diff --git a/.gitignore b/.gitignore index 89c6698..45f5bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,7 @@ frontend/e2e/playwright-report/ skills-lock.json -semgrep-rules/ \ No newline at end of file +semgrep-rules/ + +# Screenshots +*.png \ No newline at end of file diff --git a/forms_pro/api/submission/test_submission_validation.py b/forms_pro/api/submission/test_submission_validation.py index 320f71d..8e0a645 100644 --- a/forms_pro/api/submission/test_submission_validation.py +++ b/forms_pro/api/submission/test_submission_validation.py @@ -243,3 +243,41 @@ def test_required_data_field_still_validated_when_heading_present(self): def test_display_only_fieldtypes_contains_all_heading_levels(self): for fieldtype in ("Heading 1", "Heading 2", "Heading 3"): self.assertIn(fieldtype, _DISPLAY_ONLY_FIELDTYPES) + + +class TestPageBreakValidation(unittest.TestCase): + def test_page_break_in_display_only_fieldtypes(self): + self.assertIn("Page Break", _DISPLAY_ONLY_FIELDTYPES) + + def test_page_break_skipped_even_when_required(self): + """reqd=1 Page Break must never trigger a validation error.""" + form = _form(_field("pb", fieldtype="Page Break", reqd=1)) + _validate_form_response(form, {}) # must not raise + + def test_required_data_field_still_validated_when_page_break_present(self): + """Page Break being skipped must not suppress validation of adjacent required fields.""" + import frappe + + form = _form( + _field("step_two", fieldtype="Page Break"), + _field("full_name", fieldtype="Data", reqd=1), + ) + with self.assertRaises(frappe.ValidationError) as ctx: + _validate_form_response(form, {}) + self.assertIn("Full Name", str(ctx.exception)) + + +class TestPageBreakProperties(unittest.TestCase): + def test_page_break_stores_value_is_false(self): + """Page Break maps to Tab Break which is a no_value_field — stores_value must be False.""" + from frappe.model import no_value_fields + + from forms_pro.forms_pro.doctype.form_field.form_field import FORM_TO_FRAPPE_FIELDTYPE + + mapped = FORM_TO_FRAPPE_FIELDTYPE["Page Break"] + self.assertIn(mapped["fieldtype"], no_value_fields) + + def test_page_break_frappe_fieldtype_is_tab_break(self): + from forms_pro.forms_pro.doctype.form_field.form_field import FORM_TO_FRAPPE_FIELDTYPE + + self.assertEqual(FORM_TO_FRAPPE_FIELDTYPE["Page Break"]["fieldtype"], "Tab Break") diff --git a/forms_pro/forms_pro/doctype/form_field/form_field.json b/forms_pro/forms_pro/doctype/form_field/form_field.json index 5c0f1fd..0d0cc33 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.json +++ b/forms_pro/forms_pro/doctype/form_field/form_field.json @@ -40,7 +40,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\nHeading 1\nHeading 2\nHeading 3", + "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\nPage Break", "reqd": 1 }, { diff --git a/forms_pro/forms_pro/doctype/form_field/form_field.py b/forms_pro/forms_pro/doctype/form_field/form_field.py index a1a1bc7..0f447d5 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.py +++ b/forms_pro/forms_pro/doctype/form_field/form_field.py @@ -34,10 +34,11 @@ "Heading 1": {"fieldtype": "HTML"}, "Heading 2": {"fieldtype": "HTML"}, "Heading 3": {"fieldtype": "HTML"}, + "Page Break": {"fieldtype": "Tab Break"}, } -_DISPLAY_ONLY_FIELDTYPES = {"Heading 1", "Heading 2", "Heading 3"} +_DISPLAY_ONLY_FIELDTYPES = {"Heading 1", "Heading 2", "Heading 3", "Page Break"} class FormField(Document): @@ -78,6 +79,7 @@ class FormField(Document): "Heading 1", "Heading 2", "Heading 3", + "Page Break", ] hidden: DF.Check label: DF.Data @@ -114,13 +116,12 @@ def to_frappe_field(self) -> dict: } 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") + HEADING_MAP = { + "Heading 1": "h1", + "Heading 2": "h2", + "Heading 3": "h3", + } + if tag := HEADING_MAP.get(self.fieldtype): return f"<{tag}>{escape_html(self.label or '')}" return self.options diff --git a/forms_pro/tests/test_page_break.py b/forms_pro/tests/test_page_break.py new file mode 100644 index 0000000..cf3145d --- /dev/null +++ b/forms_pro/tests/test_page_break.py @@ -0,0 +1,71 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from forms_pro.tests.factories.form_factory import FormFactory + + +class TestPageBreakIntegration(IntegrationTestCase): + def test_form_with_page_break_saves(self): + form = FormFactory.create() + form.append( + "fields", + { + "label": "Your Name", + "fieldname": "your_name", + "fieldtype": "Data", + "reqd": 0, + "row_index": 0, + "column_index": 0, + "cell_index": 0, + }, + ) + form.append( + "fields", + { + "label": "Step 2", + "fieldname": "step_2", + "fieldtype": "Page Break", + "reqd": 0, + "row_index": 1, + "column_index": 0, + "cell_index": 0, + }, + ) + form.append( + "fields", + { + "label": "Your Email", + "fieldname": "your_email", + "fieldtype": "Email", + "reqd": 0, + "row_index": 2, + "column_index": 0, + "cell_index": 0, + }, + ) + form.save() + + form.reload() + fieldtypes = [f.fieldtype for f in form.fields] + self.assertIn("Page Break", fieldtypes) + + def test_page_break_syncs_as_tab_break_to_linked_doctype(self): + form = FormFactory.create() + form.append( + "fields", + { + "label": "Step 2", + "fieldname": "step_2", + "fieldtype": "Page Break", + "reqd": 0, + "row_index": 0, + "column_index": 0, + "cell_index": 0, + }, + ) + form.save() + + doctype_doc = frappe.get_doc("DocType", form.linked_doctype) + synced = {f.fieldname: f for f in doctype_doc.fields} + self.assertIn("step_2", synced) + self.assertEqual(synced["step_2"].fieldtype, "Tab Break") diff --git a/frontend/e2e/helpers/form-builder.ts b/frontend/e2e/helpers/form-builder.ts index d5af874..79f4041 100644 --- a/frontend/e2e/helpers/form-builder.ts +++ b/frontend/e2e/helpers/form-builder.ts @@ -304,4 +304,69 @@ export class FormBuilderPage { publishButton() { return this.page.getByRole("button", { name: /^publish$/i }); } + + // ---- Step navigation (SectionNavBar) ---- + + stepNav(): Locator { + return this.page.locator('nav[aria-label="Form steps"]'); + } + + // The remove "X" is nested inside the tab button, so tabs after the first + // have the accessible name "