Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1b0b487
fix: improve type hinting and ensure boolean conversion for desk access
harshtandiya Jan 7, 2026
1984a5a
feat: get user submission api endpoint
harshtandiya Jan 7, 2026
41b7152
feat: share form submission with owner upon creation
harshtandiya Jan 7, 2026
c109f51
feat: add PreviousSubmissionSection component to display user submiss…
harshtandiya Jan 7, 2026
5e81f77
chore: update component.d.ts
harshtandiya Jan 7, 2026
75dc1f5
feat: add submission status field to forms and handle custom field cr…
harshtandiya Jan 7, 2026
d1e0c5b
feat: enhance form submission handling with status management and err…
harshtandiya Jan 7, 2026
9522bc5
feat: add submission status display to PreviousSubmissionSection
harshtandiya Jan 7, 2026
186046d
chore: make previous submission modal an accordian
harshtandiya Jan 7, 2026
f9880cb
fix: footer
harshtandiya Jan 7, 2026
1afa35b
refactor: rename submission status field and update form submission l…
harshtandiya Jan 7, 2026
efb357b
feat: handle unpublished forms in ui
harshtandiya Jan 7, 2026
b1ddfef
feat: add validation for unpublished forms in submission process
harshtandiya Jan 7, 2026
76919a9
refactor: simplify AccordionTrigger and adjust spacing in PreviousSub…
harshtandiya Jan 7, 2026
f138be4
feat: add linked form field to submissions and implement retrieval fu…
harshtandiya Jan 8, 2026
20b6e7f
feat: public submission edit v1
harshtandiya Jan 8, 2026
2339760
fix: lint
harshtandiya Jan 8, 2026
0fc285b
fix: enable form renderer for submission page
harshtandiya Jan 8, 2026
33a9bab
fix: update export syntax in components.d.ts
harshtandiya Jan 8, 2026
fa98d2c
fix: test
harshtandiya Jan 8, 2026
f0793e2
fix: better code
harshtandiya Jan 8, 2026
46aa1c2
fix: better code
harshtandiya Jan 8, 2026
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
1 change: 1 addition & 0 deletions forms_pro/api/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down
133 changes: 126 additions & 7 deletions forms_pro/api/submission.py
Original file line number Diff line number Diff line change
@@ -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",
)
Comment on lines +104 to +109
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing filter for fp_linked_form may return unrelated submissions.

The query retrieves all submissions from linked_doctype owned by the user, but doesn't filter by the specific form. If multiple forms share the same linked_doctype, this will return submissions from all forms rather than just the requested form_id.

🐛 Proposed fix
     submissions = frappe.get_all(
         doctype=linked_doctype,
-        filters={"owner": frappe.session.user},
+        filters={"owner": frappe.session.user, "fp_linked_form": form_id},
         fields=["name", "creation", "modified", "fp_submission_status"],
         order_by="creation",
     )
🤖 Prompt for AI Agents
In @forms_pro/api/submission.py around lines 104 - 109, The current
frappe.get_all call for variable submissions queries linked_doctype by owner
only and can return submissions from other forms; update the filters in the
frappe.get_all call (the get_all invocation that uses linked_doctype and fields
["name","creation","modified","fp_submission_status"]) to include fp_linked_form
equal to the requested form_id so only submissions for that specific form are
returned.


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()
5 changes: 3 additions & 2 deletions forms_pro/api/user.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()

Expand Down
64 changes: 64 additions & 0 deletions forms_pro/utils/form_generator.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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,
}
Comment on lines +67 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing mandatory key in LINKED_FORM_FIELDOPTIONS.

The test test_linked_form_field_is_added_custom_doctype (line 262) and test_linked_form_field_is_added_core_doctype (line 342) assert linked_form_field.mandatory == LINKED_FORM_FIELDOPTIONS["mandatory"], but this key is not defined in the options dict, which will cause a KeyError.

 LINKED_FORM_FIELDOPTIONS = {
     "label": "Linked Form",
     "fieldname": "fp_linked_form",
     "fieldtype": "Link",
     "options": "Form",
     "read_only": 1,
+    "mandatory": 0,
+    "in_list_view": 0,
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
LINKED_FORM_FIELDOPTIONS = {
"label": "Linked Form",
"fieldname": "fp_linked_form",
"fieldtype": "Link",
"options": "Form",
"read_only": 1,
}
LINKED_FORM_FIELDOPTIONS = {
"label": "Linked Form",
"fieldname": "fp_linked_form",
"fieldtype": "Link",
"options": "Form",
"read_only": 1,
"mandatory": 0,
"in_list_view": 0,
}
🤖 Prompt for AI Agents
In @forms_pro/utils/form_generator.py around lines 67 - 73, The
LINKED_FORM_FIELDOPTIONS dict is missing the "mandatory" key referenced by
tests; update LINKED_FORM_FIELDOPTIONS (the constant defined in
form_generator.py) to include a "mandatory" entry (e.g., "mandatory": 0) using
the same type convention used elsewhere (int or bool) so
linked_form_field.mandatory matches LINKED_FORM_FIELDOPTIONS["mandatory"] in the
tests.


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,
Expand All @@ -66,6 +96,7 @@ def __init__(

def generate(self) -> None:
self._initialize_doctype()
self._add_status_field()
self._initialize_form_document()
frappe.clear_cache()

Expand Down Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions forms_pro/utils/test_form_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
4 changes: 1 addition & 3 deletions frontend/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,4 @@
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {

}
declare global {}
Loading
Loading