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 '')}{tag}>"
+
+ 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] ?? ""
+);
+
+
+
+
+
+ {{ field.label }}
+
+
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(() =>
-
+
{{ label }}
{{ description }}
@@ -128,6 +130,12 @@ const classNames = computed
(() =>
{{ formattedDateValue }}
+
+
{{ value ?? "–" }}
diff --git a/frontend/src/config/fieldTypes.ts b/frontend/src/config/fieldTypes.ts
index 716ed06..59a9a2c 100644
--- a/frontend/src/config/fieldTypes.ts
+++ b/frontend/src/config/fieldTypes.ts
@@ -34,6 +34,7 @@ import {
FormControl,
} from "frappe-ui";
import Attachment from "@/components/fields/Attachment.vue";
+import Heading from "@/components/fields/Heading.vue";
import Multiselect from "@/components/fields/multiselect/Multiselect.vue";
import MultiselectBuilderExtras from "@/components/fields/multiselect/MultiselectBuilderExtras.vue";
import Phone from "@/components/fields/Phone.vue";
@@ -50,7 +51,12 @@ export { Fieldtype };
* - "description-first" label on top, description below label, input at the bottom (Text Editor)
* - "custom" the component handles its own full layout (Table)
*/
-export type FieldLayout = "default" | "inline" | "description-first" | "custom";
+export type FieldLayout =
+ | "default"
+ | "inline"
+ | "description-first"
+ | "custom"
+ | "heading";
export type FieldTypeDefinition = {
/** Canonical name — must match a Fieldtype enum value */
@@ -271,6 +277,33 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDefinition[] = [
isDate: false,
builderExtras: MultiselectBuilderExtras,
},
+ {
+ name: Fieldtype.HEADING_1,
+ component: Heading,
+ props: {},
+ layout: "heading",
+ frappeFieldtype: "HTML",
+ isBoolean: false,
+ isDate: false,
+ },
+ {
+ name: Fieldtype.HEADING_2,
+ component: Heading,
+ props: {},
+ layout: "heading",
+ frappeFieldtype: "HTML",
+ isBoolean: false,
+ isDate: false,
+ },
+ {
+ name: Fieldtype.HEADING_3,
+ component: Heading,
+ props: {},
+ layout: "heading",
+ frappeFieldtype: "HTML",
+ isBoolean: false,
+ isDate: false,
+ },
];
export const FIELD_TYPE_MAP = new Map(
diff --git a/frontend/src/types/FormsPro/form_field.types.ts b/frontend/src/types/FormsPro/form_field.types.ts
index ec0ec56..288e265 100644
--- a/frontend/src/types/FormsPro/form_field.types.ts
+++ b/frontend/src/types/FormsPro/form_field.types.ts
@@ -18,6 +18,9 @@ export enum Fieldtype {
"PHONE" = "Phone",
"TABLE" = "Table",
"MULTISELECT" = "Multiselect",
+ "HEADING_1" = "Heading 1",
+ "HEADING_2" = "Heading 2",
+ "HEADING_3" = "Heading 3",
}
export interface FormField {
diff --git a/frontend/src/utils/form_fields.ts b/frontend/src/utils/form_fields.ts
index 13cf2e7..e818b72 100644
--- a/frontend/src/utils/form_fields.ts
+++ b/frontend/src/utils/form_fields.ts
@@ -55,3 +55,11 @@ export const mapDoctypeFieldForForm = (
return FRAPPE_TO_FORM_TYPE[fieldtype];
};
+
+export const isHeading = (fieldtype: Fieldtype): boolean => {
+ return [
+ Fieldtype.HEADING_1,
+ Fieldtype.HEADING_2,
+ Fieldtype.HEADING_3,
+ ].includes(fieldtype);
+};