Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
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
24 changes: 23 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 All @@ -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
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 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(), "<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())
Comment on lines +43 to +45
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the options docfield definition on Form Field to confirm None vs "" semantics.
fd -t f 'form_field.json' forms_pro
rg -n -C2 '"fieldname"\s*:\s*"options"' forms_pro/forms_pro/doctype/form_field/form_field.json
# And confirm the runtime behavior of frappe.get_doc + missing options.
rg -nP --type=py -C3 '\bdef\s+get_options\s*\(' forms_pro/forms_pro/doctype/form_field/form_field.py

Repository: BuildWithHussain/forms_pro

Length of output: 453


🏁 Script executed:

#!/bin/bash
# Get full form_field.json to check default value for options field
cat forms_pro/forms_pro/doctype/form_field/form_field.json | jq '.[] | select(.fieldname == "options")' 2>/dev/null || rg -A5 '"fieldname"\s*:\s*"options"' forms_pro/forms_pro/doctype/form_field/form_field.json

Repository: BuildWithHussain/forms_pro

Length of output: 189


🏁 Script executed:

#!/bin/bash
# Get the complete get_options() method implementation
rg -A20 'def get_options\(' forms_pro/forms_pro/doctype/form_field/form_field.py

Repository: BuildWithHussain/forms_pro

Length of output: 441


🏁 Script executed:

#!/bin/bash
# Check the test file to see the full test and helper function
cat forms_pro/forms_pro/tests/test_form_field.py

Repository: BuildWithHussain/forms_pro

Length of output: 147


🏁 Script executed:

#!/bin/bash
# Find the test file
fd -type f 'test_form_field.py' forms_pro

Repository: BuildWithHussain/forms_pro

Length of output: 241


🏁 Script executed:

#!/bin/bash
# Also check what test files exist
find forms_pro -name 'test_*.py' -o -name '*_test.py' | head -20

Repository: BuildWithHussain/forms_pro

Length of output: 420


🏁 Script executed:

#!/bin/bash
# Check the doctype python file to understand the Model class
cat -n forms_pro/forms_pro/doctype/form_field/form_field.py | head -50

Repository: BuildWithHussain/forms_pro

Length of output: 2161


🏁 Script executed:

#!/bin/bash
# Now get the test file
cat -n forms_pro/tests/test_form_field.py

Repository: BuildWithHussain/forms_pro

Length of output: 3839


🏁 Script executed:

#!/bin/bash
# Check if options field has a default value in form_field.json - get the full field definition
rg -B2 -A8 '"fieldname"\s*:\s*"options"' forms_pro/forms_pro/doctype/form_field/form_field.json

Repository: BuildWithHussain/forms_pro

Length of output: 259


🏁 Script executed:

#!/bin/bash
# Check the _build_field helper function and how it's used in tests
rg -B5 -A10 '_build_field' forms_pro/tests/test_form_field.py

Repository: BuildWithHussain/forms_pro

Length of output: 3163


🏁 Script executed:

#!/bin/bash
# Check if Frappe Small Text fields have any default behavior and verify type annotation
rg -B5 -A15 'def get_options' forms_pro/forms_pro/doctype/form_field/form_field.py

Repository: BuildWithHussain/forms_pro

Length of output: 601


🏁 Script executed:

#!/bin/bash
# Check if there's a to_frappe_field property that might be relevant
rg -B5 -A20 'to_frappe_field' forms_pro/forms_pro/doctype/form_field/form_field.py

Repository: BuildWithHussain/forms_pro

Length of output: 965


get_options() has incorrect return type annotation and test assertion.

The method is declared as def get_options(self) -> str: (never None), but the test asserts self.assertIsNone(field.get_options()). When frappe.get_doc() constructs the doc with "options": None (or omitted), Frappe coerces the Small Text field to "" (empty string), not None. This means:

  • field.options will be "", not None
  • get_options() returns self.options, so it returns ""
  • The test assertion will fail because assertIsNone("") fails
  • The return type annotation -> str contradicts the test's expectation of None

Fix: Either change the return type to str | None and handle both cases, or change the test to assertFalsy(field.get_options()) / assertEqual(field.get_options(), "").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@forms_pro/tests/test_form_field.py` around lines 43 - 45, The get_options
return type and behavior are inconsistent with the test: update the
Field.get_options method to return an optional string (e.g., change signature to
return str | None and/or Optional[str]) and normalize empty-string options to
None (i.e., if self.options == "" return None, otherwise return self.options) so
the test_non_heading_with_no_options_returns_none passes; modify the method
implementation referenced as get_options and ensure it checks the field
attribute options before returning, leaving the test name
test_non_heading_with_no_options_returns_none unchanged.



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")
26 changes: 26 additions & 0 deletions forms_pro/tests/test_submission_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
134 changes: 134 additions & 0 deletions frontend/e2e/specs/heading-field.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
10 changes: 10 additions & 0 deletions frontend/src/components/builder/FieldRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -83,6 +84,15 @@ const { options: selectOptions } = useFieldOptions(fieldData);
/>
</div>

<!-- heading: renders field label as h1/h2/h3; no input -->
<div v-else-if="layout === 'heading'" class="w-full py-1">
<Heading
:field="fieldData"
:in-edit-mode="inEditMode"
@update:label="fieldData.label = $event"
/>
</div>

<!-- custom: Attach and Table each need their own binding/widget -->
<div v-else-if="layout === 'custom' && fieldData.fieldtype === 'Attach'">
<FieldLabel
Expand Down
Loading
Loading