From 9ff92e8411e0f7a8b2ebeee9734c61ce442826cf Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Thu, 4 Jun 2026 12:00:00 +0530 Subject: [PATCH 01/21] feat: add Page Break display-only field type --- .../submission/test_submission_validation.py | 51 +++++++++++++++++++ .../doctype/form_field/form_field.json | 2 +- .../doctype/form_field/form_field.py | 6 ++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/forms_pro/api/submission/test_submission_validation.py b/forms_pro/api/submission/test_submission_validation.py index 320f71d..2a360a8 100644 --- a/forms_pro/api/submission/test_submission_validation.py +++ b/forms_pro/api/submission/test_submission_validation.py @@ -243,3 +243,54 @@ 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 HTML 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_html(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"], "HTML") + + +class TestPageBreakOptions(unittest.TestCase): + def test_page_break_get_options_returns_hr_tag(self): + """Page Break should generate an hr HTML element as its options value.""" + from forms_pro.forms_pro.doctype.form_field.form_field import FormField + + field = FormField.__new__(FormField) + field.fieldtype = "Page Break" + field.label = "Step 2" + field.options = None + result = field.get_options() + self.assertEqual(result, "
") 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 5c0f1fd..0d0cc33 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.json +++ b/forms_pro/forms_pro/doctype/form_field/form_field.json @@ -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 }, { 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 a1a1bc7..e758268 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.py +++ b/forms_pro/forms_pro/doctype/form_field/form_field.py @@ -34,10 +34,11 @@ "Heading 1": {"fieldtype": "HTML"}, "Heading 2": {"fieldtype": "HTML"}, "Heading 3": {"fieldtype": "HTML"}, + "Page Break": {"fieldtype": "HTML"}, } -_DISPLAY_ONLY_FIELDTYPES = {"Heading 1", "Heading 2", "Heading 3"} +_DISPLAY_ONLY_FIELDTYPES = {"Heading 1", "Heading 2", "Heading 3", "Page Break"} class FormField(Document): @@ -115,6 +116,9 @@ def to_frappe_field(self) -> dict: def get_options(self) -> str | None: if self.fieldtype in _DISPLAY_ONLY_FIELDTYPES: + if self.fieldtype == "Page Break": + return "
" + HEADING_MAP = { "Heading 1": "h1", "Heading 2": "h2", From 61714b6ace9110a9f788c609da3bf9181d73d61e Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Thu, 4 Jun 2026 12:01:52 +0530 Subject: [PATCH 02/21] test: integration test for Page Break form save and DocType sync --- forms_pro/tests/test_page_break.py | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 forms_pro/tests/test_page_break.py diff --git a/forms_pro/tests/test_page_break.py b/forms_pro/tests/test_page_break.py new file mode 100644 index 0000000..1929c3c --- /dev/null +++ b/forms_pro/tests/test_page_break.py @@ -0,0 +1,72 @@ +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_html_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, "HTML") + self.assertEqual(synced["step_2"].options, "
") From 3948c51c7c40b32dfad2c104a4443fcf8a8bf11c Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Thu, 4 Jun 2026 12:03:35 +0530 Subject: [PATCH 03/21] chore: add Page Break to auto-generated TypeScript types --- frontend/src/types/FormsPro/form_field.types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/types/FormsPro/form_field.types.ts b/frontend/src/types/FormsPro/form_field.types.ts index 6c569e3..af62609 100644 --- a/frontend/src/types/FormsPro/form_field.types.ts +++ b/frontend/src/types/FormsPro/form_field.types.ts @@ -21,6 +21,7 @@ export enum Fieldtype { "HEADING_1" = "Heading 1", "HEADING_2" = "Heading 2", "HEADING_3" = "Heading 3", + "PAGE_BREAK" = "Page Break", } export interface FormField { From ef5c4cc0dd5f8a8a6e6d044d7b3240c95924aa38 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 5 Jun 2026 01:11:43 +0530 Subject: [PATCH 04/21] 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
workaround. --- .../submission/test_submission_validation.py | 19 +++---------------- .../doctype/form_field/form_field.py | 18 +++++++----------- forms_pro/tests/test_page_break.py | 5 ++--- 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/forms_pro/api/submission/test_submission_validation.py b/forms_pro/api/submission/test_submission_validation.py index 2a360a8..8e0a645 100644 --- a/forms_pro/api/submission/test_submission_validation.py +++ b/forms_pro/api/submission/test_submission_validation.py @@ -269,7 +269,7 @@ def test_required_data_field_still_validated_when_page_break_present(self): class TestPageBreakProperties(unittest.TestCase): def test_page_break_stores_value_is_false(self): - """Page Break maps to HTML which is a no_value_field — stores_value must be False.""" + """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 @@ -277,20 +277,7 @@ def test_page_break_stores_value_is_false(self): mapped = FORM_TO_FRAPPE_FIELDTYPE["Page Break"] self.assertIn(mapped["fieldtype"], no_value_fields) - def test_page_break_frappe_fieldtype_is_html(self): + 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"], "HTML") - - -class TestPageBreakOptions(unittest.TestCase): - def test_page_break_get_options_returns_hr_tag(self): - """Page Break should generate an hr HTML element as its options value.""" - from forms_pro.forms_pro.doctype.form_field.form_field import FormField - - field = FormField.__new__(FormField) - field.fieldtype = "Page Break" - field.label = "Step 2" - field.options = None - result = field.get_options() - self.assertEqual(result, "
") + self.assertEqual(FORM_TO_FRAPPE_FIELDTYPE["Page Break"]["fieldtype"], "Tab Break") 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 e758268..e955bd6 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.py +++ b/forms_pro/forms_pro/doctype/form_field/form_field.py @@ -34,7 +34,7 @@ "Heading 1": {"fieldtype": "HTML"}, "Heading 2": {"fieldtype": "HTML"}, "Heading 3": {"fieldtype": "HTML"}, - "Page Break": {"fieldtype": "HTML"}, + "Page Break": {"fieldtype": "Tab Break"}, } @@ -115,16 +115,12 @@ def to_frappe_field(self) -> dict: } def get_options(self) -> str | None: - if self.fieldtype in _DISPLAY_ONLY_FIELDTYPES: - if self.fieldtype == "Page Break": - return "
" - - 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 '')}" return self.options diff --git a/forms_pro/tests/test_page_break.py b/forms_pro/tests/test_page_break.py index 1929c3c..cf3145d 100644 --- a/forms_pro/tests/test_page_break.py +++ b/forms_pro/tests/test_page_break.py @@ -49,7 +49,7 @@ def test_form_with_page_break_saves(self): fieldtypes = [f.fieldtype for f in form.fields] self.assertIn("Page Break", fieldtypes) - def test_page_break_syncs_as_html_to_linked_doctype(self): + def test_page_break_syncs_as_tab_break_to_linked_doctype(self): form = FormFactory.create() form.append( "fields", @@ -68,5 +68,4 @@ def test_page_break_syncs_as_html_to_linked_doctype(self): 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, "HTML") - self.assertEqual(synced["step_2"].options, "
") + self.assertEqual(synced["step_2"].fieldtype, "Tab Break") From b34cec170d20669180fa792b4b984ade9f4150df Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 5 Jun 2026 12:16:25 +0530 Subject: [PATCH 05/21] docs: add section navigation bar implementation plan --- .../2026-06-05-section-navigation-bar.md | 669 ++++++++++++++++++ 1 file changed, 669 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-section-navigation-bar.md diff --git a/docs/superpowers/plans/2026-06-05-section-navigation-bar.md b/docs/superpowers/plans/2026-06-05-section-navigation-bar.md new file mode 100644 index 0000000..65a8492 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-section-navigation-bar.md @@ -0,0 +1,669 @@ +# Section Navigation Bar Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a section navigation bar to the form builder that lets users organize form fields into sections (backed by Page Break fields) and edit the success page inline. + +**Architecture:** Sections are derived from Page Break field positions in the flat fields array. A Page Break marks the start of a new section; its `label` stores the section name. The first section is implicit ("Section 1") when no Page Break exists at position 0. A new `SectionBar` component sits between the header and canvas. The `editForm` store gains section-aware computed properties and actions. The canvas filters fields to the active section, and shows a success editor when the Success tab is selected. + +**Tech Stack:** Vue 3, Pinia, TypeScript, frappe-ui, vuedraggable, Tailwind CSS + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|---------------| +| Modify | `frontend/src/stores/editForm.ts` | Add section state, computed properties, and actions | +| Modify | `frontend/src/layouts/FormBuilderLayout.vue` | Insert SectionBar between header and content row | +| Modify | `frontend/src/components/FormBuilderContent.vue` | Use `activeSectionFields` for canvas, add success editor view | +| Modify | `frontend/src/components/builder/sidebar/SettingsSection.vue` | Remove Success Page editor section | +| Create | `frontend/src/components/builder/SectionBar.vue` | Section tab bar component | + +--- + +### Task 1: Add section derivation and state to editForm store + +**Files:** +- Modify: `frontend/src/stores/editForm.ts` + +This task adds all the section logic to the store. The UI tasks that follow will consume these properties and actions. + +- [ ] **Step 1: Add `Section` type and `activeSectionIndex` state** + +At the top of the store setup function (after line 21), add: + +```typescript +const activeSectionIndex = ref(0); +``` + +Above the store definition (after the `scrubFieldname` function, before `export const useEditForm`), add the Section type: + +```typescript +export type Section = { + name: string; + fields: FormField[]; + pageBreak: FormField | null; +}; +``` + +- [ ] **Step 2: Add `sections` computed property** + +After the `fields` computed (line 37), add: + +```typescript +const sections = computed(() => { + const allFields: FormField[] = formResource.value?.doc?.fields || []; + const result: Section[] = []; + let currentFields: FormField[] = []; + let currentName = "Section 1"; + let currentPageBreak: FormField | null = null; + + for (const field of allFields) { + if (field.fieldtype === Fieldtype.PAGE_BREAK) { + if (currentFields.length === 0 && result.length === 0) { + currentName = field.label || "Section 1"; + currentPageBreak = field; + } else { + result.push({ + name: currentName, + fields: currentFields, + pageBreak: currentPageBreak, + }); + currentName = field.label || `Section ${result.length + 1}`; + currentPageBreak = field; + currentFields = []; + } + } else { + currentFields.push(field); + } + } + result.push({ + name: currentName, + fields: currentFields, + pageBreak: currentPageBreak, + }); + + return result; +}); + +const activeSectionFields = computed(() => { + const sec = sections.value[activeSectionIndex.value]; + return sec?.fields ?? []; +}); + +const isSuccessActive = computed(() => activeSectionIndex.value === -1); +``` + +- [ ] **Step 3: Add `setActiveSection` action** + +```typescript +function setActiveSection(index: number) { + activeSectionIndex.value = index; + selectedField.value = null; +} +``` + +- [ ] **Step 4: Add `addSection` action** + +This inserts a Page Break field at the end of the fields array and switches to the new section. + +```typescript +function addSection() { + if (!formResource.value?.doc) return; + const fs: FormField[] = formResource.value.doc.fields; + const newSectionIndex = sections.value.length; + const newPageBreak: FormField = { + idx: fs.length + 1, + fieldtype: Fieldtype.PAGE_BREAK, + label: `Section ${newSectionIndex + 1}`, + fieldname: `section_${newSectionIndex + 1}`, + options: "", + default: "", + description: "", + row_index: lastRowIndex(fs) + 1, + column_index: 0, + cell_index: 0, + }; + fs.push(newPageBreak); + activeSectionIndex.value = newSectionIndex; + selectedField.value = null; +} +``` + +- [ ] **Step 5: Add `renameSection` action** + +```typescript +function renameSection(index: number, name: string) { + if (!formResource.value?.doc) return; + const fs: FormField[] = formResource.value.doc.fields; + const section = sections.value[index]; + if (!section) return; + + if (section.pageBreak) { + section.pageBreak.label = name; + section.pageBreak.fieldname = scrubFieldname(name); + } else { + const firstNonPBIndex = fs.findIndex( + (f) => f.fieldtype !== Fieldtype.PAGE_BREAK + ); + const insertAt = firstNonPBIndex === -1 ? 0 : firstNonPBIndex; + const newPB: FormField = { + idx: 0, + fieldtype: Fieldtype.PAGE_BREAK, + label: name, + fieldname: scrubFieldname(name), + options: "", + default: "", + description: "", + row_index: 0, + column_index: 0, + cell_index: 0, + }; + fs.splice(insertAt, 0, newPB); + compact(); + } +} +``` + +- [ ] **Step 6: Add `deleteSection` action** + +```typescript +function deleteSection(index: number, mergeFields: boolean) { + if (!formResource.value?.doc) return; + if (index === 0 && sections.value.length === 1) return; + + const section = sections.value[index]; + if (!section) return; + + const fs: FormField[] = formResource.value.doc.fields; + + if (mergeFields) { + if (section.pageBreak) { + formResource.value.doc.fields = fs.filter( + (f: FormField) => f !== section.pageBreak + ); + } + } else { + const toRemove = new Set(section.fields); + if (section.pageBreak) toRemove.add(section.pageBreak); + formResource.value.doc.fields = fs.filter( + (f: FormField) => !toRemove.has(f) + ); + } + + compact(); + + if (activeSectionIndex.value >= sections.value.length) { + activeSectionIndex.value = Math.max(0, sections.value.length - 1); + } + selectedField.value = null; +} +``` + +- [ ] **Step 7: Add `reorderSections` action** + +```typescript +function reorderSections(fromIndex: number, toIndex: number) { + if (!formResource.value?.doc) return; + if (fromIndex === toIndex) return; + + const secs = sections.value; + if (fromIndex < 0 || fromIndex >= secs.length) return; + if (toIndex < 0 || toIndex >= secs.length) return; + + const ordered: FormField[] = []; + const reordered = [...secs]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, moved); + + for (let i = 0; i < reordered.length; i++) { + const sec = reordered[i]; + if (sec.pageBreak) { + ordered.push(sec.pageBreak); + } + ordered.push(...sec.fields); + } + + formResource.value.doc.fields = ordered; + compact(); + activeSectionIndex.value = toIndex; +} +``` + +- [ ] **Step 8: Modify `addField` to insert into active section** + +Replace the current `addField` function (lines 248-267) with: + +```typescript +function addField(fieldtype: Fieldtype) { + if (formResource.value?.doc) { + const fs: FormField[] = formResource.value.doc.fields; + const sectionFields = activeSectionFields.value; + + const newField: FormField = { + idx: fs.length + 1, + fieldtype, + label: "", + fieldname: "", + options: "", + default: "", + description: "", + row_index: lastRowIndex(sectionFields) + 1, + column_index: 0, + cell_index: 0, + }; + + const section = sections.value[activeSectionIndex.value]; + if (!section) { + fs.push(newField); + return; + } + + const lastFieldInSection = + section.fields.length > 0 + ? section.fields[section.fields.length - 1] + : section.pageBreak; + const insertAfterIdx = lastFieldInSection + ? fs.indexOf(lastFieldInSection) + 1 + : fs.length; + + fs.splice(insertAfterIdx, 0, newField); + compact(); + } +} +``` + +- [ ] **Step 9: Modify `addFieldFromDoctype` similarly** + +Replace the current `addFieldFromDoctype` function (lines 269-287) with: + +```typescript +function addFieldFromDoctype(field: any) { + if (!formResource.value?.doc) return; + const fs: FormField[] = formResource.value.doc.fields; + const sectionFields = activeSectionFields.value; + + const _newField: FormField = { + idx: fs.length + 1, + fieldtype: field.fieldtype, + label: field.label, + fieldname: field.fieldname, + options: field.options, + default: field.default, + description: field.description, + row_index: lastRowIndex(sectionFields) + 1, + column_index: 0, + cell_index: 0, + }; + + const section = sections.value[activeSectionIndex.value]; + if (!section) { + fs.push(_newField); + return; + } + + const lastFieldInSection = + section.fields.length > 0 + ? section.fields[section.fields.length - 1] + : section.pageBreak; + const insertAfterIdx = lastFieldInSection + ? fs.indexOf(lastFieldInSection) + 1 + : fs.length; + + fs.splice(insertAfterIdx, 0, _newField); + compact(); +} +``` + +- [ ] **Step 10: Export new state, computed properties, and actions** + +Add to the return statement (after line 414): + +```typescript +// Section state +activeSectionIndex, + +// Section computed +sections, +activeSectionFields, +isSuccessActive, + +// Section actions +setActiveSection, +addSection, +renameSection, +deleteSection, +reorderSections, +``` + +- [ ] **Step 11: Run typecheck** + +Run: `cd frontend && yarn typecheck` +Expected: PASS (no type errors) + +- [ ] **Step 12: Commit** + +```bash +git add frontend/src/stores/editForm.ts +git commit -m "feat: add section derivation and management to editForm store" +``` + +--- + +### Task 2: Create SectionBar component + +**Files:** +- Create: `frontend/src/components/builder/SectionBar.vue` + +A simple starter component. The user will build on this further (drag-to-reorder, right-click context menu, inline rename). This version covers: clickable tabs, add section button, success tab, and basic active state styling. + +- [ ] **Step 1: Create the SectionBar component** + +```vue + + +``` + +- [ ] **Step 2: Run typecheck** + +Run: `cd frontend && yarn typecheck` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/builder/SectionBar.vue +git commit -m "feat: add SectionBar component for form builder" +``` + +--- + +### Task 3: Wire SectionBar into layout and update canvas + +**Files:** +- Modify: `frontend/src/layouts/FormBuilderLayout.vue` +- Modify: `frontend/src/components/FormBuilderContent.vue` + +- [ ] **Step 1: Add SectionBar to FormBuilderLayout** + +Replace the full content of `FormBuilderLayout.vue` with: + +```vue + + +``` + +- [ ] **Step 2: Update FormBuilderContent to use activeSectionFields** + +In `FormBuilderContent.vue`, change line 30 from: + +```typescript +const groupedRows = useGroupedRows(computed(() => editFormStore.fields)); +``` + +to: + +```typescript +const groupedRows = useGroupedRows(computed(() => editFormStore.activeSectionFields)); +``` + +- [ ] **Step 3: Update the empty state check in FormBuilderContent** + +Change line 195 from: + +```html +
+``` + +to: + +```html +
+``` + +- [ ] **Step 4: Add success editor view to FormBuilderContent** + +In the template, after the existing form card `div` (after line 268, before ``), add the success editor. Wrap the existing form card in a `v-if="!editFormStore.isSuccessActive"` and add the success view as a sibling: + +The form card div at line 172 currently starts with: +```html +
+``` + +Change the `v-if` to: +```html +
+``` + +Then after the closing `
` of that form card (line 268), add: + +```html +
+
+

Success Page

+
+ + +
+
+ + +
+
+
+``` + +- [ ] **Step 5: Update the onCellChange handler to use activeSectionFields** + +In `onCellChange` (line 48), change `editFormStore.fields` to `editFormStore.activeSectionFields`: + +```typescript +const cells: FormField[] = editFormStore.activeSectionFields + .filter( + (f: FormField) => + (f.row_index ?? 0) === rowIndex && (f.column_index ?? 0) === colIndex + ) + .sort((a: FormField, b: FormField) => (a.cell_index ?? 0) - (b.cell_index ?? 0)); +``` + +- [ ] **Step 6: Run typecheck** + +Run: `cd frontend && yarn typecheck` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/layouts/FormBuilderLayout.vue frontend/src/components/FormBuilderContent.vue +git commit -m "feat: wire SectionBar into layout and make canvas section-aware" +``` + +--- + +### Task 4: Remove Success Page editor from SettingsSection sidebar + +**Files:** +- Modify: `frontend/src/components/builder/sidebar/SettingsSection.vue` + +The success editor now lives in the canvas (Task 3), so remove it from the sidebar. + +- [ ] **Step 1: Remove the Success Page section from SettingsSection** + +Remove everything from line 103 to line 176 (the `
Success Page
` heading through the closing `
` of the success section, including the Dialog and expand button). + +Also remove the unused imports. The `Dialog` and `TextEditor` imports (line 2) can be simplified to: + +```typescript +import { Checkbox, FormControl, Tooltip } from "frappe-ui"; +``` + +Remove `ref` from the `ref, watch` import if `inExpandedDescription` was the only ref (it was not, `showValidateMsg` and `routeExists` remain). The `ref` import stays. + +Remove the `inExpandedDescription` ref (line 33): +```typescript +// DELETE: const inExpandedDescription = ref(false); +``` + +Also remove the `CircleCheck` import only if it's unused elsewhere. Check: `CircleCheck` is used for the route validation display (line 70). Keep it. + +The remaining template should end after the "Allow Incomplete Forms" checkbox section (line 101). + +- [ ] **Step 2: Run typecheck** + +Run: `cd frontend && yarn typecheck` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/builder/sidebar/SettingsSection.vue +git commit -m "refactor: move success page editor from sidebar to canvas" +``` + +--- + +### Task 5: Manual verification + +**Files:** None (testing only) + +- [ ] **Step 1: Start the dev server** + +Run: `cd frontend && npm run dev` + +- [ ] **Step 2: Open the form builder in a browser** + +Navigate to `http://localhost:8001/forms/edit-form/`. + +- [ ] **Step 3: Verify section bar appears** + +Confirm: +- Section bar visible between header and canvas +- "Section 1" tab is active by default +- "+" button is present +- "Success" tab is pinned to the right + +- [ ] **Step 4: Test adding a section** + +Click "+". Confirm: +- "Section 2" tab appears +- Canvas switches to Section 2 (empty) +- Clicking "Section 1" shows original fields +- Clicking "Section 2" shows empty section + +- [ ] **Step 5: Test adding fields to a section** + +With Section 2 active, add a field from the sidebar. Confirm: +- Field appears in Section 2 canvas +- Switching to Section 1 does not show the new field +- Switching back to Section 2 shows it + +- [ ] **Step 6: Test success tab** + +Click "Success". Confirm: +- Form card disappears +- Success title input and description editor appear +- Changes to success title/description mark the form as unsaved +- Switching back to a section shows the form card again + +- [ ] **Step 7: Test save** + +Save the form (Cmd+S or Save button). Confirm: +- Form saves successfully +- Reload the page: sections persist (Page Break fields are saved) +- Section names persist + +- [ ] **Step 8: Verify success editor removed from sidebar** + +Open Settings tab in left sidebar. Confirm: +- "Success Page" section no longer appears in sidebar settings From 4a2377381288ab51cf25e8d8c1651d0a53771344 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 8 Jun 2026 01:00:20 +0530 Subject: [PATCH 06/21] Revert "docs: add section navigation bar implementation plan" This reverts commit b34cec170d20669180fa792b4b984ade9f4150df. --- .../2026-06-05-section-navigation-bar.md | 669 ------------------ 1 file changed, 669 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-05-section-navigation-bar.md diff --git a/docs/superpowers/plans/2026-06-05-section-navigation-bar.md b/docs/superpowers/plans/2026-06-05-section-navigation-bar.md deleted file mode 100644 index 65a8492..0000000 --- a/docs/superpowers/plans/2026-06-05-section-navigation-bar.md +++ /dev/null @@ -1,669 +0,0 @@ -# Section Navigation Bar Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a section navigation bar to the form builder that lets users organize form fields into sections (backed by Page Break fields) and edit the success page inline. - -**Architecture:** Sections are derived from Page Break field positions in the flat fields array. A Page Break marks the start of a new section; its `label` stores the section name. The first section is implicit ("Section 1") when no Page Break exists at position 0. A new `SectionBar` component sits between the header and canvas. The `editForm` store gains section-aware computed properties and actions. The canvas filters fields to the active section, and shows a success editor when the Success tab is selected. - -**Tech Stack:** Vue 3, Pinia, TypeScript, frappe-ui, vuedraggable, Tailwind CSS - ---- - -## File Map - -| Action | File | Responsibility | -|--------|------|---------------| -| Modify | `frontend/src/stores/editForm.ts` | Add section state, computed properties, and actions | -| Modify | `frontend/src/layouts/FormBuilderLayout.vue` | Insert SectionBar between header and content row | -| Modify | `frontend/src/components/FormBuilderContent.vue` | Use `activeSectionFields` for canvas, add success editor view | -| Modify | `frontend/src/components/builder/sidebar/SettingsSection.vue` | Remove Success Page editor section | -| Create | `frontend/src/components/builder/SectionBar.vue` | Section tab bar component | - ---- - -### Task 1: Add section derivation and state to editForm store - -**Files:** -- Modify: `frontend/src/stores/editForm.ts` - -This task adds all the section logic to the store. The UI tasks that follow will consume these properties and actions. - -- [ ] **Step 1: Add `Section` type and `activeSectionIndex` state** - -At the top of the store setup function (after line 21), add: - -```typescript -const activeSectionIndex = ref(0); -``` - -Above the store definition (after the `scrubFieldname` function, before `export const useEditForm`), add the Section type: - -```typescript -export type Section = { - name: string; - fields: FormField[]; - pageBreak: FormField | null; -}; -``` - -- [ ] **Step 2: Add `sections` computed property** - -After the `fields` computed (line 37), add: - -```typescript -const sections = computed(() => { - const allFields: FormField[] = formResource.value?.doc?.fields || []; - const result: Section[] = []; - let currentFields: FormField[] = []; - let currentName = "Section 1"; - let currentPageBreak: FormField | null = null; - - for (const field of allFields) { - if (field.fieldtype === Fieldtype.PAGE_BREAK) { - if (currentFields.length === 0 && result.length === 0) { - currentName = field.label || "Section 1"; - currentPageBreak = field; - } else { - result.push({ - name: currentName, - fields: currentFields, - pageBreak: currentPageBreak, - }); - currentName = field.label || `Section ${result.length + 1}`; - currentPageBreak = field; - currentFields = []; - } - } else { - currentFields.push(field); - } - } - result.push({ - name: currentName, - fields: currentFields, - pageBreak: currentPageBreak, - }); - - return result; -}); - -const activeSectionFields = computed(() => { - const sec = sections.value[activeSectionIndex.value]; - return sec?.fields ?? []; -}); - -const isSuccessActive = computed(() => activeSectionIndex.value === -1); -``` - -- [ ] **Step 3: Add `setActiveSection` action** - -```typescript -function setActiveSection(index: number) { - activeSectionIndex.value = index; - selectedField.value = null; -} -``` - -- [ ] **Step 4: Add `addSection` action** - -This inserts a Page Break field at the end of the fields array and switches to the new section. - -```typescript -function addSection() { - if (!formResource.value?.doc) return; - const fs: FormField[] = formResource.value.doc.fields; - const newSectionIndex = sections.value.length; - const newPageBreak: FormField = { - idx: fs.length + 1, - fieldtype: Fieldtype.PAGE_BREAK, - label: `Section ${newSectionIndex + 1}`, - fieldname: `section_${newSectionIndex + 1}`, - options: "", - default: "", - description: "", - row_index: lastRowIndex(fs) + 1, - column_index: 0, - cell_index: 0, - }; - fs.push(newPageBreak); - activeSectionIndex.value = newSectionIndex; - selectedField.value = null; -} -``` - -- [ ] **Step 5: Add `renameSection` action** - -```typescript -function renameSection(index: number, name: string) { - if (!formResource.value?.doc) return; - const fs: FormField[] = formResource.value.doc.fields; - const section = sections.value[index]; - if (!section) return; - - if (section.pageBreak) { - section.pageBreak.label = name; - section.pageBreak.fieldname = scrubFieldname(name); - } else { - const firstNonPBIndex = fs.findIndex( - (f) => f.fieldtype !== Fieldtype.PAGE_BREAK - ); - const insertAt = firstNonPBIndex === -1 ? 0 : firstNonPBIndex; - const newPB: FormField = { - idx: 0, - fieldtype: Fieldtype.PAGE_BREAK, - label: name, - fieldname: scrubFieldname(name), - options: "", - default: "", - description: "", - row_index: 0, - column_index: 0, - cell_index: 0, - }; - fs.splice(insertAt, 0, newPB); - compact(); - } -} -``` - -- [ ] **Step 6: Add `deleteSection` action** - -```typescript -function deleteSection(index: number, mergeFields: boolean) { - if (!formResource.value?.doc) return; - if (index === 0 && sections.value.length === 1) return; - - const section = sections.value[index]; - if (!section) return; - - const fs: FormField[] = formResource.value.doc.fields; - - if (mergeFields) { - if (section.pageBreak) { - formResource.value.doc.fields = fs.filter( - (f: FormField) => f !== section.pageBreak - ); - } - } else { - const toRemove = new Set(section.fields); - if (section.pageBreak) toRemove.add(section.pageBreak); - formResource.value.doc.fields = fs.filter( - (f: FormField) => !toRemove.has(f) - ); - } - - compact(); - - if (activeSectionIndex.value >= sections.value.length) { - activeSectionIndex.value = Math.max(0, sections.value.length - 1); - } - selectedField.value = null; -} -``` - -- [ ] **Step 7: Add `reorderSections` action** - -```typescript -function reorderSections(fromIndex: number, toIndex: number) { - if (!formResource.value?.doc) return; - if (fromIndex === toIndex) return; - - const secs = sections.value; - if (fromIndex < 0 || fromIndex >= secs.length) return; - if (toIndex < 0 || toIndex >= secs.length) return; - - const ordered: FormField[] = []; - const reordered = [...secs]; - const [moved] = reordered.splice(fromIndex, 1); - reordered.splice(toIndex, 0, moved); - - for (let i = 0; i < reordered.length; i++) { - const sec = reordered[i]; - if (sec.pageBreak) { - ordered.push(sec.pageBreak); - } - ordered.push(...sec.fields); - } - - formResource.value.doc.fields = ordered; - compact(); - activeSectionIndex.value = toIndex; -} -``` - -- [ ] **Step 8: Modify `addField` to insert into active section** - -Replace the current `addField` function (lines 248-267) with: - -```typescript -function addField(fieldtype: Fieldtype) { - if (formResource.value?.doc) { - const fs: FormField[] = formResource.value.doc.fields; - const sectionFields = activeSectionFields.value; - - const newField: FormField = { - idx: fs.length + 1, - fieldtype, - label: "", - fieldname: "", - options: "", - default: "", - description: "", - row_index: lastRowIndex(sectionFields) + 1, - column_index: 0, - cell_index: 0, - }; - - const section = sections.value[activeSectionIndex.value]; - if (!section) { - fs.push(newField); - return; - } - - const lastFieldInSection = - section.fields.length > 0 - ? section.fields[section.fields.length - 1] - : section.pageBreak; - const insertAfterIdx = lastFieldInSection - ? fs.indexOf(lastFieldInSection) + 1 - : fs.length; - - fs.splice(insertAfterIdx, 0, newField); - compact(); - } -} -``` - -- [ ] **Step 9: Modify `addFieldFromDoctype` similarly** - -Replace the current `addFieldFromDoctype` function (lines 269-287) with: - -```typescript -function addFieldFromDoctype(field: any) { - if (!formResource.value?.doc) return; - const fs: FormField[] = formResource.value.doc.fields; - const sectionFields = activeSectionFields.value; - - const _newField: FormField = { - idx: fs.length + 1, - fieldtype: field.fieldtype, - label: field.label, - fieldname: field.fieldname, - options: field.options, - default: field.default, - description: field.description, - row_index: lastRowIndex(sectionFields) + 1, - column_index: 0, - cell_index: 0, - }; - - const section = sections.value[activeSectionIndex.value]; - if (!section) { - fs.push(_newField); - return; - } - - const lastFieldInSection = - section.fields.length > 0 - ? section.fields[section.fields.length - 1] - : section.pageBreak; - const insertAfterIdx = lastFieldInSection - ? fs.indexOf(lastFieldInSection) + 1 - : fs.length; - - fs.splice(insertAfterIdx, 0, _newField); - compact(); -} -``` - -- [ ] **Step 10: Export new state, computed properties, and actions** - -Add to the return statement (after line 414): - -```typescript -// Section state -activeSectionIndex, - -// Section computed -sections, -activeSectionFields, -isSuccessActive, - -// Section actions -setActiveSection, -addSection, -renameSection, -deleteSection, -reorderSections, -``` - -- [ ] **Step 11: Run typecheck** - -Run: `cd frontend && yarn typecheck` -Expected: PASS (no type errors) - -- [ ] **Step 12: Commit** - -```bash -git add frontend/src/stores/editForm.ts -git commit -m "feat: add section derivation and management to editForm store" -``` - ---- - -### Task 2: Create SectionBar component - -**Files:** -- Create: `frontend/src/components/builder/SectionBar.vue` - -A simple starter component. The user will build on this further (drag-to-reorder, right-click context menu, inline rename). This version covers: clickable tabs, add section button, success tab, and basic active state styling. - -- [ ] **Step 1: Create the SectionBar component** - -```vue - - -``` - -- [ ] **Step 2: Run typecheck** - -Run: `cd frontend && yarn typecheck` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add frontend/src/components/builder/SectionBar.vue -git commit -m "feat: add SectionBar component for form builder" -``` - ---- - -### Task 3: Wire SectionBar into layout and update canvas - -**Files:** -- Modify: `frontend/src/layouts/FormBuilderLayout.vue` -- Modify: `frontend/src/components/FormBuilderContent.vue` - -- [ ] **Step 1: Add SectionBar to FormBuilderLayout** - -Replace the full content of `FormBuilderLayout.vue` with: - -```vue - - -``` - -- [ ] **Step 2: Update FormBuilderContent to use activeSectionFields** - -In `FormBuilderContent.vue`, change line 30 from: - -```typescript -const groupedRows = useGroupedRows(computed(() => editFormStore.fields)); -``` - -to: - -```typescript -const groupedRows = useGroupedRows(computed(() => editFormStore.activeSectionFields)); -``` - -- [ ] **Step 3: Update the empty state check in FormBuilderContent** - -Change line 195 from: - -```html -
-``` - -to: - -```html -
-``` - -- [ ] **Step 4: Add success editor view to FormBuilderContent** - -In the template, after the existing form card `div` (after line 268, before ``), add the success editor. Wrap the existing form card in a `v-if="!editFormStore.isSuccessActive"` and add the success view as a sibling: - -The form card div at line 172 currently starts with: -```html -
-``` - -Change the `v-if` to: -```html -
-``` - -Then after the closing `
` of that form card (line 268), add: - -```html -
-
-

Success Page

-
- - -
-
- - -
-
-
-``` - -- [ ] **Step 5: Update the onCellChange handler to use activeSectionFields** - -In `onCellChange` (line 48), change `editFormStore.fields` to `editFormStore.activeSectionFields`: - -```typescript -const cells: FormField[] = editFormStore.activeSectionFields - .filter( - (f: FormField) => - (f.row_index ?? 0) === rowIndex && (f.column_index ?? 0) === colIndex - ) - .sort((a: FormField, b: FormField) => (a.cell_index ?? 0) - (b.cell_index ?? 0)); -``` - -- [ ] **Step 6: Run typecheck** - -Run: `cd frontend && yarn typecheck` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add frontend/src/layouts/FormBuilderLayout.vue frontend/src/components/FormBuilderContent.vue -git commit -m "feat: wire SectionBar into layout and make canvas section-aware" -``` - ---- - -### Task 4: Remove Success Page editor from SettingsSection sidebar - -**Files:** -- Modify: `frontend/src/components/builder/sidebar/SettingsSection.vue` - -The success editor now lives in the canvas (Task 3), so remove it from the sidebar. - -- [ ] **Step 1: Remove the Success Page section from SettingsSection** - -Remove everything from line 103 to line 176 (the `
Success Page
` heading through the closing `
` of the success section, including the Dialog and expand button). - -Also remove the unused imports. The `Dialog` and `TextEditor` imports (line 2) can be simplified to: - -```typescript -import { Checkbox, FormControl, Tooltip } from "frappe-ui"; -``` - -Remove `ref` from the `ref, watch` import if `inExpandedDescription` was the only ref (it was not, `showValidateMsg` and `routeExists` remain). The `ref` import stays. - -Remove the `inExpandedDescription` ref (line 33): -```typescript -// DELETE: const inExpandedDescription = ref(false); -``` - -Also remove the `CircleCheck` import only if it's unused elsewhere. Check: `CircleCheck` is used for the route validation display (line 70). Keep it. - -The remaining template should end after the "Allow Incomplete Forms" checkbox section (line 101). - -- [ ] **Step 2: Run typecheck** - -Run: `cd frontend && yarn typecheck` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add frontend/src/components/builder/sidebar/SettingsSection.vue -git commit -m "refactor: move success page editor from sidebar to canvas" -``` - ---- - -### Task 5: Manual verification - -**Files:** None (testing only) - -- [ ] **Step 1: Start the dev server** - -Run: `cd frontend && npm run dev` - -- [ ] **Step 2: Open the form builder in a browser** - -Navigate to `http://localhost:8001/forms/edit-form/`. - -- [ ] **Step 3: Verify section bar appears** - -Confirm: -- Section bar visible between header and canvas -- "Section 1" tab is active by default -- "+" button is present -- "Success" tab is pinned to the right - -- [ ] **Step 4: Test adding a section** - -Click "+". Confirm: -- "Section 2" tab appears -- Canvas switches to Section 2 (empty) -- Clicking "Section 1" shows original fields -- Clicking "Section 2" shows empty section - -- [ ] **Step 5: Test adding fields to a section** - -With Section 2 active, add a field from the sidebar. Confirm: -- Field appears in Section 2 canvas -- Switching to Section 1 does not show the new field -- Switching back to Section 2 shows it - -- [ ] **Step 6: Test success tab** - -Click "Success". Confirm: -- Form card disappears -- Success title input and description editor appear -- Changes to success title/description mark the form as unsaved -- Switching back to a section shows the form card again - -- [ ] **Step 7: Test save** - -Save the form (Cmd+S or Save button). Confirm: -- Form saves successfully -- Reload the page: sections persist (Page Break fields are saved) -- Section names persist - -- [ ] **Step 8: Verify success editor removed from sidebar** - -Open Settings tab in left sidebar. Confirm: -- "Success Page" section no longer appears in sidebar settings From 82ca940940ef0618643c9ec67638f5939a155990 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 8 Jun 2026 01:01:15 +0530 Subject: [PATCH 07/21] chore: gitignore screenshot PNGs --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 89c6698..45f5bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,7 @@ frontend/e2e/playwright-report/ skills-lock.json -semgrep-rules/ \ No newline at end of file +semgrep-rules/ + +# Screenshots +*.png \ No newline at end of file From adca5b5aa0c1729cddaebbcc9e3640153c43cd20 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 8 Jun 2026 01:01:27 +0530 Subject: [PATCH 08/21] 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. --- .../src/components/builder/FieldRenderer.vue | 10 +++++++ .../builder/sidebar/AddFieldsSection.vue | 7 ++++- frontend/src/components/fields/PageBreak.vue | 29 +++++++++++++++++++ .../form/submissions/SubmissionFieldValue.vue | 5 ++-- frontend/src/config/fieldTypes.ts | 15 +++++++++- frontend/src/utils/form_fields.ts | 8 +++++ 6 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/fields/PageBreak.vue diff --git a/frontend/src/components/builder/FieldRenderer.vue b/frontend/src/components/builder/FieldRenderer.vue index b6f1cec..8aa28ff 100644 --- a/frontend/src/components/builder/FieldRenderer.vue +++ b/frontend/src/components/builder/FieldRenderer.vue @@ -3,6 +3,7 @@ import { computed } from "vue"; import RenderField from "../RenderField.vue"; import FieldLabel from "./FieldLabel.vue"; import Heading from "@/components/fields/Heading.vue"; +import PageBreak from "@/components/fields/PageBreak.vue"; import Table from "@/components/fields/Table.vue"; import { useFieldOptions } from "@/utils/selectOptions"; import { getFieldTypeDef, Fieldtype } from "@/config/fieldTypes"; @@ -84,6 +85,15 @@ const { options: selectOptions } = useFieldOptions(fieldData); />
+ +
+ +
+
import { ref, computed } from "vue"; import { formFields, type FormFields } from "@/utils/form_fields"; +import { Fieldtype } from "@/types/FormsPro/form_field.types"; import { FormControl } from "frappe-ui"; import { useEditForm } from "@/stores/editForm"; const search = ref(""); +const HIDDEN_FROM_SIDEBAR = new Set([Fieldtype.PAGE_BREAK]); + const filteredFields = computed(() => { const q = search.value.toLowerCase(); - return formFields.filter((field: FormFields) => field.name.toLowerCase().includes(q)); + return formFields + .filter((field: FormFields) => !HIDDEN_FROM_SIDEBAR.has(field.name)) + .filter((field: FormFields) => field.name.toLowerCase().includes(q)); }); const editFormStore = useEditForm(); diff --git a/frontend/src/components/fields/PageBreak.vue b/frontend/src/components/fields/PageBreak.vue new file mode 100644 index 0000000..f9f6a79 --- /dev/null +++ b/frontend/src/components/fields/PageBreak.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/form/submissions/SubmissionFieldValue.vue b/frontend/src/components/form/submissions/SubmissionFieldValue.vue index dcbb56f..74cbccd 100644 --- a/frontend/src/components/form/submissions/SubmissionFieldValue.vue +++ b/frontend/src/components/form/submissions/SubmissionFieldValue.vue @@ -4,7 +4,7 @@ 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 { isHeading, isPageBreak } from "@/utils/form_fields"; import Heading from "@/components/fields/Heading.vue"; const props = defineProps<{ @@ -76,7 +76,8 @@ const classNames = computed(() => diff --git a/frontend/src/components/builder/SectionNavBar.vue b/frontend/src/components/builder/SectionNavBar.vue new file mode 100644 index 0000000..bf9c279 --- /dev/null +++ b/frontend/src/components/builder/SectionNavBar.vue @@ -0,0 +1,135 @@ + + + diff --git a/frontend/src/stores/editForm.ts b/frontend/src/stores/editForm.ts index 505fa9b..50a9529 100644 --- a/frontend/src/stores/editForm.ts +++ b/frontend/src/stores/editForm.ts @@ -15,10 +15,13 @@ function scrubFieldname(label: string) { .replace(/_{2,}/g, "_"); // collapse multiple underscores } +type Section = { label: string; fields: FormField[] }; + export const useEditForm = defineStore("editForm", () => { const formResource = ref(null); const currentFormId = ref(null); const selectedField = ref(null); + const activeSectionIndex = ref(0); const isUnsaved = computed(() => formResource.value?.isDirty || false); const isLoading = computed(() => formResource.value?.loading || false); const isSaving = computed( @@ -35,6 +38,48 @@ export const useEditForm = defineStore("editForm", () => { const fields = computed(() => { return formResource.value?.doc?.fields || []; }); + + const sections = computed(() => { + const all: FormField[] = fields.value; + const result: Section[] = []; + let current: FormField[] = []; + let nextLabel = ""; + let isFirst = true; + + for (const field of all) { + if (field.fieldtype === Fieldtype.PAGE_BREAK) { + if (isFirst) { + nextLabel = field.label || "Section 1"; + isFirst = false; + continue; + } + result.push({ + label: nextLabel || `Section ${result.length + 1}`, + fields: current, + }); + current = []; + nextLabel = field.label || ""; + continue; + } + isFirst = false; + current.push(field); + } + + result.push({ + label: + nextLabel || + (result.length === 0 ? "Section 1" : `Section ${result.length + 1}`), + fields: current, + }); + + return result; + }); + + const isMultiSection = computed(() => sections.value.length > 1); + + const activeSectionFields = computed( + () => sections.value[activeSectionIndex.value]?.fields ?? [] + ); const originalFormData = computed( () => formResource.value?.originalDoc || null ); @@ -246,24 +291,30 @@ export const useEditForm = defineStore("editForm", () => { } function addField(fieldtype: Fieldtype) { - if (formResource.value?.doc) { - const fs: FormField[] = formResource.value.doc.fields; - - const newField: FormField = { - idx: fs.length + 1, - fieldtype, - label: "", - fieldname: "", - options: "", - default: "", - description: "", - row_index: lastRowIndex(fs) + 1, - column_index: 0, - cell_index: 0, - }; + if (!formResource.value?.doc) return; + const fs: FormField[] = formResource.value.doc.fields; - fs.push(newField); - } + const sectionFields = activeSectionFields.value; + const newRowIndex = + sectionFields.length > 0 + ? Math.max(...sectionFields.map((f) => f.row_index ?? 0)) + 1 + : 0; + + const newField: FormField = { + idx: fs.length + 1, + fieldtype, + label: "", + fieldname: "", + options: "", + default: "", + description: "", + row_index: newRowIndex, + column_index: 0, + cell_index: 0, + }; + + const insertAt = getActiveSectionEndIndex(); + fs.splice(insertAt, 0, newField); } function addFieldFromDoctype(field: any) { @@ -364,6 +415,149 @@ export const useEditForm = defineStore("editForm", () => { } } + function getActiveSectionEndIndex(): number { + const fs: FormField[] = formResource.value?.doc?.fields ?? []; + if (!isMultiSection.value) return fs.length; + + let sectionIdx = 0; + for (let i = 0; i < fs.length; i++) { + if (fs[i].fieldtype === Fieldtype.PAGE_BREAK) { + if (sectionIdx === activeSectionIndex.value) return i; + sectionIdx++; + } + } + return fs.length; + } + + function addSection() { + const fs: FormField[] = formResource.value?.doc?.fields; + if (!fs) return; + + const count = sections.value.length; + fs.push({ + idx: fs.length + 1, + fieldtype: Fieldtype.PAGE_BREAK, + label: `Section ${count + 1}`, + fieldname: scrubFieldname(`section_${count + 1}`), + row_index: lastRowIndex(fs) + 1, + column_index: 0, + cell_index: 0, + } as FormField); + + activeSectionIndex.value = count; + } + + function findSectionPBIndex(sectionIndex: number): number { + const fs: FormField[] = formResource.value?.doc?.fields; + if (!fs) return -1; + const hasLeadingPB = fs[0]?.fieldtype === Fieldtype.PAGE_BREAK; + const target = sectionIndex + (hasLeadingPB ? 1 : 0); + let pbCount = 0; + for (let i = 0; i < fs.length; i++) { + if (fs[i].fieldtype === Fieldtype.PAGE_BREAK) { + pbCount++; + if (pbCount === target) return i; + } + } + return -1; + } + + function stripOrphanedLeadingPB() { + const fs: FormField[] = formResource.value?.doc?.fields; + if (!fs) return; + const hasLeadingPB = fs[0]?.fieldtype === Fieldtype.PAGE_BREAK; + const pbCount = fs.filter( + (f) => f.fieldtype === Fieldtype.PAGE_BREAK + ).length; + if (hasLeadingPB && pbCount === 1) { + fs.splice(0, 1); + } + } + + function removeSectionKeepFields(index: number) { + if (index <= 0) return; + const fs: FormField[] = formResource.value?.doc?.fields; + if (!fs) return; + + const pbIdx = findSectionPBIndex(index); + if (pbIdx === -1) return; + + const prevFields = sections.value[index - 1]?.fields ?? []; + const movedFields = sections.value[index]?.fields ?? []; + + const maxPrevRow = prevFields.reduce( + (max, f) => Math.max(max, f.row_index ?? 0), + -1 + ); + const minMovedRow = movedFields.reduce( + (min, f) => Math.min(min, f.row_index ?? 0), + Infinity + ); + + if (movedFields.length > 0 && isFinite(minMovedRow)) { + const offset = maxPrevRow - minMovedRow + 1; + for (const f of movedFields) { + f.row_index = (f.row_index ?? 0) + offset; + } + } + + fs.splice(pbIdx, 1); + stripOrphanedLeadingPB(); + compact(); + if (activeSectionIndex.value >= sections.value.length) { + activeSectionIndex.value = sections.value.length - 1; + } + } + + function removeSectionWithFields(index: number) { + if (index <= 0) return; + const fs: FormField[] = formResource.value?.doc?.fields; + if (!fs) return; + + const sectionFields = sections.value[index]?.fields ?? []; + const fieldSet = new Set(sectionFields); + const pbIdx = findSectionPBIndex(index); + + const toRemove = new Set(); + if (pbIdx !== -1) toRemove.add(pbIdx); + for (let i = 0; i < fs.length; i++) { + if (fieldSet.has(fs[i])) toRemove.add(i); + } + + for (const i of [...toRemove].sort((a, b) => b - a)) { + fs.splice(i, 1); + } + + stripOrphanedLeadingPB(); + compact(); + if (activeSectionIndex.value >= sections.value.length) { + activeSectionIndex.value = sections.value.length - 1; + } + } + + function renameSection(index: number, newLabel: string) { + const fs: FormField[] = formResource.value?.doc?.fields; + if (!fs) return; + + if (index === 0 && fs[0]?.fieldtype !== Fieldtype.PAGE_BREAK) { + fs.unshift({ + idx: 0, + fieldtype: Fieldtype.PAGE_BREAK, + label: newLabel, + fieldname: scrubFieldname("section_1"), + row_index: 0, + column_index: 0, + cell_index: 0, + } as FormField); + return; + } + + const pbIdx = findSectionPBIndex(index); + if (pbIdx !== -1) { + fs[pbIdx].label = newLabel; + } + } + function selectField(field: FormField | null) { selectedField.value = field; } @@ -395,6 +589,10 @@ export const useEditForm = defineStore("editForm", () => { selectedField, isPublished, doctypeFields, + sections, + isMultiSection, + activeSectionFields, + activeSectionIndex, // Actions initialize, @@ -406,6 +604,10 @@ export const useEditForm = defineStore("editForm", () => { updateFormData, addField, addFieldFromDoctype, + addSection, + removeSectionKeepFields, + removeSectionWithFields, + renameSection, selectField, updateField, removeField, From 7c184284dfb1629051773d1001fbcdbfe6c3d2f7 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 8 Jun 2026 01:01:43 +0530 Subject: [PATCH 10/21] 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. --- .../components/submission/FormRenderer.vue | 41 +++++++- .../components/submission/StepIndicator.vue | 56 +++++++++++ frontend/src/composables/useFormSteps.ts | 99 +++++++++++++++++++ frontend/src/stores/submissionForm.ts | 7 +- 4 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/submission/StepIndicator.vue create mode 100644 frontend/src/composables/useFormSteps.ts diff --git a/frontend/src/components/submission/FormRenderer.vue b/frontend/src/components/submission/FormRenderer.vue index 99b7d63..c030ebf 100644 --- a/frontend/src/components/submission/FormRenderer.vue +++ b/frontend/src/components/submission/FormRenderer.vue @@ -2,9 +2,11 @@ import { ErrorMessage, LoadingIndicator, Button } from "frappe-ui"; import { useSubmissionForm } from "@/stores/submissionForm"; import FieldRenderer from "@/components/builder/FieldRenderer.vue"; +import StepIndicator from "@/components/submission/StepIndicator.vue"; import { computed } from "vue"; import { shouldFieldBeVisible, shouldFieldBeRequired } from "@/utils/conditionals"; import { useGroupedRows } from "@/composables/useGroupedRows"; +import { useFormSteps } from "@/composables/useFormSteps"; import type { FormField } from "@/types/formfield"; const submissionFormStore = useSubmissionForm(); @@ -20,7 +22,19 @@ const props = withDefaults( const allFields = computed(() => submissionFormStore.formResource.data?.fields || []); -const groupedRows = useGroupedRows(allFields); +const { + steps, + currentStepIndex, + currentStepFields, + isMultiStep, + isFirstStep, + isLastStep, + nextStep, + prevStep, + goToStep, +} = useFormSteps(allFields); + +const groupedRows = useGroupedRows(currentStepFields); function isFieldVisible(field: FormField) { return shouldFieldBeVisible(field, submissionFormStore.fields, allFields.value); @@ -34,6 +48,12 @@ function colKey(col: FormField[], cIdx: number) { return `c-${col[0]?.column_index ?? cIdx}`; } +function handleNextStep() { + submissionFormStore.validateValues(currentStepFields.value); + if (submissionFormStore.errors.length > 0) return; + nextStep(); +} + function handleSubmitForm() { submissionFormStore.submitForm(); } @@ -43,6 +63,12 @@ function handleSubmitForm() {
+ diff --git a/frontend/src/components/submission/FormHeader.vue b/frontend/src/components/submission/FormHeader.vue index dfb4c63..1c1eccf 100644 --- a/frontend/src/components/submission/FormHeader.vue +++ b/frontend/src/components/submission/FormHeader.vue @@ -1,19 +1,49 @@