Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions forms_pro/api/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class UserSubmissionResponse(BaseModel):
alias="fp_submission_status",
default=SubmissionStatus.SUBMITTED.value,
)
owner: str = Field(description="Owner of the submission")

@field_validator("creation", "modified", mode="before")
@classmethod
Expand Down Expand Up @@ -104,17 +105,51 @@ def get_user_submissions(form_id: str) -> list[UserSubmissionResponse]:
submissions = frappe.get_all(
doctype=linked_doctype,
filters={"owner": frappe.session.user},
fields=["name", "creation", "modified", "fp_submission_status"],
fields=["name", "creation", "modified", "fp_submission_status", "owner"],
order_by="creation",
)

return [UserSubmissionResponse.model_validate(submission).model_dump() for submission in submissions]


@frappe.whitelist()
def get_submission_response(submission_id: str, doctype: str) -> dict[str, Any]:
"""
Get a full submission response by ID.
This API checks if the user is the team member / the form is shared with the user.

Args:
submission_id: The name/ID of the submission document
doctype: The submission's doctype (linked_doctype of the form)

Returns:
The submission document as a dict
"""
linked_form = frappe.db.get_value(doctype, submission_id, "fp_linked_form")
if not linked_form:
frappe.throw(_("Submission not found."), frappe.DoesNotExistError)

form_data = frappe.db.get_value("Form", linked_form, ["linked_team_id", "linked_doctype"], as_dict=True)

if form_data.linked_doctype != doctype:
frappe.throw(_("Invalid doctype for this submission."), frappe.PermissionError)

if not frappe.has_permission(
doctype="FP Team", ptype="write", doc=form_data.linked_team_id, user=frappe.session.user
):
frappe.throw(
_("You do not have permission to read this form's submissions."),
frappe.PermissionError,
)

submission = frappe.get_doc(form_data.linked_doctype, submission_id)
return submission.as_dict()
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@frappe.whitelist()
def get_submission(submission_doctype: str, submission_name: str) -> dict[str, Any]:
"""
Get a submission by name
Get a submission by name. Used usually by the form owner to view the submission details.

Args:
submission_name: The name of the submission
Expand All @@ -131,3 +166,38 @@ def get_submission(submission_doctype: str, submission_name: str) -> dict[str, A
)

return submission.as_dict()


@frappe.whitelist()
def get_all_submissions(form_id: str) -> list[UserSubmissionResponse]:
"""
Get all submissions for a form

Args:
form_id: The ID of the form

Returns:
A list of submissions for the form
"""
linked_team = frappe.db.get_value("Form", form_id, "linked_team_id")

if not linked_team:
frappe.throw(_("Form not found."), frappe.DoesNotExistError)

if not frappe.has_permission(doctype="FP Team", ptype="write", doc=linked_team, user=frappe.session.user):
frappe.throw(
_("You do not have permission to read this form's submissions."),
frappe.PermissionError,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

form: Form = frappe.get_doc("Form", form_id)
linked_doctype = form.linked_doctype

submissions = frappe.get_all(
doctype=linked_doctype,
fields=["name", "creation", "modified", "fp_submission_status", "owner"],
filters={"fp_submission_status": "Submitted"},
order_by="creation",
)

return [UserSubmissionResponse.model_validate(submission).model_dump() for submission in submissions]
94 changes: 94 additions & 0 deletions frontend/src/components/form/submissions/SubmissionDetails.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { createResource } from "frappe-ui";
import { useManageForm } from "@/stores/form/manageForm";
import { computed, watch } from "vue";
import { toast } from "vue-sonner";
import { formatDateTime } from "@/utils/date";
import SubmissionFieldValue from "@/components/form/submissions/SubmissionFieldValue.vue";

const props = defineProps<{
submissionId: string;
doctype: string;
}>();

const manageFormStore = useManageForm();

const submissionResource = createResource({
url: "forms_pro.api.submission.get_submission_response",
makeParams() {
return {
submission_id: props.submissionId,
doctype: props.doctype,
};
},
onError(error: Error) {
toast.error("Failed to fetch submission details", {
description: error.message,
});
},
});

const submissionData = computed(() => submissionResource.data ?? null);

watch(
() => props.submissionId,
(newId) => {
if (newId) {
submissionResource.fetch();
}
},
{ immediate: true }
);

const gridItems = computed(() => [
{
label: "Submission ID",
key: "name",
value: submissionData.value?.name,
class: "font-mono",
},
{
label: "Submitted On",
key: "creation",
value: formatDateTime(submissionData.value?.creation),
},
{
label: "Last Modified",
key: "modified",
value: formatDateTime(submissionData.value?.modified),
},
{
label: "Submitted By",
key: "owner",
value: submissionData.value?.owner,
},
]);
</script>

<template>
<div v-if="submissionResource.loading" class="text-sm text-ink-gray-5">Loading...</div>
<div v-else-if="submissionData" class="flex flex-col gap-4">
<div class="p-2 flex flex-col gap-4">
<div class="grid grid-cols-3 gap-4">
<template v-for="item in gridItems" :key="item.key">
<span class="text-sm text-ink-gray-5">{{ item.label }}</span>
<span class="text-sm text-ink-gray-7 col-span-2" :class="item.class">{{
item.value
}}</span>
</template>
</div>
<hr />
<div class="flex flex-col gap-6 my-4">
<SubmissionFieldValue
v-for="field in manageFormStore.formFields"
:key="field.name"
:fieldname="field.fieldname"
:label="field.label"
:description="field.description"
:fieldtype="field.fieldtype"
:value="submissionData[field.fieldname]"
/>
</div>
</div>
</div>
</template>
111 changes: 111 additions & 0 deletions frontend/src/components/form/submissions/SubmissionFieldValue.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { Checkbox, Switch, Rating, TextEditor } from "frappe-ui";
import { FormFieldTypes } from "@/types/formfield";
import { formatDate, formatDateTime, formatTime } from "@/utils/date";
import { computed } from "vue";

const props = defineProps<{
fieldname: string;
label: string;
description?: string;
fieldtype: FormFieldTypes;
value: any;
}>();

const formattedDateValue = computed(() => {
if (!props.value) return "";
switch (props.fieldtype) {
case FormFieldTypes.Date:
return formatDate(props.value);
case FormFieldTypes.DateTime:
return formatDateTime(props.value);
case FormFieldTypes.TimePicker:
return formatTime(props.value);
case FormFieldTypes.DateRange:
try {
const dates = JSON.parse(props.value);
if (Array.isArray(dates) && dates.length === 2) {
return `${formatDate(dates[0])} – ${formatDate(dates[1])}`;
}
} catch {
/* value might already be a readable string */
}
return String(props.value);
default:
return String(props.value);
}
});

const isDateField = computed(() =>
[
FormFieldTypes.Date,
FormFieldTypes.DateTime,
FormFieldTypes.DateRange,
FormFieldTypes.TimePicker,
].includes(props.fieldtype)
);

const classNames = computed<string>(() => {
if ([FormFieldTypes.Switch, FormFieldTypes.Checkbox].includes(props.fieldtype)) {
return "flex gap-1 flex-row-reverse items-start justify-end";
}
return "flex flex-col gap-1";
});
</script>

<template>
<div :class="classNames">
<div>
<span class="text-sm text-ink-gray-5">{{ label }}</span>
<p v-if="description" class="text-xs text-ink-gray-4">{{ description }}</p>
</div>

<Checkbox
v-if="fieldtype === FormFieldTypes.Checkbox"
class="mt-1"
:modelValue="Boolean(value)"
disabled
/>

<Switch
v-else-if="fieldtype === FormFieldTypes.Switch"
:modelValue="Boolean(value)"
disabled
/>

<TextEditor
v-else-if="fieldtype === FormFieldTypes.TextEditor"
:content="value"
:editable="false"
:bubbleMenu="false"
:fixedMenu="false"
editorClass="prose-sm !border-none !p-0 !shadow-none"
/>

<Rating v-else-if="fieldtype === FormFieldTypes.Rating" :modelValue="value" readonly />

<a
v-else-if="fieldtype === FormFieldTypes.Attach && value"
:href="value"
target="_blank"
class="text-sm text-blue-600 hover:text-blue-700 underline truncate"
>
{{ value }}
</a>

<span
v-else-if="fieldtype === FormFieldTypes.Textarea"
class="text-sm text-ink-gray-7 whitespace-pre-wrap"
>
{{ value ?? "–" }}
</span>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<span v-else-if="isDateField" class="text-sm text-ink-gray-7">
{{ formattedDateValue }}
</span>

<span v-else class="text-sm text-ink-gray-7">
{{ value ?? "–" }}
</span>
</div>
</template>
Loading
Loading