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
0c63ad2
fix(multiselect): reset option input and error message on startAdding…
harshtandiya Apr 20, 2026
624aac8
chore: better ui to remove options in multiselect (#87)
harshtandiya Apr 20, 2026
6b0b339
chore(deps-dev): bump postcss from 8.5.8 to 8.5.10 in /frontend (#95)
dependabot[bot] Apr 22, 2026
cd52b34
chore(deps): bump dayjs from 1.11.19 to 1.11.20 in /frontend (#84)
dependabot[bot] Apr 22, 2026
037ca81
chore(deps): bump @lottiefiles/dotlottie-vue in /frontend (#93)
dependabot[bot] Apr 22, 2026
08f032c
chore(deps): bump actions/setup-node from 4 to 6 (#88)
dependabot[bot] Apr 22, 2026
9b221ce
chore(deps): bump actions/checkout from 4 to 6 (#90)
dependabot[bot] Apr 22, 2026
eac9457
chore(deps): bump actions/setup-python from 5 to 6 (#91)
dependabot[bot] Apr 22, 2026
f7cb183
chore(deps): bump actions/upload-artifact from 4 to 7 (#89)
dependabot[bot] Apr 22, 2026
63a6854
ci: add merge_group trigger to enable GitHub merge queue (#97)
harshtandiya Apr 22, 2026
887b826
fix(e2e): auto-fill form title in FormBuilderPage.goto() to prevent M…
harshtandiya Apr 22, 2026
ce6105e
chore: update gitignore to ignore semgrep folder
harshtandiya Apr 22, 2026
c89833b
fix: resolve semgrep findings across codebase (#98)
harshtandiya Apr 22, 2026
dbff25a
feat: add Heading 1/2/3 field types (#103)
harshtandiya Apr 26, 2026
c349b69
refactor: redesign the form builder layout (#105)
harshtandiya Apr 26, 2026
e19e522
enhance(FieldActions): add tooltips for field actions buttons
harshtandiya Apr 26, 2026
9705e13
fix(Form): correct initial route generation string (#106)
harshtandiya Apr 26, 2026
870c496
fix: prevent duplicate fieldnames on form save (#107)
harshtandiya Apr 27, 2026
3ef9c10
chore(deps): bump vue from 3.5.32 to 3.5.33 in /frontend (#110)
dependabot[bot] Apr 28, 2026
88e1652
chore(deps-dev): bump @vitejs/plugin-vue in /frontend (#109)
dependabot[bot] Apr 28, 2026
2b9a142
fix(backport): adapt cherry-picked code to v15 lucide + dual-enum types
harshtandiya Apr 29, 2026
e61e134
fix(test): use v15-compatible FrappeTestCase import in test_form_field
harshtandiya Apr 29, 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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
- version-15
paths-ignore:
- ".github/**"
merge_group:

concurrency:
group: version-15-forms_pro-${{ github.event.number }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- version-15
paths-ignore:
- ".github/**"
merge_group:
workflow_dispatch:

permissions:
Expand All @@ -20,7 +21,7 @@ jobs:
linter:
name: 'Frappe Linter'
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'

steps:
- uses: actions/checkout@v6
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ on:
paths:
- "frontend/**"
- ".github/workflows/typecheck.yml"
merge_group:

concurrency:
group: typecheck-version-15-forms_pro-${{ github.event.number || github.sha }}
Expand Down
11 changes: 6 additions & 5 deletions .github/workflows/ui-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- develop
pull_request:
merge_group:
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -36,15 +37,15 @@ jobs:

steps:
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.14"

- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 24
check-latest: true
Expand Down Expand Up @@ -145,15 +146,15 @@ jobs:

- name: Upload HTML report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: frontend/e2e/playwright-report/
retention-days: 14

- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-results
path: frontend/e2e/test-results/
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,6 @@ frontend/e2e/test-results/
frontend/e2e/playwright-report/
.playwright-mcp

skills-lock.json
skills-lock.json

semgrep-rules/
6 changes: 3 additions & 3 deletions forms_pro/api/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class FormSharedWithResponse(BaseModel):
submit: bool


@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def is_login_required(route: str) -> bool:
"""
Check if login is enabled for a form.
Expand All @@ -42,7 +42,7 @@ def get_form_by_route(route: str) -> dict:
return get_form(form_id)


@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def get_form(form_id: str) -> dict:
form: Form = frappe.get_doc(
"Form",
Expand All @@ -62,7 +62,7 @@ def get_form(form_id: str) -> dict:
}


@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def get_link_field_options(
doctype: str,
filters: dict | None = None,
Expand Down
4 changes: 2 additions & 2 deletions forms_pro/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo


@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def get_brand_logo() -> str:
"""
Get the brand logo for the form.
Expand All @@ -13,7 +13,7 @@ def get_brand_logo() -> str:
return str(get_app_logo())


@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def get_website_settings() -> dict:
website_settings = frappe.get_doc("Website Settings")
form_settings = {
Expand Down
3 changes: 3 additions & 0 deletions forms_pro/api/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pydantic import BaseModel, Field, field_validator

from forms_pro.forms_pro.doctype.form.form import Form
from forms_pro.forms_pro.doctype.form_field.form_field import _DISPLAY_ONLY_FIELDTYPES
from forms_pro.utils.form_generator import SubmissionStatus


Expand Down Expand Up @@ -85,6 +86,8 @@ def _validate_form_response(form: "Form", form_data: dict) -> None:
errors: list[str] = []

for field in form.fields:
if field.fieldtype in _DISPLAY_ONLY_FIELDTYPES:
continue
is_visible = not field.hidden
is_required = bool(field.reqd)

Expand Down
2 changes: 1 addition & 1 deletion forms_pro/forms_pro/doctype/form/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def shared_with(self) -> list[dict[str, Any]]:
return users_shared_with

def generate_initial_route(self) -> str:
return "s/forms_pro_" + frappe.utils.random_string(8)
return "forms_pro_" + frappe.utils.random_string(8)

def before_insert(self) -> None:
self.status = "Draft"
Expand Down
4 changes: 1 addition & 3 deletions forms_pro/forms_pro/doctype/form/test_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,10 @@ def tearDown(self):
if frappe.db.exists("Form", self.test_form.name):
self.test_form.delete()

# Clean up test doctype
# Clean up test doctype - delete_doc on DocType triggers DDL which auto-commits
if frappe.db.exists("DocType", self.test_doctype_name):
frappe.delete_doc("DocType", self.test_doctype_name, force=True)

frappe.db.commit()

def create_test_doctype(self):
"""Create a test DocType with some initial fields."""
if frappe.db.exists("DocType", self.test_doctype_name):
Expand Down
4 changes: 2 additions & 2 deletions forms_pro/forms_pro/doctype/form_field/form_field.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Fieldtype",
"options": "Attach\nData\nNumber\nEmail\nDate\nDate Time\nDate Range\nTime Picker\nPassword\nSelect\nSwitch\nTextarea\nText Editor\nLink\nCheckbox\nRating\nPhone\nTable\nMultiselect",
"options": "Attach\nData\nNumber\nEmail\nDate\nDate Time\nDate Range\nTime Picker\nPassword\nSelect\nSwitch\nTextarea\nText Editor\nLink\nCheckbox\nRating\nPhone\nTable\nMultiselect\nHeading 1\nHeading 2\nHeading 3",
"reqd": 1
},
{
Expand Down Expand Up @@ -81,7 +81,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-04-15 16:26:10.519579",
"modified": "2026-04-25 20:32:10.994784",
"modified_by": "Administrator",
"module": "Forms Pro",
"name": "Form Field",
Expand Down
26 changes: 25 additions & 1 deletion forms_pro/forms_pro/doctype/form_field/form_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

# import frappe
from frappe.model.document import Document
from frappe.utils import escape_html

# Maps Forms Pro field types to Frappe CustomField fieldtypes.
# When adding a new field type:
Expand All @@ -29,9 +30,15 @@
"Rating": {"fieldtype": "Rating"},
"Table": {"fieldtype": "Table"},
"Multiselect": {"fieldtype": "JSON"},
"Heading 1": {"fieldtype": "HTML"},
"Heading 2": {"fieldtype": "HTML"},
"Heading 3": {"fieldtype": "HTML"},
}


_DISPLAY_ONLY_FIELDTYPES = {"Heading 1", "Heading 2", "Heading 3"}


class FormField(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
Expand Down Expand Up @@ -65,6 +72,9 @@ class FormField(Document):
"Phone",
"Table",
"Multiselect",
"Heading 1",
"Heading 2",
"Heading 3",
]
hidden: DF.Check
label: DF.Data
Expand Down Expand Up @@ -96,13 +106,27 @@ def to_frappe_field(self) -> dict:
_fieldtype = "Text"
elif self.fieldtype == "Multiselect":
_fieldtype = "JSON"
elif self.fieldtype in _DISPLAY_ONLY_FIELDTYPES:
_fieldtype = "HTML"

return {
"fieldname": self.fieldname,
"fieldtype": _fieldtype,
"label": self.label,
"reqd": self.reqd,
"options": self.options,
"options": self.get_options(),
"description": self.description,
"default": self.default,
}

def get_options(self) -> str | None:
if self.fieldtype in _DISPLAY_ONLY_FIELDTYPES:
HEADING_MAP = {
"Heading 1": "h1",
"Heading 2": "h2",
"Heading 3": "h3",
}
tag = HEADING_MAP.get(self.fieldtype, "h2")
return f"<{tag}>{escape_html(self.label or '')}</{tag}>"

return self.options
76 changes: 76 additions & 0 deletions forms_pro/tests/test_form_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copyright (c) 2025, harsh@buildwithhussain.com and contributors
# For license information, please see license.txt

import frappe
from frappe.tests.utils import FrappeTestCase as IntegrationTestCase

from forms_pro.forms_pro.doctype.form_field.form_field import FORM_TO_FRAPPE_FIELDTYPE


def _build_field(
fieldtype: str, label: str = "Test Field", fieldname: str = "test_field", options: str | None = None
):
"""Build an in-memory FormField document without inserting."""
doc = frappe.get_doc(
{
"doctype": "Form Field",
"fieldtype": fieldtype,
"label": label,
"fieldname": fieldname,
"options": options,
}
)
return doc


class TestFormFieldGetOptions(IntegrationTestCase):
def test_heading_1_returns_h1_tag_wrapping_label(self):
field = _build_field("Heading 1", label="Introduction")
self.assertEqual(field.get_options(), "<h1>Introduction</h1>")

def test_heading_2_returns_h2_tag_wrapping_label(self):
field = _build_field("Heading 2", label="Section A")
self.assertEqual(field.get_options(), "<h2>Section A</h2>")

def test_heading_3_returns_h3_tag_wrapping_label(self):
field = _build_field("Heading 3", label="Subsection")
self.assertEqual(field.get_options(), "<h3>Subsection</h3>")

def test_non_heading_returns_options_unchanged(self):
field = _build_field("Select", label="Color", options="Red\nBlue\nGreen")
self.assertEqual(field.get_options(), "Red\nBlue\nGreen")

def test_non_heading_with_no_options_returns_none(self):
field = _build_field("Data", label="Name")
self.assertIsNone(field.get_options())


class TestFormFieldToFrappeField(IntegrationTestCase):
def test_heading_1_maps_to_html_fieldtype(self):
field = _build_field("Heading 1", label="My Heading", fieldname="my_heading")
result = field.to_frappe_field
self.assertEqual(result["fieldtype"], "HTML")

def test_heading_2_maps_to_html_fieldtype(self):
field = _build_field("Heading 2", label="Sub Heading", fieldname="sub_heading")
self.assertEqual(field.to_frappe_field["fieldtype"], "HTML")

def test_heading_3_maps_to_html_fieldtype(self):
field = _build_field("Heading 3", label="Minor Heading", fieldname="minor_heading")
self.assertEqual(field.to_frappe_field["fieldtype"], "HTML")

def test_heading_options_contains_html_tag(self):
field = _build_field("Heading 1", label="My Title", fieldname="my_title")
result = field.to_frappe_field
self.assertIn("<h1>", result["options"])
self.assertIn("My Title", result["options"])

def test_data_field_maps_to_data_frappe_fieldtype(self):
field = _build_field("Data", label="Name", fieldname="name_field")
self.assertEqual(field.to_frappe_field["fieldtype"], "Data")

def test_form_to_frappe_fieldtype_has_all_heading_levels(self):
for fieldtype in ("Heading 1", "Heading 2", "Heading 3"):
with self.subTest(fieldtype=fieldtype):
self.assertIn(fieldtype, FORM_TO_FRAPPE_FIELDTYPE)
self.assertEqual(FORM_TO_FRAPPE_FIELDTYPE[fieldtype]["fieldtype"], "HTML")
4 changes: 2 additions & 2 deletions forms_pro/utils/form_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def create_form_with_doctype(team_id: str, doctype: str):
except Exception as e:
frappe.log_error(f"Error creating form with doctype: {doctype} - {e}")
frappe.throw(
_("Error creating form with doctype: {0} - {1}").format(doctype, e),
_("Error creating form with doctype: {0} - {1}").format(doctype, str(e)),
)

return {
Expand All @@ -47,7 +47,7 @@ def create_form(team_id: str):
except Exception as e:
frappe.log_error(f"Error creating form: {e}")
frappe.throw(
_("Error creating form: {0}").format(e),
_("Error creating form: {0}").format(str(e)),
)

return {
Expand Down
2 changes: 1 addition & 1 deletion forms_pro/www/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

def get_context(context):
csrf_token = frappe.sessions.get_csrf_token()
frappe.db.commit()
frappe.db.commit() # nosemgrep: frappe-semgrep-rules.rules.frappe-manual-commit - required to persist CSRF token before page render
# developer mode
context.is_developer_mode = frappe.conf.developer_mode
context.csrf_token = csrf_token
4 changes: 2 additions & 2 deletions frontend/e2e/fixtures/test-data.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ export const test = base.extend<TestDataFixtures>({
const formId = message.form_document as string;
created.push(formId);

// Publish the form via Frappe REST API
// Publish the form via Frappe REST API (also set title to avoid "Untitled Form" transform)
const publishRes = await apiContext.put(`/api/resource/Form/${formId}`, {
data: { is_published: 1 },
data: { is_published: 1, title: `E2E Published Form ${Date.now()}` },
});
const publishData = await publishRes.json();
const route = publishData.data?.route as string;
Expand Down
Loading
Loading