diff --git a/forms_pro/api/form.py b/forms_pro/api/form.py index 05e9df9..ca1c048 100644 --- a/forms_pro/api/form.py +++ b/forms_pro/api/form.py @@ -56,6 +56,7 @@ def get_form(form_id: str) -> dict: "route": form.route, "is_published": form.is_published, "allow_incomplete": form.allow_incomplete, + "linked_doctype": form.linked_doctype, } diff --git a/forms_pro/api/submission.py b/forms_pro/api/submission.py index a4d892d..04ff1cb 100644 --- a/forms_pro/api/submission.py +++ b/forms_pro/api/submission.py @@ -1,14 +1,133 @@ +from datetime import datetime +from typing import Any + import frappe +from frappe import _ +from frappe.share import add_docshare +from pydantic import BaseModel, Field, field_validator + +from forms_pro.forms_pro.doctype.form.form import Form +from forms_pro.utils.form_generator import SubmissionStatus + + +class UserSubmissionResponse(BaseModel): + name: str = Field(description="Name of the submission") + creation: datetime = Field(description="Creation date of the submission") + modified: datetime = Field(description="Last modified date of the submission") + submission_status: str = Field( + description="Status of the submission", + alias="fp_submission_status", + default=SubmissionStatus.SUBMITTED.value, + ) + + @field_validator("creation", "modified", mode="before") + @classmethod + def parse_datetime(cls, v: Any) -> datetime: + """Convert datetime string to datetime object.""" + if isinstance(v, str): + return frappe.utils.get_datetime(v) + if isinstance(v, datetime): + return v + raise ValueError(f"Invalid datetime value: {v}") @frappe.whitelist(allow_guest=True) -def submit_form_response(form_id: str, form_data: list[dict]): - form = frappe.get_doc("Form", form_id) +def submit_form_response( + form_id: str, + form_data: list[dict], + submission_status: SubmissionStatus = SubmissionStatus.SUBMITTED, +) -> str: + """ + Submit a form response + + Args: + form_id: The ID of the form + form_data: The data of the form + submission_status: The status of the submission: Default is `Submitted` + + Returns: + The name of the submission + """ + try: + form: Form = frappe.get_doc("Form", form_id) + linked_doctype = form.linked_doctype + + if not form.is_published: + frappe.throw( + _("This form is un-published, so responses are no longer being collected."), + frappe.PermissionError, + ) + + submission = frappe.new_doc(linked_doctype) + for data in form_data: + submission.set(data["fieldname"], data["value"]) + + submission.fp_linked_form = form_id + submission.fp_submission_status = submission_status.value + submission.insert(ignore_permissions=True) + + # Share the submission with the owner + add_docshare( + doctype=linked_doctype, + name=submission.name, + user=frappe.session.user, + read=1, + write=1, + flags={ + "ignore_share_permission": True, + }, + ) + + return submission.name + except Exception: + raise + + +@frappe.whitelist() +def get_user_submissions(form_id: str) -> list[UserSubmissionResponse]: + """ + Get the submissions for a user + + Args: + form_id: The ID of the form + + Returns: + A list of submissions for the user + """ + + if frappe.session.user == "Guest": + return [] + + form: Form = frappe.get_doc("Form", form_id) linked_doctype = form.linked_doctype - submission = frappe.new_doc(linked_doctype) - for data in form_data: - submission.set(data["fieldname"], data["value"]) - submission.insert(ignore_permissions=True) + submissions = frappe.get_all( + doctype=linked_doctype, + filters={"owner": frappe.session.user}, + fields=["name", "creation", "modified", "fp_submission_status"], + order_by="creation", + ) + + return [UserSubmissionResponse.model_validate(submission).model_dump() for submission in submissions] + + +@frappe.whitelist() +def get_submission(submission_doctype: str, submission_name: str) -> dict[str, Any]: + """ + Get a submission by name + + Args: + submission_name: The name of the submission + + Returns: + The submission + """ + submission = frappe.get_doc(submission_doctype, submission_name) + + if not frappe.has_permission(doctype=submission.doctype, ptype="read", doc=submission.name): + frappe.throw( + _("You do not have permission to read this submission."), + frappe.PermissionError, + ) - return submission.name + return submission.as_dict() diff --git a/forms_pro/api/user.py b/forms_pro/api/user.py index 11c49f7..d43d1e4 100644 --- a/forms_pro/api/user.py +++ b/forms_pro/api/user.py @@ -1,5 +1,6 @@ import frappe from frappe.core.doctype.has_role.has_role import HasRole +from frappe.core.doctype.user.user import User from pydantic import BaseModel, Field, field_validator from forms_pro.utils.teams import get_user_teams as get_user_teams_utils @@ -51,10 +52,10 @@ def get_current_user() -> GetUserResponseSchema: """ user_id = frappe.session.user - user_doc = frappe.get_doc("User", user_id) + user_doc: User = frappe.get_doc("User", user_id) data = user_doc.as_dict() data["roles"] = user_doc.get("roles") - data["has_desk_access"] = user_doc.has_desk_access() + data["has_desk_access"] = bool(user_doc.has_desk_access()) return GetUserResponseSchema.model_validate(data).model_dump() diff --git a/forms_pro/utils/form_generator.py b/forms_pro/utils/form_generator.py index e570a9d..8bae1ba 100644 --- a/forms_pro/utils/form_generator.py +++ b/forms_pro/utils/form_generator.py @@ -1,5 +1,8 @@ +from enum import Enum + import frappe from frappe import _ +from frappe.custom.doctype.custom_field.custom_field import CustomField from frappe.share import add_docshare FORMS_PRO_ROLE = "Forms Pro User" @@ -53,6 +56,33 @@ def create_form(team_id: str): } +USER_FORM_MODULE = "User Forms" + + +class SubmissionStatus(str, Enum): + DRAFT = "Draft" + SUBMITTED = "Submitted" + + +LINKED_FORM_FIELDOPTIONS = { + "label": "Linked Form", + "fieldname": "fp_linked_form", + "fieldtype": "Link", + "options": "Form", + "read_only": 1, +} + +SUBMISSION_STATUS_FIELDOPTIONS = { + "label": "Submission Status (Form)", + "fieldname": "fp_submission_status", + "fieldtype": "Select", + "options": "\n".join([status.value for status in SubmissionStatus]), + "default": SubmissionStatus.DRAFT.value, + "read_only": 1, + "in_list_view": 1, +} + + class FormGenerator: def __init__( self, @@ -66,6 +96,7 @@ def __init__( def generate(self) -> None: self._initialize_doctype() + self._add_status_field() self._initialize_form_document() frappe.clear_cache() @@ -124,6 +155,39 @@ def _initialize_doctype(self) -> None: self.doctype = placeholder_doctype + def _add_status_field(self) -> None: + # If the doctype is not custom, add a `Custom Field` to the doctype + if not self.doctype.custom: + self._add_custom_field_for_status() + self._add_custom_field_for_linked_form() + + # If the doctype is custom, we can go ahead and append the field directly to the doctype + self.doctype.append( + "fields", + SUBMISSION_STATUS_FIELDOPTIONS, + ) + + # Add the linked form field + self.doctype.append( + "fields", + LINKED_FORM_FIELDOPTIONS, + ) + self.doctype.save(ignore_permissions=True) + + def _add_custom_field_for_status(self) -> None: + custom_field: CustomField = frappe.new_doc("Custom Field") + custom_field.update(SUBMISSION_STATUS_FIELDOPTIONS) + custom_field.dt = self.doctype.name + custom_field.is_system_generated = True + custom_field.insert(ignore_permissions=True) + + def _add_custom_field_for_linked_form(self) -> None: + custom_field: CustomField = frappe.new_doc("Custom Field") + custom_field.update(LINKED_FORM_FIELDOPTIONS) + custom_field.dt = self.doctype.name + custom_field.is_system_generated = True + custom_field.insert(ignore_permissions=True) + def _initialize_form_document(self) -> None: form_document = frappe.new_doc("Form") form_document.linked_doctype = self.doctype.name diff --git a/forms_pro/utils/test_form_generator.py b/forms_pro/utils/test_form_generator.py index 2998165..bd48936 100644 --- a/forms_pro/utils/test_form_generator.py +++ b/forms_pro/utils/test_form_generator.py @@ -211,3 +211,128 @@ def test_generate_creates_form_docshare(self): self.assertEqual(docshare.write, 1, "DocShare should have write permission") self.assertEqual(docshare.share, 1, "DocShare should have share permission") self.assertEqual(docshare.submit, 0, "DocShare should not have submit permission") + + def test_status_field_is_added_custom_doctype(self): + """Test that status field is added to custom doctype""" + from forms_pro.utils.form_generator import SUBMISSION_STATUS_FIELDOPTIONS + + frappe.set_user(self.test_user) + form_generator = FormGenerator(team_id=self.test_team) + form_generator.generate() + + frappe.set_user("Administrator") + # Assertions + self.assertIsNotNone(form_generator.doctype.fields) + status_field = next( + field + for field in form_generator.doctype.fields + if field.fieldname == SUBMISSION_STATUS_FIELDOPTIONS["fieldname"] + ) + assert status_field is not None, "Status field should be added to doctype" + self.assertEqual(status_field.label, SUBMISSION_STATUS_FIELDOPTIONS["label"]) + self.assertEqual(status_field.fieldname, SUBMISSION_STATUS_FIELDOPTIONS["fieldname"]) + self.assertEqual(status_field.fieldtype, SUBMISSION_STATUS_FIELDOPTIONS["fieldtype"]) + self.assertEqual(status_field.options, SUBMISSION_STATUS_FIELDOPTIONS["options"]) + self.assertEqual(status_field.default, SUBMISSION_STATUS_FIELDOPTIONS["default"]) + self.assertEqual(status_field.read_only, SUBMISSION_STATUS_FIELDOPTIONS["read_only"]) + self.assertEqual(status_field.in_list_view, SUBMISSION_STATUS_FIELDOPTIONS["in_list_view"]) + + def test_linked_form_field_is_added_custom_doctype(self): + """Test that linked form field is added to custom doctype""" + from forms_pro.utils.form_generator import LINKED_FORM_FIELDOPTIONS + + frappe.set_user(self.test_user) + form_generator = FormGenerator(team_id=self.test_team) + form_generator.generate() + + frappe.set_user("Administrator") + self.assertIsNotNone(form_generator.doctype.fields) + linked_form_field = next( + field + for field in form_generator.doctype.fields + if field.fieldname == LINKED_FORM_FIELDOPTIONS["fieldname"] + ) + assert linked_form_field is not None, "Linked form field should be added to doctype" + self.assertEqual(linked_form_field.label, LINKED_FORM_FIELDOPTIONS["label"]) + self.assertEqual(linked_form_field.fieldname, LINKED_FORM_FIELDOPTIONS["fieldname"]) + self.assertEqual(linked_form_field.fieldtype, LINKED_FORM_FIELDOPTIONS["fieldtype"]) + self.assertEqual(linked_form_field.options, LINKED_FORM_FIELDOPTIONS["options"]) + self.assertEqual(linked_form_field.read_only, LINKED_FORM_FIELDOPTIONS["read_only"]) + + def test_status_field_is_added_core_doctype(self): + """Test that status field is added to core doctype as a custom field""" + from forms_pro.utils.form_generator import SUBMISSION_STATUS_FIELDOPTIONS + + test_doctype = frappe.new_doc("DocType") + test_doctype.name = "Test Status Field Doctype" + frappe.utils.random_string(8) + test_doctype.module = "User Forms" + test_doctype.custom = False + test_doctype.insert(ignore_permissions=True) + + frappe.set_user(self.test_user) + form_generator = FormGenerator(linked_doctype=test_doctype.name, team_id=self.test_team) + form_generator.generate() + + # Assertions + frappe.set_user("Administrator") + self.assertIsNotNone(form_generator.doctype.fields) + status_field = next( + field + for field in form_generator.doctype.fields + if field.fieldname == SUBMISSION_STATUS_FIELDOPTIONS["fieldname"] + ) + assert status_field is not None, "Status field should be added to doctype" + + custom_field_id = frappe.db.exists( + "Custom Field", + {"dt": test_doctype.name, "fieldname": SUBMISSION_STATUS_FIELDOPTIONS["fieldname"]}, + ) + self.assertIsNotNone(custom_field_id, "Custom field should be created for doctype") + + custom_field = frappe.get_doc("Custom Field", custom_field_id) + + self.assertEqual(custom_field.dt, test_doctype.name) + self.assertEqual(custom_field.fieldname, SUBMISSION_STATUS_FIELDOPTIONS["fieldname"]) + self.assertEqual(custom_field.fieldtype, SUBMISSION_STATUS_FIELDOPTIONS["fieldtype"]) + self.assertEqual(custom_field.options, SUBMISSION_STATUS_FIELDOPTIONS["options"]) + self.assertEqual(custom_field.default, SUBMISSION_STATUS_FIELDOPTIONS["default"]) + self.assertEqual(custom_field.read_only, SUBMISSION_STATUS_FIELDOPTIONS["read_only"]) + self.assertEqual(custom_field.in_list_view, SUBMISSION_STATUS_FIELDOPTIONS["in_list_view"]) + + def test_linked_form_field_is_added_core_doctype(self): + """Test that linked form field is added to core doctype as a custom field""" + from forms_pro.utils.form_generator import LINKED_FORM_FIELDOPTIONS + + test_doctype = frappe.new_doc("DocType") + test_doctype.name = "Test Linked Form Doctype" + frappe.utils.random_string(8) + test_doctype.module = "User Forms" + test_doctype.custom = False + test_doctype.insert(ignore_permissions=True) + + frappe.set_user(self.test_user) + form_generator = FormGenerator(linked_doctype=test_doctype.name, team_id=self.test_team) + form_generator.generate() + + # Assertions + frappe.set_user("Administrator") + self.assertIsNotNone(form_generator.doctype.fields) + linked_form_field = next( + field + for field in form_generator.doctype.fields + if field.fieldname == LINKED_FORM_FIELDOPTIONS["fieldname"] + ) + assert linked_form_field is not None, "Linked form field should be added to doctype" + + custom_field_id = frappe.db.exists( + "Custom Field", + {"dt": test_doctype.name, "fieldname": LINKED_FORM_FIELDOPTIONS["fieldname"]}, + ) + self.assertIsNotNone(custom_field_id, "Custom field should be created for doctype") + + custom_field = frappe.get_doc("Custom Field", custom_field_id) + + self.assertEqual(custom_field.dt, test_doctype.name) + self.assertEqual(custom_field.fieldname, LINKED_FORM_FIELDOPTIONS["fieldname"]) + self.assertEqual(custom_field.fieldtype, LINKED_FORM_FIELDOPTIONS["fieldtype"]) + self.assertEqual(custom_field.options, LINKED_FORM_FIELDOPTIONS["options"]) + self.assertEqual(custom_field.read_only, LINKED_FORM_FIELDOPTIONS["read_only"]) diff --git a/frontend/auto-imports.d.ts b/frontend/auto-imports.d.ts index 9d24007..b0b0357 100644 --- a/frontend/auto-imports.d.ts +++ b/frontend/auto-imports.d.ts @@ -5,6 +5,4 @@ // Generated by unplugin-auto-import // biome-ignore lint: disable export {} -declare global { - -} +declare global {} diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 70ead57..f7a0e18 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -3,7 +3,7 @@ // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 // biome-ignore lint: disable -export {} +export {}; /* prettier-ignore */ declare module 'vue' { @@ -13,6 +13,8 @@ declare module 'vue' { Avatar: typeof import('./src/components/ui/Avatar.vue')['default'] DescriptionSection: typeof import('./src/components/form/manage/DescriptionSection.vue')['default'] DoctypeFieldsSection: typeof import('./src/components/builder/sidebar/DoctypeFieldsSection.vue')['default'] + EditFormRender: typeof import('./src/components/submission/EditFormRender.vue')['default'] + EditSubmissionCommon: typeof import('./src/components/submission/EditSubmissionCommon.vue')['default'] FieldEditorSidebar: typeof import('./src/components/FieldEditorSidebar.vue')['default'] FieldPropertiesForm: typeof import('./src/components/builder/FieldPropertiesForm.vue')['default'] FieldRenderer: typeof import('./src/components/builder/FieldRenderer.vue')['default'] @@ -22,7 +24,9 @@ declare module 'vue' { FormHeader: typeof import('./src/components/submission/FormHeader.vue')['default'] FormPreviewCard: typeof import('./src/components/dashboard/FormPreviewCard.vue')['default'] FormRenderer: typeof import('./src/components/submission/FormRenderer.vue')['default'] + FormUnpublishedState: typeof import('./src/components/submission/FormUnpublishedState.vue')['default'] PageHeader: typeof import('./src/components/submission/PageHeader.vue')['default'] + PreviousSubmissionSection: typeof import('./src/components/submission/PreviousSubmissionSection.vue')['default'] RemoveAccessModal: typeof import('./src/components/form/manage/RemoveAccessModal.vue')['default'] RenderField: typeof import('./src/components/RenderField.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/frontend/src/components/FormBuilderHeader.vue b/frontend/src/components/FormBuilderHeader.vue index 999de30..512d0a9 100644 --- a/frontend/src/components/FormBuilderHeader.vue +++ b/frontend/src/components/FormBuilderHeader.vue @@ -1,6 +1,6 @@ - + - - Save as draft - - { - submissionFormStore.submitForm(); - } - " - :loading="submissionFormStore.isLoading" - > - Submit - + + + Save as draft + + + Submit + + diff --git a/frontend/src/components/submission/FormUnpublishedState.vue b/frontend/src/components/submission/FormUnpublishedState.vue new file mode 100644 index 0000000..e1bea2f --- /dev/null +++ b/frontend/src/components/submission/FormUnpublishedState.vue @@ -0,0 +1,14 @@ + + + + + + Form is un-published! + + The form has been unpublished by its creator, so responses are no longer being + collected. + + + diff --git a/frontend/src/components/submission/PreviousSubmissionSection.vue b/frontend/src/components/submission/PreviousSubmissionSection.vue new file mode 100644 index 0000000..ad05571 --- /dev/null +++ b/frontend/src/components/submission/PreviousSubmissionSection.vue @@ -0,0 +1,82 @@ + + + + + + + + Previous Submissions + + Here are your previous submissions for this form. + + + + + + + + + Submission #{{ index + 1 }} + + + + + Modified {{ formatDateTime(submission.modified) }} + • + Created {{ formatDateTime(submission.creation) }} + + + Created {{ formatDateTime(submission.creation) }} + + + + + + + + + diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e868ef..685118f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -36,3 +36,7 @@ @apply text-base font-normal; } } + +.form-container-simple { + @apply space-y-4 shadow-[0_0_10px_0_rgba(0,0,0,0.1)] bg-surface-white border rounded-lg p-6 max-w-screen-md mx-auto; +} diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue index bed85a0..e6297ad 100644 --- a/frontend/src/pages/Dashboard.vue +++ b/frontend/src/pages/Dashboard.vue @@ -149,7 +149,7 @@ watch( teamForms.fetch(); } }, - { immediate: true }, + { immediate: true } ); watch( @@ -159,6 +159,6 @@ watch( doctypesList.fetch(); } }, - { immediate: true }, + { immediate: true } ); diff --git a/frontend/src/pages/SubmissionPage.vue b/frontend/src/pages/SubmissionPage.vue index cb76ebc..e8db264 100644 --- a/frontend/src/pages/SubmissionPage.vue +++ b/frontend/src/pages/SubmissionPage.vue @@ -5,27 +5,32 @@ import FormHeader from "@/components/submission/FormHeader.vue"; import FormRenderer from "@/components/submission/FormRenderer.vue"; import Logo from "@/assets/Logo.vue"; import PageHeader from "@/components/submission/PageHeader.vue"; +import PreviousSubmissionSection from "@/components/submission/PreviousSubmissionSection.vue"; const route = useRoute(); const submissionFormStore = useSubmissionForm(); submissionFormStore.initialize(route.params.route as string); - + - - - + + + + - + - + - - + + Built on +import PageHeader from "@/components/submission/PageHeader.vue"; +import { Alert, Badge, LoadingText, Button } from "frappe-ui"; +import { useRoute } from "vue-router"; +import { useSubmissionForm } from "@/stores/submissionForm"; +import { useEditSubmission } from "@/stores/editSubmission"; +import { computed, watch } from "vue"; +import FormRenderer from "@/components/submission/FormRenderer.vue"; +import { CircleDashed } from "lucide-vue-next"; +import { formatDateTime } from "@/utils/date"; + +const route = useRoute(); +const submissionFormStore = useSubmissionForm(); +const editSubmissionStore = useEditSubmission(); + +submissionFormStore.initialize(route.params.route as string); +watch( + () => submissionFormStore.formResource.data, + () => { + if (submissionFormStore.formResource.data) { + editSubmissionStore.initialize( + submissionFormStore.formResource.data?.linked_doctype, + route.params.submissionName as string + ); + } + }, + { immediate: true } +); + +watch( + () => editSubmissionStore.submission, + () => { + if (editSubmissionStore.submission) { + Object.keys(submissionFormStore.fields).forEach((key) => { + const matchingField = editSubmissionStore.submission[key]; + if (matchingField !== undefined) { + submissionFormStore.fields[key] = matchingField; + } + }); + } + }, + { immediate: true } +); + +const isDirty = computed(() => { + return Object.keys(submissionFormStore.fields).some((key) => { + return submissionFormStore.fields[key] !== editSubmissionStore.submission[key]; + }); +}); + + + + + + + + + + Edit Submission + + {{ submissionFormStore.formResource.data?.title }} + + + + + + Submission Details + + Status + + + + Submission ID: + + {{ editSubmissionStore.submission?.name }} + + + + Last Modified: + {{ formatDateTime(editSubmissionStore.submission?.modified) }} + • + Created: + {{ formatDateTime(editSubmissionStore.submission?.creation) }} + + + + + + + + + Actions are disabled because the form is no longer live. + + + + { + submissionFormStore.validateValues(); + if (submissionFormStore.errors.length > 0) { + return; + } + editSubmissionStore.updateForm( + submissionFormStore.fields + ); + } + " + :loading="editSubmissionStore.submissionResource.loading" + /> + { + submissionFormStore.validateValues(); + if (submissionFormStore.errors.length > 0) { + return; + } + editSubmissionStore.submitForm(); + } + " + :loading="editSubmissionStore.submissionResource.loading" + :tooltip=" + isDirty + ? 'You have unsaved changes. Please update the form before submitting.' + : '' + " + /> + + + Update & Submit + + + + + + + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index b932d1e..1e06ef3 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -42,6 +42,11 @@ const routes: RouteRecordRaw[] = [ return true; }, }, + { + path: "/p/:route(.*)/edit/:submissionName", + name: "Public Edit Submission Page", + component: () => import("@/pages/submission/PublicEdit.vue"), + }, ]; const router = createRouter({ diff --git a/frontend/src/stores/editSubmission.ts b/frontend/src/stores/editSubmission.ts new file mode 100644 index 0000000..6de26c7 --- /dev/null +++ b/frontend/src/stores/editSubmission.ts @@ -0,0 +1,103 @@ +import { createDocumentResource, createResource } from "frappe-ui"; +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { toast } from "vue-sonner"; + +export const useEditSubmission = defineStore("editSubmission", () => { + const submissionDoctype = ref(null); + const submissionName = ref(null); + const submissionResource = ref(null); + const submission = computed(() => submissionResource.value?.doc || null); + const isDraft = computed( + () => submission.value?.fp_submission_status == "Draft" + ); + const isSubmitted = computed( + () => submission.value?.fp_submission_status == "Submitted" + ); + + const isLoading = ref(true); + + async function initialize(doctype: string, name: string) { + isLoading.value = true; + submissionDoctype.value = doctype; + submissionName.value = name; + submissionResource.value = createDocumentResource({ + doctype: submissionDoctype.value, + name: submissionName.value, + }); + + isLoading.value = false; + } + + function convertToDraft() { + submissionResource.value.setValue.submit( + { + fp_submission_status: "Draft", + }, + { + onSuccess: () => { + toast.success("Submission converted to draft"); + }, + onError: () => { + toast.error("Failed to convert submission to draft"); + }, + } + ); + } + + function updateForm(data: Record): Promise { + return new Promise((resolve, reject) => { + submissionResource.value.setValue.submit(data, { + onSuccess: () => { + toast.success("Your response has been updated"); + resolve(); + }, + onError: () => { + toast.error("Failed to update your response!"); + reject(new Error("Failed to update submission")); + }, + }); + }); + } + + async function updateAndSubmitForm(data: Record) { + try { + await updateForm(data); + submitForm(); + } catch (error) { + // Error already handled in updateForm's onError callback + console.error("Error updating form before submission:", error); + } + } + + function submitForm() { + submissionResource.value.setValue.submit( + { + fp_submission_status: "Submitted", + }, + { + onSuccess: () => { + toast.success("Successfully submitted your response!"); + }, + onError: () => { + toast.error("Failed to submit!"); + }, + } + ); + } + + return { + submissionResource, + submission, + isLoading, + submissionDoctype, + submissionName, + initialize, + isDraft, + isSubmitted, + convertToDraft, + updateForm, + updateAndSubmitForm, + submitForm, + }; +}); diff --git a/frontend/src/stores/submissionForm.ts b/frontend/src/stores/submissionForm.ts index 5e447aa..7400720 100644 --- a/frontend/src/stores/submissionForm.ts +++ b/frontend/src/stores/submissionForm.ts @@ -4,21 +4,69 @@ import { defineStore } from "pinia"; import { computed, ref } from "vue"; import { FormField } from "@/types/formfield"; import { useStorage } from "@vueuse/core"; +import { session } from "@/data/session"; + +export type UserSubmission = { + name: string; + creation: string; + modified: string; +}; + +export enum SubmissionStatus { + DRAFT = "Draft", + SUBMITTED = "Submitted", +} export const useSubmissionForm = defineStore("submissionForm", () => { const formResource = ref(null); - const currentFormId = ref(null); + const currentFormRoute = ref(null); const isLoading = computed(() => formResource.value?.loading); const allowIncompleteForms = computed( () => formResource.value?.data?.allow_incomplete ); + const currentFormId = computed((): string | null => { + if (!formResource.value || !formResource.value.data) { + return null; + } + + return formResource.value.data.name; + }); + + const formIsPublished = computed((): boolean | null => { + if (!formResource.value || !formResource.value.data) { + return null; + } + + return formResource.value.data.is_published; + }); + const errors = ref([]); - const successSubmission = ref(0); - const inFormSubmission = ref(1); + const inSuccessState = ref(false); + const inFormFillingState = ref(true); const fields = ref>({}); + const userSubmissionsResource = createResource({ + url: "forms_pro.api.submission.get_user_submissions", + makeParams() { + return { + form_id: currentFormId.value, + }; + }, + auto: false, + }); + + const userSubmissions = computed((): UserSubmission[] | null => { + if ( + userSubmissionsResource.data && + userSubmissionsResource.data.length > 0 + ) { + return userSubmissionsResource.data; + } + return null; + }); + function initializeFields() { if (!formResource.value?.data) return; @@ -34,7 +82,7 @@ export const useSubmissionForm = defineStore("submissionForm", () => { } async function initialize(route: string) { - currentFormId.value = route; + currentFormRoute.value = route; formResource.value = createResource({ url: "forms_pro.api.form.get_form_by_route", params: { @@ -53,27 +101,22 @@ export const useSubmissionForm = defineStore("submissionForm", () => { fields.value = { ...fields.value, ...draftData.value }; } - inFormSubmission.value = 1; + inFormFillingState.value = true; }, }); + await formResource.value.fetch(); + if (session.isLoggedIn) { + await userSubmissionsResource.fetch(); + } } function saveAsDraft() { - errors.value = []; - - if (!formResource.value?.data?.name) { - toast.error("Form not loaded"); - return; - } - - const draftKey = `draft_submission_data_${formResource.value.data.name}`; - const draftData = useStorage(draftKey, {}, localStorage); - draftData.value = fields.value; - toast.success("Draft saved successfully"); + toast.info("Saving draft..."); + submitForm(true); } - async function submitForm() { + async function submitForm(isDraft: boolean = false) { validateValues(); if (errors.value.length > 0) { return; @@ -88,12 +131,30 @@ export const useSubmissionForm = defineStore("submissionForm", () => { fieldname: fieldname, value: value, })), + submission_status: isDraft + ? SubmissionStatus.DRAFT + : SubmissionStatus.SUBMITTED, }; }, onSuccess() { clearDraft(); - inFormSubmission.value = 0; - successSubmission.value = 1; + if (isDraft) { + toast.info("Draft saved successfully"); + inFormFillingState.value = true; + userSubmissionsResource.fetch(); + } else { + inFormFillingState.value = false; + inSuccessState.value = true; + } + }, + onError(error: any) { + toast.error("Failed to submit form"); + errors.value = error.messages?.map((message: string) => message) || []; + if (errors.value.length === 0) { + errors.value.push( + "Error while submitting form. Check the values and try again." + ); + } }, }); @@ -118,12 +179,17 @@ export const useSubmissionForm = defineStore("submissionForm", () => { return { formResource, currentFormId, + currentFormRoute, + validateValues, fields, isLoading, allowIncompleteForms, errors, - successSubmission, - inFormSubmission, + inSuccessState, + inFormFillingState, + userSubmissionsResource, + userSubmissions, + formIsPublished, initialize, submitForm, saveAsDraft, diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 2b6976f..bfeec9f 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -10,6 +10,7 @@ export default { theme: { fontFamily: { instrument: ["Instrument Serif", "serif"], + mono: ["JetBrains Mono", "monospace"], }, extend: {}, },
+ The form has been unpublished by its creator, so responses are no longer being + collected. +
+ Here are your previous submissions for this form. +