Skip to content

Commit 69acb3d

Browse files
authored
feat: add Page Break field type for multistep forms (#150)
* feat: add Page Break display-only field type * test: integration test for Page Break form save and DocType sync * chore: add Page Break to auto-generated TypeScript types * refactor: map Page Break to Tab Break instead of HTML Tab Break is a native Frappe layout field that renders proper tabs in Desk view, replacing the raw HTML <hr> workaround. * docs: add section navigation bar implementation plan * Revert "docs: add section navigation bar implementation plan" This reverts commit b34cec1. * chore: gitignore screenshot PNGs * feat: register Page Break field type in frontend Add Page Break to fieldTypes registry, FieldRenderer, form_fields utils, and hide it from the sidebar. Filter it out in submission field display. * feat: multi-section form builder with section navigation Add section navigation bar for multi-page forms. Sections are derived from Page Break fields. Supports renaming (double-click), adding, and removing sections with field merge or deletion options. * feat: multi-step submission form with step navigation Add step indicator and Next/Back navigation for multi-page form submissions. Validates current step fields before advancing. * feat: polish multi-step submission UI and lift step state into store - Move step state (useFormSteps) into submissionForm store; FormRenderer reads steps/navigation off the store instead of the composable - Add stepDirection (forward/backward) to drive slide animations - Redesign StepIndicator as numbered-circle stepper with animated check and progress-fill connectors - Compact step header on step 2+, full header on step 1 - Footer progress pills + Back/Next/Submit/Draft buttons; success fade-up - Rename builder terminology page -> Step (addSection -> addStep) * refactor: use frappe-ui semantic color tokens in stepper UI Replace hardcoded Tailwind grays with surface/ink/outline tokens so the multi-step submission UI respects theming. Kept the translucent halo ring on the active step (no opaque token equivalent). * fix: make multi-step stepper responsive on small screens Drop the fixed percentage stepper width that starved nodes of space and collapsed connectors. Use a scrollable w-max/mx-auto row with fixed-width connectors, bounded nodes, and truncated labels so the stepper centers when it fits and scrolls cleanly when many steps overflow narrow screens. * refactor: rename "Section" to "Step" in form editor UI User-facing labels and dialog text only. Default page-break labels now read "Step N", and the remove-step dialog and nav aria-labels follow. Internal symbols and section_* fieldnames unchanged. * refactor: extract step logic into pure form_steps util Step grouping was duplicated between editForm store and useFormSteps composable and had drifted (label fallback, empty-form behavior). Both now consume one parameterized groupFieldsIntoSteps core. - form_steps.ts: grouping, page-break index helpers, step mutations - form_fields.ts: compact/lastRowIndex/scrubFieldname (now take fields param instead of reading the store resource) - editForm store keeps state, persistence, and thin action wrappers; step actions renamed removeSection* -> removeStep*, renameSection -> renameStep to finish the Section->Step migration * test: add e2e coverage for multi-step builder and submission Add Playwright specs for step navigation in the builder (add/rename/ remove/persist) and the public multi-step submission flow (next/back, value retention, done/current/todo stepper, conditional across steps, guest path). Supporting changes to make flows testable + correct: - SectionNavBar: fix Escape during rename leaking into the next save; add aria-label "Remove step" and "Add Step" labels - StepIndicator: emit data-step-* hooks for stable step assertions - submission helper: resolve field inputs via label sibling Two known limitations pinned as test.fixme: getActiveStepEndIndex leading-PageBreak offset, and REST seeding of empty-label Page Breaks. * fix: make step remove control keyboard-accessible and harden rename Address review findings on the multi-step builder: - SectionNavBar: replace the nested <X role="button"> (mouse-only, interactive element inside <Button>) with a real frappe-ui ghost icon Button rendered as a sibling of the step tab. Now focusable and Enter/Space activatable, and no longer invalid nested-interactive HTML. - SectionNavBar: reset cancelRename in startRename so an Escape whose @blur never fires (DOM removal doesn't reliably blur) can't leak into and silently drop the next rename. - e2e: seed helper now asserts the Form PUT succeeded, failing loudly instead of as a downstream locator timeout. - e2e: scope the remove control via the new [data-step-tab] group since it is no longer a child of the tab button. * refactor: nest step remove control back into the tab button Keep the remove "X" inside the step tab's suffix slot so it shares the tab's pill background instead of floating beside it. Keyboard access is handled on the icon itself: tabindex, Enter/Space activation, and a focus-visible ring. Nesting changes the tab's accessible name to "<label> Remove step", so the e2e stepTab locator now matches the label with that suffix optional. * fix: render Submit button on fieldless published forms groupFieldsIntoSteps returns no steps for an empty field list, so isLastStep was false on step 0 and FormRenderer showed Next instead of Submit. Pass alwaysIncludeTrailing so there is always >= 1 step. * fix: add Page Break to FormField fieldtype literal Runtime map already supported it; the TYPE_CHECKING union lagged behind the doctype JSON options. * fix(frontend): keep row_index unique when inserting fields into steps Field insertion computed row_index from the active section only, so an empty or middle section produced rows colliding with other sections — moveField and compact() treat row_index as global. Centralize insertion in insertFieldAtStepEnd, which numbers the new row from everything before the insertion point and shifts later rows. Also fixed along the way: - addFieldFromDoctype appended to the form end instead of the active section; it now uses the same helper - getActiveStepEndIndex was off by one step when the form has a leading Page Break (un-fixmes the palette e2e test) - renameStep step 0 inserted a leading Page Break at row 0 without shifting existing fields off that row Pure grid helpers moved to form_layout.ts so vitest can import them without the component registry; form_fields.ts re-exports them.
1 parent 6155a26 commit 69acb3d

32 files changed

Lines changed: 2284 additions & 267 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,7 @@ frontend/e2e/playwright-report/
7676

7777
skills-lock.json
7878

79-
semgrep-rules/
79+
semgrep-rules/
80+
81+
# Screenshots
82+
*.png

forms_pro/api/submission/test_submission_validation.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,41 @@ def test_required_data_field_still_validated_when_heading_present(self):
243243
def test_display_only_fieldtypes_contains_all_heading_levels(self):
244244
for fieldtype in ("Heading 1", "Heading 2", "Heading 3"):
245245
self.assertIn(fieldtype, _DISPLAY_ONLY_FIELDTYPES)
246+
247+
248+
class TestPageBreakValidation(unittest.TestCase):
249+
def test_page_break_in_display_only_fieldtypes(self):
250+
self.assertIn("Page Break", _DISPLAY_ONLY_FIELDTYPES)
251+
252+
def test_page_break_skipped_even_when_required(self):
253+
"""reqd=1 Page Break must never trigger a validation error."""
254+
form = _form(_field("pb", fieldtype="Page Break", reqd=1))
255+
_validate_form_response(form, {}) # must not raise
256+
257+
def test_required_data_field_still_validated_when_page_break_present(self):
258+
"""Page Break being skipped must not suppress validation of adjacent required fields."""
259+
import frappe
260+
261+
form = _form(
262+
_field("step_two", fieldtype="Page Break"),
263+
_field("full_name", fieldtype="Data", reqd=1),
264+
)
265+
with self.assertRaises(frappe.ValidationError) as ctx:
266+
_validate_form_response(form, {})
267+
self.assertIn("Full Name", str(ctx.exception))
268+
269+
270+
class TestPageBreakProperties(unittest.TestCase):
271+
def test_page_break_stores_value_is_false(self):
272+
"""Page Break maps to Tab Break which is a no_value_field — stores_value must be False."""
273+
from frappe.model import no_value_fields
274+
275+
from forms_pro.forms_pro.doctype.form_field.form_field import FORM_TO_FRAPPE_FIELDTYPE
276+
277+
mapped = FORM_TO_FRAPPE_FIELDTYPE["Page Break"]
278+
self.assertIn(mapped["fieldtype"], no_value_fields)
279+
280+
def test_page_break_frappe_fieldtype_is_tab_break(self):
281+
from forms_pro.forms_pro.doctype.form_field.form_field import FORM_TO_FRAPPE_FIELDTYPE
282+
283+
self.assertEqual(FORM_TO_FRAPPE_FIELDTYPE["Page Break"]["fieldtype"], "Tab Break")

forms_pro/forms_pro/doctype/form_field/form_field.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"fieldtype": "Select",
4141
"in_list_view": 1,
4242
"label": "Fieldtype",
43-
"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",
43+
"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",
4444
"reqd": 1
4545
},
4646
{

forms_pro/forms_pro/doctype/form_field/form_field.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@
3434
"Heading 1": {"fieldtype": "HTML"},
3535
"Heading 2": {"fieldtype": "HTML"},
3636
"Heading 3": {"fieldtype": "HTML"},
37+
"Page Break": {"fieldtype": "Tab Break"},
3738
}
3839

3940

40-
_DISPLAY_ONLY_FIELDTYPES = {"Heading 1", "Heading 2", "Heading 3"}
41+
_DISPLAY_ONLY_FIELDTYPES = {"Heading 1", "Heading 2", "Heading 3", "Page Break"}
4142

4243

4344
class FormField(Document):
@@ -78,6 +79,7 @@ class FormField(Document):
7879
"Heading 1",
7980
"Heading 2",
8081
"Heading 3",
82+
"Page Break",
8183
]
8284
hidden: DF.Check
8385
label: DF.Data
@@ -114,13 +116,12 @@ def to_frappe_field(self) -> dict:
114116
}
115117

116118
def get_options(self) -> str | None:
117-
if self.fieldtype in _DISPLAY_ONLY_FIELDTYPES:
118-
HEADING_MAP = {
119-
"Heading 1": "h1",
120-
"Heading 2": "h2",
121-
"Heading 3": "h3",
122-
}
123-
tag = HEADING_MAP.get(self.fieldtype, "h2")
119+
HEADING_MAP = {
120+
"Heading 1": "h1",
121+
"Heading 2": "h2",
122+
"Heading 3": "h3",
123+
}
124+
if tag := HEADING_MAP.get(self.fieldtype):
124125
return f"<{tag}>{escape_html(self.label or '')}</{tag}>"
125126

126127
return self.options

forms_pro/tests/test_page_break.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import frappe
2+
from frappe.tests import IntegrationTestCase
3+
4+
from forms_pro.tests.factories.form_factory import FormFactory
5+
6+
7+
class TestPageBreakIntegration(IntegrationTestCase):
8+
def test_form_with_page_break_saves(self):
9+
form = FormFactory.create()
10+
form.append(
11+
"fields",
12+
{
13+
"label": "Your Name",
14+
"fieldname": "your_name",
15+
"fieldtype": "Data",
16+
"reqd": 0,
17+
"row_index": 0,
18+
"column_index": 0,
19+
"cell_index": 0,
20+
},
21+
)
22+
form.append(
23+
"fields",
24+
{
25+
"label": "Step 2",
26+
"fieldname": "step_2",
27+
"fieldtype": "Page Break",
28+
"reqd": 0,
29+
"row_index": 1,
30+
"column_index": 0,
31+
"cell_index": 0,
32+
},
33+
)
34+
form.append(
35+
"fields",
36+
{
37+
"label": "Your Email",
38+
"fieldname": "your_email",
39+
"fieldtype": "Email",
40+
"reqd": 0,
41+
"row_index": 2,
42+
"column_index": 0,
43+
"cell_index": 0,
44+
},
45+
)
46+
form.save()
47+
48+
form.reload()
49+
fieldtypes = [f.fieldtype for f in form.fields]
50+
self.assertIn("Page Break", fieldtypes)
51+
52+
def test_page_break_syncs_as_tab_break_to_linked_doctype(self):
53+
form = FormFactory.create()
54+
form.append(
55+
"fields",
56+
{
57+
"label": "Step 2",
58+
"fieldname": "step_2",
59+
"fieldtype": "Page Break",
60+
"reqd": 0,
61+
"row_index": 0,
62+
"column_index": 0,
63+
"cell_index": 0,
64+
},
65+
)
66+
form.save()
67+
68+
doctype_doc = frappe.get_doc("DocType", form.linked_doctype)
69+
synced = {f.fieldname: f for f in doctype_doc.fields}
70+
self.assertIn("step_2", synced)
71+
self.assertEqual(synced["step_2"].fieldtype, "Tab Break")

frontend/e2e/helpers/form-builder.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,69 @@ export class FormBuilderPage {
304304
publishButton() {
305305
return this.page.getByRole("button", { name: /^publish$/i });
306306
}
307+
308+
// ---- Step navigation (SectionNavBar) ----
309+
310+
stepNav(): Locator {
311+
return this.page.locator('nav[aria-label="Form steps"]');
312+
}
313+
314+
// The remove "X" is nested inside the tab button, so tabs after the first
315+
// have the accessible name "<label> Remove step" — match the label exactly,
316+
// with that suffix optional.
317+
stepTab(label: string): Locator {
318+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
319+
return this.stepNav().getByRole("button", {
320+
name: new RegExp(`^${escaped}( Remove step)?$`),
321+
});
322+
}
323+
324+
removeStepControl(label: string): Locator {
325+
return this.stepTab(label).getByLabel("Remove step");
326+
}
327+
328+
activeStepTab(): Locator {
329+
return this.stepNav().locator('button[aria-current="true"]');
330+
}
331+
332+
async addStep() {
333+
await this.stepNav().getByRole("button", { name: "Add Step" }).click();
334+
}
335+
336+
async switchToStep(label: string) {
337+
await this.stepTab(label).click();
338+
}
339+
340+
async renameStep(oldLabel: string, newLabel: string) {
341+
await this.stepTab(oldLabel).dblclick();
342+
const input = this.stepNav().getByRole("textbox");
343+
await input.waitFor({ state: "visible" });
344+
await input.fill(newLabel);
345+
await input.press("Enter");
346+
}
347+
348+
// mode "keep" -> "Move fields to previous step"
349+
// mode "delete"-> "Remove step and fields"
350+
// omit mode for empty steps (no dialog appears)
351+
async removeStep(label: string, mode?: "keep" | "delete") {
352+
await this.removeStepControl(label).click();
353+
if (mode === "keep") {
354+
await this.page
355+
.getByRole("button", { name: "Move fields to previous step" })
356+
.click();
357+
} else if (mode === "delete") {
358+
await this.page
359+
.getByRole("button", { name: "Remove step and fields" })
360+
.click();
361+
}
362+
}
363+
364+
async save() {
365+
const saveBtn = this.page.getByRole("button", {
366+
name: "Save",
367+
exact: true,
368+
});
369+
await saveBtn.click();
370+
await saveBtn.waitFor({ state: "hidden", timeout: 30000 });
371+
}
307372
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { expect, type APIRequestContext } from "@playwright/test";
2+
3+
export type SeedField = {
4+
label: string;
5+
fieldtype?: string;
6+
fieldname?: string;
7+
reqd?: 0 | 1;
8+
hidden?: 0 | 1;
9+
conditional_logic?: string;
10+
row_index?: number;
11+
column_index?: number;
12+
cell_index?: number;
13+
};
14+
15+
function defaultFieldname(label: string): string {
16+
return label
17+
.toLowerCase()
18+
.replace(/[^a-z0-9]+/g, "_")
19+
.replace(/^_+|_+$/g, "");
20+
}
21+
22+
// Replace a form's full field list via REST so the builder/public page load a
23+
// known layout. row_index defaults to the array position, which keeps every
24+
// field on its own row — fine for step tests, which don't care about columns.
25+
export async function setFormFields(
26+
apiContext: APIRequestContext,
27+
formId: string,
28+
fields: SeedField[]
29+
) {
30+
const res = await apiContext.put(`/api/resource/Form/${formId}`, {
31+
data: {
32+
fields: fields.map((f, i) => ({
33+
idx: i + 1,
34+
fieldtype: f.fieldtype ?? "Data",
35+
label: f.label,
36+
fieldname: f.fieldname ?? defaultFieldname(f.label),
37+
reqd: f.reqd ?? 0,
38+
hidden: f.hidden ?? 0,
39+
conditional_logic: f.conditional_logic ?? "",
40+
row_index: f.row_index ?? i,
41+
column_index: f.column_index ?? 0,
42+
cell_index: f.cell_index ?? 0,
43+
})),
44+
},
45+
});
46+
// Fail loudly here rather than as a confusing locator timeout downstream.
47+
expect(
48+
res.ok(),
49+
`setFormFields PUT failed: ${res.status()} ${await res.text()}`
50+
).toBeTruthy();
51+
}
52+
53+
export function pageBreak(label = "", fieldname?: string): SeedField {
54+
return {
55+
label,
56+
fieldtype: "Page Break",
57+
fieldname: fieldname ?? (label ? defaultFieldname(label) : undefined),
58+
};
59+
}
60+
61+
// 3 steps: Basics(Full Name) / Contact(Work Email) / Final(Notes).
62+
// Leading Page Break labels step 1.
63+
export function threeStepFields(): SeedField[] {
64+
return [
65+
pageBreak("Basics", "pb_basics"),
66+
{ label: "Full Name" },
67+
pageBreak("Contact", "pb_contact"),
68+
{ label: "Work Email" },
69+
pageBreak("Final", "pb_final"),
70+
{ label: "Notes" },
71+
];
72+
}
73+
74+
// 2 steps with NO leading Page Break: step 1 is unlabeled in data
75+
// (builder shows fallback "Step 1"), Page Break labels step 2.
76+
export function twoStepFieldsNoLeadingPB(): SeedField[] {
77+
return [
78+
{ label: "Full Name" },
79+
pageBreak("Details", "pb_details"),
80+
{ label: "Notes" },
81+
];
82+
}

frontend/e2e/helpers/submission.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Page } from "@playwright/test";
1+
import type { Locator, Page } from "@playwright/test";
22

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

10+
// Submission fields render a visible <label> sibling, not a for/id association.
11+
fieldInput(label: string): Locator {
12+
return this.page
13+
.locator("div.flex.flex-col.gap-2")
14+
.filter({ has: this.page.getByText(label, { exact: true }) })
15+
.getByRole("textbox");
16+
}
17+
1018
async fillField(label: string, value: string) {
11-
await this.page.getByLabel(label).fill(value);
19+
await this.fieldInput(label).fill(value);
1220
}
1321

1422
async submit() {
@@ -19,4 +27,36 @@ export class SubmissionPage {
1927
successMessage() {
2028
return this.page.getByText(/thank you for submitting/i);
2129
}
30+
31+
// ---- Multi-step navigation ----
32+
33+
stepIndicator(): Locator {
34+
return this.page.locator('nav[aria-label="Form steps"]');
35+
}
36+
37+
stepNode(index: number): Locator {
38+
return this.page.locator(
39+
`[data-step-component="step-node"][data-step-index="${index}"]`
40+
);
41+
}
42+
43+
nextButton(): Locator {
44+
return this.page.getByRole("button", { name: "Next" });
45+
}
46+
47+
backButton(): Locator {
48+
return this.page.getByRole("button", { name: "Back" });
49+
}
50+
51+
submitButton(): Locator {
52+
return this.page.getByRole("button", { name: "Submit" });
53+
}
54+
55+
async next() {
56+
await this.nextButton().click();
57+
}
58+
59+
async back() {
60+
await this.backButton().click();
61+
}
2262
}

0 commit comments

Comments
 (0)