Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9ff92e8
feat: add Page Break display-only field type
harshtandiya Jun 4, 2026
61714b6
test: integration test for Page Break form save and DocType sync
harshtandiya Jun 4, 2026
3948c51
chore: add Page Break to auto-generated TypeScript types
harshtandiya Jun 4, 2026
ef5c4cc
refactor: map Page Break to Tab Break instead of HTML
harshtandiya Jun 4, 2026
b34cec1
docs: add section navigation bar implementation plan
harshtandiya Jun 5, 2026
4a23773
Revert "docs: add section navigation bar implementation plan"
harshtandiya Jun 7, 2026
82ca940
chore: gitignore screenshot PNGs
harshtandiya Jun 7, 2026
adca5b5
feat: register Page Break field type in frontend
harshtandiya Jun 7, 2026
1074c57
feat: multi-section form builder with section navigation
harshtandiya Jun 7, 2026
7c18428
feat: multi-step submission form with step navigation
harshtandiya Jun 7, 2026
fd163c2
feat: polish multi-step submission UI and lift step state into store
harshtandiya Jun 9, 2026
6a4a2be
refactor: use frappe-ui semantic color tokens in stepper UI
harshtandiya Jun 9, 2026
8a7aabc
fix: make multi-step stepper responsive on small screens
harshtandiya Jun 9, 2026
95f5243
refactor: rename "Section" to "Step" in form editor UI
harshtandiya Jun 9, 2026
d394557
refactor: extract step logic into pure form_steps util
harshtandiya Jun 9, 2026
da0fba2
test: add e2e coverage for multi-step builder and submission
harshtandiya Jun 10, 2026
54e055a
fix: make step remove control keyboard-accessible and harden rename
harshtandiya Jun 10, 2026
4cdbe8d
refactor: nest step remove control back into the tab button
harshtandiya Jun 10, 2026
451ae10
fix: render Submit button on fieldless published forms
harshtandiya Jun 11, 2026
bf20d79
fix: add Page Break to FormField fieldtype literal
harshtandiya Jun 11, 2026
7f520e3
fix(frontend): keep row_index unique when inserting fields into steps
harshtandiya Jun 11, 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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,7 @@ frontend/e2e/playwright-report/

skills-lock.json

semgrep-rules/
semgrep-rules/

# Screenshots
*.png
38 changes: 38 additions & 0 deletions forms_pro/api/submission/test_submission_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
2 changes: 1 addition & 1 deletion forms_pro/forms_pro/doctype/form_field/form_field.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
{
Expand Down
17 changes: 9 additions & 8 deletions forms_pro/forms_pro/doctype/form_field/form_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -78,6 +79,7 @@ class FormField(Document):
"Heading 1",
"Heading 2",
"Heading 3",
"Page Break",
]
hidden: DF.Check
label: DF.Data
Expand Down Expand Up @@ -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 '')}</{tag}>"

return self.options
71 changes: 71 additions & 0 deletions forms_pro/tests/test_page_break.py
Original file line number Diff line number Diff line change
@@ -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")
65 changes: 65 additions & 0 deletions frontend/e2e/helpers/form-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<label> Remove step" — match the label exactly,
// with that suffix optional.
stepTab(label: string): Locator {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return this.stepNav().getByRole("button", {
name: new RegExp(`^${escaped}( Remove step)?$`),
});
}

removeStepControl(label: string): Locator {
return this.stepTab(label).getByLabel("Remove step");
}

activeStepTab(): Locator {
return this.stepNav().locator('button[aria-current="true"]');
}

async addStep() {
await this.stepNav().getByRole("button", { name: "Add Step" }).click();
}

async switchToStep(label: string) {
await this.stepTab(label).click();
}

async renameStep(oldLabel: string, newLabel: string) {
await this.stepTab(oldLabel).dblclick();
const input = this.stepNav().getByRole("textbox");
await input.waitFor({ state: "visible" });
await input.fill(newLabel);
await input.press("Enter");
}

// mode "keep" -> "Move fields to previous step"
// mode "delete"-> "Remove step and fields"
// omit mode for empty steps (no dialog appears)
async removeStep(label: string, mode?: "keep" | "delete") {
await this.removeStepControl(label).click();
if (mode === "keep") {
await this.page
.getByRole("button", { name: "Move fields to previous step" })
.click();
} else if (mode === "delete") {
await this.page
.getByRole("button", { name: "Remove step and fields" })
.click();
}
}

async save() {
const saveBtn = this.page.getByRole("button", {
name: "Save",
exact: true,
});
await saveBtn.click();
await saveBtn.waitFor({ state: "hidden", timeout: 30000 });
}
}
82 changes: 82 additions & 0 deletions frontend/e2e/helpers/form-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { expect, type APIRequestContext } from "@playwright/test";

export type SeedField = {
label: string;
fieldtype?: string;
fieldname?: string;
reqd?: 0 | 1;
hidden?: 0 | 1;
conditional_logic?: string;
row_index?: number;
column_index?: number;
cell_index?: number;
};

function defaultFieldname(label: string): string {
return label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}

// Replace a form's full field list via REST so the builder/public page load a
// known layout. row_index defaults to the array position, which keeps every
// field on its own row — fine for step tests, which don't care about columns.
export async function setFormFields(
apiContext: APIRequestContext,
formId: string,
fields: SeedField[]
) {
const res = await apiContext.put(`/api/resource/Form/${formId}`, {
data: {
fields: fields.map((f, i) => ({
idx: i + 1,
fieldtype: f.fieldtype ?? "Data",
label: f.label,
fieldname: f.fieldname ?? defaultFieldname(f.label),
reqd: f.reqd ?? 0,
hidden: f.hidden ?? 0,
conditional_logic: f.conditional_logic ?? "",
row_index: f.row_index ?? i,
column_index: f.column_index ?? 0,
cell_index: f.cell_index ?? 0,
})),
},
});
// Fail loudly here rather than as a confusing locator timeout downstream.
expect(
res.ok(),
`setFormFields PUT failed: ${res.status()} ${await res.text()}`
).toBeTruthy();
}

export function pageBreak(label = "", fieldname?: string): SeedField {
return {
label,
fieldtype: "Page Break",
fieldname: fieldname ?? (label ? defaultFieldname(label) : undefined),
};
}

// 3 steps: Basics(Full Name) / Contact(Work Email) / Final(Notes).
// Leading Page Break labels step 1.
export function threeStepFields(): SeedField[] {
return [
pageBreak("Basics", "pb_basics"),
{ label: "Full Name" },
pageBreak("Contact", "pb_contact"),
{ label: "Work Email" },
pageBreak("Final", "pb_final"),
{ label: "Notes" },
];
}

// 2 steps with NO leading Page Break: step 1 is unlabeled in data
// (builder shows fallback "Step 1"), Page Break labels step 2.
export function twoStepFieldsNoLeadingPB(): SeedField[] {
return [
{ label: "Full Name" },
pageBreak("Details", "pb_details"),
{ label: "Notes" },
];
}
44 changes: 42 additions & 2 deletions frontend/e2e/helpers/submission.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";

export class SubmissionPage {
constructor(private page: Page) {}
Expand All @@ -7,8 +7,16 @@ export class SubmissionPage {
await this.page.goto(`/forms/p/${route}`);
}

// Submission fields render a visible <label> sibling, not a for/id association.
fieldInput(label: string): Locator {
return this.page
.locator("div.flex.flex-col.gap-2")
.filter({ has: this.page.getByText(label, { exact: true }) })
.getByRole("textbox");
}

async fillField(label: string, value: string) {
await this.page.getByLabel(label).fill(value);
await this.fieldInput(label).fill(value);
}

async submit() {
Expand All @@ -19,4 +27,36 @@ export class SubmissionPage {
successMessage() {
return this.page.getByText(/thank you for submitting/i);
}

// ---- Multi-step navigation ----

stepIndicator(): Locator {
return this.page.locator('nav[aria-label="Form steps"]');
}

stepNode(index: number): Locator {
return this.page.locator(
`[data-step-component="step-node"][data-step-index="${index}"]`
);
}

nextButton(): Locator {
return this.page.getByRole("button", { name: "Next" });
}

backButton(): Locator {
return this.page.getByRole("button", { name: "Back" });
}

submitButton(): Locator {
return this.page.getByRole("button", { name: "Submit" });
}

async next() {
await this.nextButton().click();
}

async back() {
await this.backButton().click();
}
}
Loading
Loading