diff --git a/forms_pro/api/submission.py b/forms_pro/api/submission.py index 7e18b82..73abfdb 100644 --- a/forms_pro/api/submission.py +++ b/forms_pro/api/submission.py @@ -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 @@ -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) 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 d1dc0ab..02a9a11 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.json +++ b/forms_pro/forms_pro/doctype/form_field/form_field.json @@ -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 }, { @@ -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", 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 269b882..6788aa4 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.py +++ b/forms_pro/forms_pro/doctype/form_field/form_field.py @@ -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: @@ -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. @@ -65,6 +72,9 @@ class FormField(Document): "Phone", "Table", "Multiselect", + "Heading 1", + "Heading 2", + "Heading 3", ] hidden: DF.Check label: DF.Data @@ -83,7 +93,19 @@ def to_frappe_field(self) -> dict: "fieldtype": mapping.get("fieldtype", self.fieldtype), "label": self.label, "reqd": self.reqd, - "options": mapping.get("options", self.options), + "options": mapping.get("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 '')}" + + return self.options diff --git a/forms_pro/tests/test_form_field.py b/forms_pro/tests/test_form_field.py new file mode 100644 index 0000000..101db9e --- /dev/null +++ b/forms_pro/tests/test_form_field.py @@ -0,0 +1,76 @@ +# Copyright (c) 2025, harsh@buildwithhussain.com and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests import 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(), "

Introduction

") + + def test_heading_2_returns_h2_tag_wrapping_label(self): + field = _build_field("Heading 2", label="Section A") + self.assertEqual(field.get_options(), "

Section A

") + + def test_heading_3_returns_h3_tag_wrapping_label(self): + field = _build_field("Heading 3", label="Subsection") + self.assertEqual(field.get_options(), "

Subsection

") + + 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("

", 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") diff --git a/forms_pro/tests/test_submission_validation.py b/forms_pro/tests/test_submission_validation.py index 9aaff25..610dc31 100644 --- a/forms_pro/tests/test_submission_validation.py +++ b/forms_pro/tests/test_submission_validation.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from forms_pro.api.submission import _coerce_field_value, _evaluate_conditions, _validate_form_response +from forms_pro.forms_pro.doctype.form_field.form_field import _DISPLAY_ONLY_FIELDTYPES from forms_pro.utils.constants import FORMS_PRO_SYSTEM_FIELDNAMES, UNSUPPORTED_FRAPPE_FIELDTYPES from forms_pro.utils.form_generator import LINKED_FORM_FIELDOPTIONS, SUBMISSION_STATUS_FIELDOPTIONS @@ -213,3 +214,28 @@ def test_unsupported_fieldtypes_contains_layout_types(self): def test_unsupported_fieldtypes_contains_non_input_types(self): for fieldtype in ("HTML", "Button", "Barcode", "Dynamic Link"): self.assertIn(fieldtype, UNSUPPORTED_FRAPPE_FIELDTYPES) + + +class TestHeadingFieldValidation(unittest.TestCase): + def test_all_heading_levels_skipped_even_when_required(self): + """reqd=1 heading fields must never trigger a validation error.""" + for fieldtype in ("Heading 1", "Heading 2", "Heading 3"): + with self.subTest(fieldtype=fieldtype): + form = _form(_field("h", fieldtype=fieldtype, reqd=1)) + _validate_form_response(form, {}) # must not raise + + def test_required_data_field_still_validated_when_heading_present(self): + """Heading fields being skipped must not suppress validation of adjacent required fields.""" + import frappe + + form = _form( + _field("section_title", fieldtype="Heading 1", reqd=1), + _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)) + + 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) diff --git a/frontend/e2e/specs/heading-field.spec.ts b/frontend/e2e/specs/heading-field.spec.ts new file mode 100644 index 0000000..0ac5f51 --- /dev/null +++ b/frontend/e2e/specs/heading-field.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from "../fixtures/test-data.fixture"; +import { FormBuilderPage } from "../helpers/form-builder"; +import { SubmissionPage } from "../helpers/submission"; + +test.describe("Heading fields", () => { + test("all three heading types appear in the Add Fields sidebar", async ({ + page, + createForm, + }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + const sidebar = page.locator( + '[data-form-builder-component="form-builder-sidebar"]' + ); + await expect(sidebar.getByText("Heading 1", { exact: true })).toBeVisible(); + await expect(sidebar.getByText("Heading 2", { exact: true })).toBeVisible(); + await expect(sidebar.getByText("Heading 3", { exact: true })).toBeVisible(); + }); + + test("adding Heading 1 shows editable input in builder (edit mode)", async ({ + page, + createForm, + }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + await builder.addField("Heading 1"); + + // In edit mode (field selected), shows input with placeholder + const headingInput = page.getByPlaceholder("Heading Text").first(); + await expect(headingInput).toBeVisible({ timeout: 5000 }); + }); + + test("heading label is editable in builder", async ({ page, createForm }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + await builder.addField("Heading 1"); + + // Edit mode shows a text input for the heading label + const headingInput = page.getByPlaceholder("Heading Text").first(); + await expect(headingInput).toBeVisible({ timeout: 5000 }); + await headingInput.fill("About You"); + + // Verify the input has the value we typed + await expect(headingInput).toHaveValue("About You"); + }); + + test("heading renders on the public submission page without an input", async ({ + page, + browser, + createForm, + apiContext, + }) => { + const formId = await createForm(); + const builder = new FormBuilderPage(page); + await builder.goto(formId); + + await builder.addField("Heading 2"); + const headingInput = page.getByPlaceholder("Heading Text").first(); + await expect(headingInput).toBeVisible({ timeout: 5000 }); + await headingInput.fill("Your Details"); + + await builder.publish(); + + const res = await apiContext.get(`/api/resource/Form/${formId}`); + const { data } = await res.json(); + const route: string = data.route; + + const guestCtx = await browser.newContext(); + const guestPage = await guestCtx.newPage(); + const submissionPage = new SubmissionPage(guestPage); + await submissionPage.goto(route); + + // Heading renders as text, not as an input + await expect( + guestPage.locator("h3", { hasText: "Your Details" }) + ).toBeVisible({ + timeout: 10000, + }); + // h3 because Heading 2 → h3 tag in Heading.vue + + await guestCtx.close(); + }); + + test("form with heading fields submits successfully", async ({ + browser, + createPublishedForm, + apiContext, + }) => { + const { formId, route } = await createPublishedForm(); + + // Add a Heading 1 field to the published form via REST + const formRes = await apiContext.get(`/api/resource/Form/${formId}`); + const { data: formData } = await formRes.json(); + + await apiContext.put(`/api/resource/Form/${formId}`, { + data: { + fields: [ + ...(formData.fields ?? []), + { + fieldtype: "Heading 1", + label: "Section Header", + fieldname: "section_header", + reqd: 0, + }, + ], + }, + }); + + // Guest submits — heading must not cause a server-side validation error + const guestCtx = await browser.newContext(); + const guestPage = await guestCtx.newPage(); + const submissionPage = new SubmissionPage(guestPage); + await submissionPage.goto(route); + + await expect(guestPage.getByRole("button", { name: "Submit" })).toBeVisible( + { + timeout: 10000, + } + ); + await submissionPage.submit(); + + await expect(submissionPage.successMessage()).toBeVisible({ + timeout: 10000, + }); + + await guestCtx.close(); + }); +}); diff --git a/frontend/src/components/builder/FieldRenderer.vue b/frontend/src/components/builder/FieldRenderer.vue index 2a50def..b6f1cec 100644 --- a/frontend/src/components/builder/FieldRenderer.vue +++ b/frontend/src/components/builder/FieldRenderer.vue @@ -2,6 +2,7 @@ import { computed } from "vue"; import RenderField from "../RenderField.vue"; import FieldLabel from "./FieldLabel.vue"; +import Heading from "@/components/fields/Heading.vue"; import Table from "@/components/fields/Table.vue"; import { useFieldOptions } from "@/utils/selectOptions"; import { getFieldTypeDef, Fieldtype } from "@/config/fieldTypes"; @@ -83,6 +84,15 @@ const { options: selectOptions } = useFieldOptions(fieldData); /> + +
+ +
+
+import { Fieldtype } from "@/types/FormsPro/form_field.types"; +import { computed } from "vue"; + +const props = defineProps<{ + field: { + label?: string; + fieldtype?: Fieldtype; + }; + inEditMode: boolean; +}>(); + +const emit = defineEmits<{ + "update:label": [value: string]; +}>(); + +const headingClasses: Record = { + [Fieldtype.HEADING_1]: "text-xl font-bold", + [Fieldtype.HEADING_2]: "text-lg font-semibold", + [Fieldtype.HEADING_3]: "text-base font-semibold", +}; + +type HeadingFieldtype = Fieldtype.HEADING_1 | Fieldtype.HEADING_2 | Fieldtype.HEADING_3; +type HeadingTag = "h2" | "h3" | "h4"; + +const headingTypeToTag: Record = { + [Fieldtype.HEADING_1]: "h2", + [Fieldtype.HEADING_2]: "h3", + [Fieldtype.HEADING_3]: "h4", +}; + +const headingTag = computed( + () => headingTypeToTag[props.field.fieldtype as HeadingFieldtype] ?? "h2" +); + +const headingClass = computed( + () => headingClasses[props.field.fieldtype as HeadingFieldtype] ?? "" +); + + + diff --git a/frontend/src/components/form/submissions/SubmissionFieldValue.vue b/frontend/src/components/form/submissions/SubmissionFieldValue.vue index c6754bb..9e0adcf 100644 --- a/frontend/src/components/form/submissions/SubmissionFieldValue.vue +++ b/frontend/src/components/form/submissions/SubmissionFieldValue.vue @@ -4,6 +4,8 @@ import { Fieldtype } from "@/types/formfield"; import { getFieldTypeDef } from "@/config/fieldTypes"; import { formatDate, formatDateTime, formatTime } from "@/utils/date"; import { computed } from "vue"; +import { isHeading } from "@/utils/form_fields"; +import Heading from "@/components/fields/Heading.vue"; const props = defineProps<{ fieldname: string; @@ -75,7 +77,7 @@ const classNames = computed(() =>