diff --git a/forms_pro/forms_pro/doctype/form/test_form.py b/forms_pro/forms_pro/doctype/form/test_form.py index b54287e..b2403a9 100644 --- a/forms_pro/forms_pro/doctype/form/test_form.py +++ b/forms_pro/forms_pro/doctype/form/test_form.py @@ -4,6 +4,7 @@ import frappe from frappe.tests import IntegrationTestCase +from forms_pro.patches.v0_x.backfill_field_layout import execute as backfill_execute from forms_pro.utils.teams import get_user_teams # On IntegrationTestCase, the doctype test records and all @@ -202,3 +203,104 @@ def test_field_with_options(self): self.assertEqual(status_field.fieldtype, "Select") self.assertEqual(status_field.options, "Active\nInactive\nPending") self.assertEqual(status_field.default, "Active") + + def test_layout_fields_do_not_affect_doctype_sync(self): + """Changing row_index/column_index on FormField must not modify the linked DocType.""" + self.test_form.append( + "fields", + {"label": "Full Name", "fieldname": "full_name", "fieldtype": "Data"}, + ) + self.test_form.save() + + doctype_doc = frappe.get_doc("DocType", self.test_doctype_name) + fields_before = [(f.fieldname, f.fieldtype, f.label) for f in doctype_doc.fields] + + # Mutate only layout fields and save + self.test_form.fields[0].row_index = 5 + self.test_form.fields[0].column_index = 3 + self.test_form.save() + + doctype_doc = frappe.get_doc("DocType", self.test_doctype_name) + fields_after = [(f.fieldname, f.fieldtype, f.label) for f in doctype_doc.fields] + + self.assertEqual(fields_before, fields_after) + + +class TestBackfillFieldLayoutPatch(IntegrationTestCase): + """Backfill patch sets row_index = idx-1, column_index = 0 for all FormField rows.""" + + _test_doctype_name = "Backfill Patch Test DocType" + + def setUp(self): + from forms_pro.tests import FORMS_PRO_TEST_USER + from forms_pro.utils.teams import get_user_teams + + if not frappe.db.exists("DocType", self._test_doctype_name): + frappe.get_doc( + { + "doctype": "DocType", + "name": self._test_doctype_name, + "module": "Custom", + "custom": 1, + "fields": [{"fieldname": "title", "fieldtype": "Data", "label": "Title"}], + } + ).insert() + + self.test_team = get_user_teams(FORMS_PRO_TEST_USER)[0]["name"] + + def tearDown(self): + for name in frappe.get_all("Form", filters={"linked_doctype": self._test_doctype_name}, pluck="name"): + frappe.delete_doc("Form", name, force=True, ignore_permissions=True) + if frappe.db.exists("DocType", self._test_doctype_name): + frappe.delete_doc("DocType", self._test_doctype_name, force=True) + + def _make_form(self) -> str: + form = frappe.get_doc( + { + "doctype": "Form", + "title": "Patch Test Form", + "linked_doctype": self._test_doctype_name, + "linked_team_id": self.test_team, + "fields": [ + {"label": "Field A", "fieldname": "field_a", "fieldtype": "Data"}, + {"label": "Field B", "fieldname": "field_b", "fieldtype": "Data"}, + {"label": "Field C", "fieldname": "field_c", "fieldtype": "Data"}, + ], + } + ) + form.insert(ignore_permissions=True) + return form.name + + def test_patch_sets_row_index_from_idx(self): + form_name = self._make_form() + frappe.db.sql( + "UPDATE `tabForm Field` SET row_index = 99, column_index = 99 WHERE parent = %s", + form_name, + ) + + backfill_execute() + + fields = frappe.get_all( + "Form Field", + filters={"parent": form_name}, + fields=["fieldname", "idx", "row_index", "column_index"], + order_by="idx asc", + ) + for f in fields: + self.assertEqual(f.row_index, f.idx - 1, f"row_index wrong for {f.fieldname}") + self.assertEqual(f.column_index, 0, f"column_index wrong for {f.fieldname}") + + def test_patch_is_idempotent(self): + form_name = self._make_form() + backfill_execute() + backfill_execute() + + fields = frappe.get_all( + "Form Field", + filters={"parent": form_name}, + fields=["idx", "row_index", "column_index"], + order_by="idx asc", + ) + for f in fields: + self.assertEqual(f.row_index, f.idx - 1) + self.assertEqual(f.column_index, 0) 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 02a9a11..5c0f1fd 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.json +++ b/forms_pro/forms_pro/doctype/form_field/form_field.json @@ -6,6 +6,9 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "row_index", + "column_index", + "cell_index", "reqd", "hidden", "label", @@ -75,13 +78,34 @@ "fieldname": "hidden", "fieldtype": "Check", "label": "Hidden" + }, + { + "fieldname": "row_index", + "fieldtype": "Int", + "label": "Row Index", + "non_negative": 1, + "read_only": 1 + }, + { + "fieldname": "column_index", + "fieldtype": "Int", + "label": "Column Index", + "non_negative": 1, + "read_only": 1 + }, + { + "fieldname": "cell_index", + "fieldtype": "Int", + "label": "Cell Index", + "non_negative": 1, + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-04-25 20:32:10.994784", + "modified": "2026-05-05 00:02:48.020615", "modified_by": "Administrator", "module": "Forms Pro", "name": "Form Field", diff --git a/forms_pro/forms_pro/doctype/form_field/form_field.py b/forms_pro/forms_pro/doctype/form_field/form_field.py index 6788aa4..3701ff5 100644 --- a/forms_pro/forms_pro/doctype/form_field/form_field.py +++ b/forms_pro/forms_pro/doctype/form_field/form_field.py @@ -48,6 +48,8 @@ class FormField(Document): if TYPE_CHECKING: from frappe.types import DF + cell_index: DF.Int + column_index: DF.Int conditional_logic: DF.Code | None default: DF.SmallText | None description: DF.SmallText | None @@ -83,6 +85,7 @@ class FormField(Document): parentfield: DF.Data parenttype: DF.Data reqd: DF.Check + row_index: DF.Int # end: auto-generated types @property diff --git a/forms_pro/patches.txt b/forms_pro/patches.txt index e944b5f..1ca1321 100644 --- a/forms_pro/patches.txt +++ b/forms_pro/patches.txt @@ -4,4 +4,5 @@ forms_pro.patches.0_1_beta.create_user_forms_module_def [post_model_sync] -# Patches added in this section will be executed after doctypes are migrated \ No newline at end of file +# Patches added in this section will be executed after doctypes are migrated +forms_pro.patches.v0_x.backfill_field_layout \ No newline at end of file diff --git a/forms_pro/patches/v0_x/__init__.py b/forms_pro/patches/v0_x/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/forms_pro/patches/v0_x/backfill_field_layout.py b/forms_pro/patches/v0_x/backfill_field_layout.py new file mode 100644 index 0000000..79b98d8 --- /dev/null +++ b/forms_pro/patches/v0_x/backfill_field_layout.py @@ -0,0 +1,19 @@ +import frappe + + +def execute(): + """ + Backfill row_index / column_index on existing FormField records. + Each field becomes its own single-column row, preserving vertical order. + Uses direct SQL to avoid triggering Form.on_update / set_doctype_fields(). + Idempotent: WHERE clause skips already-correct rows. + """ + # row_index / column_index on `tabForm Field` are Int NOT NULL DEFAULT 0 + # (Frappe's schema sync). Legacy rows pre-dating these columns therefore + # land on 0 after migration, not NULL — so a plain `!=` predicate is + # enough. No IS NULL branch needed. + frappe.db.sql(""" + UPDATE `tabForm Field` + SET row_index = idx - 1, column_index = 0 + WHERE row_index != idx - 1 OR column_index != 0 + """) diff --git a/forms_pro/tests/test_form_field.py b/forms_pro/tests/test_form_field.py index 101db9e..5697a44 100644 --- a/forms_pro/tests/test_form_field.py +++ b/forms_pro/tests/test_form_field.py @@ -74,3 +74,34 @@ def test_form_to_frappe_fieldtype_has_all_heading_levels(self): with self.subTest(fieldtype=fieldtype): self.assertIn(fieldtype, FORM_TO_FRAPPE_FIELDTYPE) self.assertEqual(FORM_TO_FRAPPE_FIELDTYPE[fieldtype]["fieldtype"], "HTML") + + +class TestLayoutFieldsNotSyncedToDoctype(IntegrationTestCase): + """row_index, column_index, cell_index are layout-only and must never appear in to_frappe_field.""" + + def test_row_index_excluded_from_frappe_field(self): + field = _build_field("Data", label="Name", fieldname="name_field") + field.row_index = 3 + self.assertNotIn("row_index", field.to_frappe_field) + + def test_column_index_excluded_from_frappe_field(self): + field = _build_field("Data", label="Name", fieldname="name_field") + field.column_index = 2 + self.assertNotIn("column_index", field.to_frappe_field) + + def test_cell_index_excluded_from_frappe_field(self): + field = _build_field("Data", label="Name", fieldname="name_field") + field.cell_index = 4 + self.assertNotIn("cell_index", field.to_frappe_field) + + def test_layout_fields_excluded_for_all_fieldtypes(self): + for fieldtype in FORM_TO_FRAPPE_FIELDTYPE: + with self.subTest(fieldtype=fieldtype): + field = _build_field(fieldtype, label="Test", fieldname="test_f") + field.row_index = 1 + field.column_index = 1 + field.cell_index = 1 + result = field.to_frappe_field + self.assertNotIn("row_index", result) + self.assertNotIn("column_index", result) + self.assertNotIn("cell_index", result) diff --git a/frontend/e2e/helpers/form-builder.ts b/frontend/e2e/helpers/form-builder.ts index 7551581..2e8a3d8 100644 --- a/frontend/e2e/helpers/form-builder.ts +++ b/frontend/e2e/helpers/form-builder.ts @@ -1,8 +1,207 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; export class FormBuilderPage { constructor(private page: Page) {} + // ---- Layout introspection helpers ---- + + fieldCard(label: string): Locator { + return this.page.locator( + `[data-form-builder-component="field-card"][data-field-label="${label}"]` + ); + } + + // Wait for the builder to finish hydrating with the form's fields (the + // form fetch is async after navigation; helps avoid races when tests load + // pre-built layouts via the REST API). + async waitForFields(labels: string[]) { + for (const label of labels) { + await this.fieldCard(label).waitFor({ state: "visible", timeout: 15000 }); + } + } + + async rowCount(): Promise { + return this.page + .locator('[data-form-builder-component="form-row"]') + .count(); + } + + async columnCount(rowIdx: number): Promise { + return this.page + .locator( + `[data-form-builder-component="cell-column"][data-row-index="${rowIdx}"]` + ) + .count(); + } + + async cellCount(rowIdx: number, colIdx: number): Promise { + return this.page + .locator( + `[data-form-builder-component="field-card"][data-row-index="${rowIdx}"][data-col-index="${colIdx}"]` + ) + .count(); + } + + // ---- Drag mechanics ---- + // SortableJS + Playwright is fragile. Use a real mouse path with intermediate + // steps so SortableJS sees a continuous dragover sequence. + private async dragWithRealMouse( + sourceHandle: Locator, + targetCenter: { x: number; y: number }, + waypoints: { x: number; y: number }[] = [] + ) { + const handleBox = await sourceHandle.boundingBox(); + if (!handleBox) throw new Error("source handle not visible"); + + const start = { + x: handleBox.x + handleBox.width / 2, + y: handleBox.y + handleBox.height / 2, + }; + + await this.page.mouse.move(start.x, start.y); + await this.page.mouse.down(); + // Small initial nudge — SortableJS needs movement to begin drag + await this.page.mouse.move(start.x + 4, start.y + 4, { steps: 4 }); + + // Walk through caller-supplied waypoints (lets the path avoid crossing + // other sortable instances en route). Each leg uses many steps so + // SortableJS receives a continuous dragover/pointer-move sequence. + let prev = start; + for (const wp of waypoints) { + await this.page.mouse.move(wp.x, wp.y, { steps: 10 }); + prev = wp; + } + + // Approach the target with a midpoint then arrival + await this.page.mouse.move( + (prev.x + targetCenter.x) / 2, + (prev.y + targetCenter.y) / 2, + { steps: 10 } + ); + await this.page.mouse.move(targetCenter.x, targetCenter.y, { steps: 12 }); + + // Wiggle at target — forces a fresh dragover/pointer-move sequence on + // the final drop target so SortableJS commits the swap there, not + // somewhere we crossed earlier in the path. + await this.page.waitForTimeout(80); + await this.page.mouse.move(targetCenter.x + 2, targetCenter.y + 2, { + steps: 4, + }); + await this.page.mouse.move(targetCenter.x, targetCenter.y, { steps: 4 }); + await this.page.waitForTimeout(120); + await this.page.mouse.up(); + } + + private dragHandle(card: Locator): Locator { + return card.locator(".handle"); + } + + async dragFieldOntoCell( + sourceLabel: string, + targetLabel: string, + position: "above" | "below" + ) { + const source = this.fieldCard(sourceLabel); + const target = this.fieldCard(targetLabel); + + // Hover the target's group first so its handle/actions render (needed for + // the source handle to be visible — actions only show on hover/select). + await source.hover(); + const handle = this.dragHandle(source); + await handle.waitFor({ state: "visible" }); + + const sourceBox = await source.boundingBox(); + const box = await target.boundingBox(); + if (!sourceBox) throw new Error(`source card '${sourceLabel}' not visible`); + if (!box) throw new Error(`target card '${targetLabel}' not visible`); + + // Position cursor in upper / lower portion of target card + const yOffset = + position === "above" ? box.height * 0.25 : box.height * 0.75; + const targetCenter = { + x: box.x + box.width / 2, + y: box.y + yOffset, + }; + + // If the drag crosses rows (different y bands), route through a + // waypoint that lives in the row drop zone between rows. This avoids + // accidentally swapping into another sortable instance (e.g. the + // neighbouring column in the source row) while passing through. + const waypoints: { x: number; y: number }[] = []; + const sourceMidY = sourceBox.y + sourceBox.height / 2; + const verticalGapMidpoint = (sourceMidY + targetCenter.y) / 2; + const isCrossRow = Math.abs(targetCenter.y - sourceMidY) > sourceBox.height; + if (isCrossRow) { + // Drop straight down (or up) along the source's x first so we leave + // the source's row through the row drop zone, then slide horizontally + // toward the target before approaching. + waypoints.push({ + x: sourceBox.x + sourceBox.width / 2, + y: verticalGapMidpoint, + }); + waypoints.push({ x: targetCenter.x, y: verticalGapMidpoint }); + } + + await this.dragWithRealMouse(handle, targetCenter, waypoints); + } + + // Drag a field onto a ColumnDropZone (gap between/around columns within a row). + // Targets the zone with [data-at-row=atRow][data-at-col=atCol]. + async dragFieldToColumnZone( + sourceLabel: string, + atRow: number, + atCol: number + ) { + const source = this.fieldCard(sourceLabel); + await source.hover(); + const handle = this.dragHandle(source); + await handle.waitFor({ state: "visible" }); + + const zone = this.page.locator( + `[data-form-builder-component="column-drop-zone"][data-at-row="${atRow}"][data-at-col="${atCol}"]` + ); + const box = await zone.boundingBox(); + if (!box) { + throw new Error(`column drop zone (${atRow},${atCol}) not visible`); + } + + await this.dragWithRealMouse(handle, { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }); + } + + // Click the eject button on the field's card (action bar). + // Action bar only mounts visible on hover/select — hover first. + async ejectField(label: string) { + const card = this.fieldCard(label); + await card.hover(); + const ejectBtn = card.locator( + '[data-form-builder-component="eject-button"]' + ); + await ejectBtn.waitFor({ state: "visible" }); + await ejectBtn.click(); + } + + // Drag a field onto a RowDropZone above row atRow. + async dragFieldToRowZone(sourceLabel: string, atRow: number) { + const source = this.fieldCard(sourceLabel); + await source.hover(); + const handle = this.dragHandle(source); + await handle.waitFor({ state: "visible" }); + + const zone = this.page.locator( + `[data-form-builder-component="row-drop-zone"][data-at-row="${atRow}"]` + ); + const box = await zone.boundingBox(); + if (!box) throw new Error(`row drop zone at row ${atRow} not visible`); + + await this.dragWithRealMouse(handle, { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }); + } + async goto( formId: string, options?: { title?: string; skipTitleFill?: boolean } diff --git a/frontend/e2e/specs/form-layout.spec.ts b/frontend/e2e/specs/form-layout.spec.ts new file mode 100644 index 0000000..98e0337 --- /dev/null +++ b/frontend/e2e/specs/form-layout.spec.ts @@ -0,0 +1,325 @@ +import { test, expect } from "../fixtures/test-data.fixture"; +import { FormBuilderPage } from "../helpers/form-builder"; +import { SubmissionPage } from "../helpers/submission"; + +// Helper: set a form's field list via REST so the builder loads with a known +// row/column/cell layout. Mirrors the pattern used in heading-field.spec.ts. +async function setFormFields( + apiContext: import("@playwright/test").APIRequestContext, + formId: string, + fields: Array<{ + label: string; + fieldtype?: string; + row_index: number; + column_index?: number; + cell_index?: number; + }> +) { + 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.label.toLowerCase(), + reqd: 0, + row_index: f.row_index, + column_index: f.column_index ?? 0, + cell_index: f.cell_index ?? 0, + })), + }, + }); +} + +test.describe("Multi-column form layout", () => { + test("cell stacks into existing column on drag", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0 }, + { label: "B", row_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + // Sanity: starting layout is 2 rows, 1 column each, 1 cell each + await builder.waitForFields(["A", "B"]); + expect(await builder.rowCount()).toBe(2); + + await builder.dragFieldOntoCell("B", "A", "above"); + + // After drag: 1 row, 1 column, 2 cells stacked in (row 0, col 0) + await expect.poll(() => builder.rowCount()).toBe(1); + await expect.poll(() => builder.columnCount(0)).toBe(1); + await expect.poll(() => builder.cellCount(0, 0)).toBe(2); + }); + + test("column drop zone creates new column", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0 }, + { label: "B", row_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B"]); + + // Drop B into the column drop zone right of A (row 0, col 1) + await builder.dragFieldToColumnZone("B", 0, 1); + + await expect.poll(() => builder.rowCount()).toBe(1); + await expect.poll(() => builder.columnCount(0)).toBe(2); + await expect.poll(() => builder.cellCount(0, 0)).toBe(1); + await expect.poll(() => builder.cellCount(0, 1)).toBe(1); + }); + + test("row drop zone creates new row", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + // 2-column row of A, B in row 0 + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0, column_index: 0 }, + { label: "B", row_index: 0, column_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B"]); + expect(await builder.columnCount(0)).toBe(2); + + // Drop B above current row — B becomes row 0, A pushed to row 1 + await builder.dragFieldToRowZone("B", 0); + + await expect.poll(() => builder.rowCount()).toBe(2); + await expect.poll(() => builder.columnCount(0)).toBe(1); + await expect.poll(() => builder.columnCount(1)).toBe(1); + }); + + test("eject moves cell to new row", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + // 2-cell column: A on top (cell 0), B below (cell 1) — same row, same col + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0, column_index: 0, cell_index: 0 }, + { label: "B", row_index: 0, column_index: 0, cell_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B"]); + expect(await builder.cellCount(0, 0)).toBe(2); + + // Eject A — A moves to its own row, B stays put + await builder.ejectField("A"); + + await expect.poll(() => builder.rowCount()).toBe(2); + // No multi-cell column should remain + const rows = await builder.rowCount(); + for (let r = 0; r < rows; r++) { + const cols = await builder.columnCount(r); + for (let c = 0; c < cols; c++) { + expect(await builder.cellCount(r, c)).toBe(1); + } + } + }); + + test("cross-row drag collapses source column", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + // 2 rows x 2 cols: A, B / C, D + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0, column_index: 0 }, + { label: "B", row_index: 0, column_index: 1 }, + { label: "C", row_index: 1, column_index: 0 }, + { label: "D", row_index: 1, column_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B", "C", "D"]); + expect(await builder.columnCount(0)).toBe(2); + expect(await builder.columnCount(1)).toBe(2); + + // Stack B onto C — moves B into row 1, col 0 (cell 0 above C) + await builder.dragFieldOntoCell("B", "C", "above"); + + // Row 0 collapses to 1 column (only A); row 1 still has 2 columns + await expect.poll(() => builder.rowCount()).toBe(2); + await expect.poll(() => builder.columnCount(0)).toBe(1); + await expect.poll(() => builder.columnCount(1)).toBe(2); + await expect.poll(() => builder.cellCount(1, 0)).toBe(2); + }); + + test("within-column reorder renumbers cell_index", async ({ + page, + createForm, + apiContext, + }) => { + const formId = await createForm(); + // 2-cell column: A on top, B on bottom + await setFormFields(apiContext, formId, [ + { label: "A", row_index: 0, column_index: 0, cell_index: 0 }, + { label: "B", row_index: 0, column_index: 0, cell_index: 1 }, + ]); + + const builder = new FormBuilderPage(page); + await builder.goto(formId, { skipTitleFill: true }); + + await builder.waitForFields(["A", "B"]); + + // Drag B above A — DOM order should flip to B, A + await builder.dragFieldOntoCell("B", "A", "above"); + + const labelsInColumn = () => + page + .locator( + '[data-form-builder-component="cell-column"][data-row-index="0"][data-col-index="0"] [data-form-builder-component="field-card"]' + ) + .evaluateAll((els) => + els.map((e) => (e as HTMLElement).getAttribute("data-field-label")) + ); + + await expect.poll(labelsInColumn).toEqual(["B", "A"]); + }); + + test("mobile viewport stacks columns vertically", async ({ + browser, + createPublishedForm, + apiContext, + }) => { + const { formId, route } = await createPublishedForm(); + // 2-column row of A, B (use Data fieldtype so they render as inputs) + await apiContext.put(`/api/resource/Form/${formId}`, { + data: { + fields: [ + { + idx: 1, + fieldtype: "Data", + label: "A", + fieldname: "a", + row_index: 0, + column_index: 0, + cell_index: 0, + }, + { + idx: 2, + fieldtype: "Data", + label: "B", + fieldname: "b", + row_index: 0, + column_index: 1, + cell_index: 0, + }, + ], + }, + }); + + // Mobile viewport + const guestCtx = await browser.newContext({ + viewport: { width: 375, height: 800 }, + }); + const guestPage = await guestCtx.newPage(); + const submission = new SubmissionPage(guestPage); + await submission.goto(route); + + const row = guestPage.locator('[data-form-renderer-component="form-row"]'); + await expect(row).toBeVisible({ timeout: 10000 }); + + const flexDirection = await row.evaluate( + (el) => getComputedStyle(el as HTMLElement).flexDirection + ); + expect(flexDirection).toBe("column"); + + await guestCtx.close(); + }); + + test("hidden field unmounts, no empty column", async ({ + browser, + createPublishedForm, + apiContext, + }) => { + const { formId, route } = await createPublishedForm(); + + // 2-column row: A (col 0), B (col 1). A carries a conditional rule that + // hides B whenever A is empty (it always is on first render), exercising + // the "hide a column entirely when its only field is hidden" path. + const conditionalLogic = JSON.stringify({ + target_field: "b", + conditions: [{ fieldname: "a", operator: "Is Empty", value: "" }], + action: "Hide Field", + }); + + await apiContext.put(`/api/resource/Form/${formId}`, { + data: { + fields: [ + { + idx: 1, + fieldtype: "Data", + label: "A", + fieldname: "a", + row_index: 0, + column_index: 0, + cell_index: 0, + conditional_logic: conditionalLogic, + }, + { + idx: 2, + fieldtype: "Data", + label: "B", + fieldname: "b", + row_index: 0, + column_index: 1, + cell_index: 0, + }, + ], + }, + }); + + const guestCtx = await browser.newContext(); + const guestPage = await guestCtx.newPage(); + const submission = new SubmissionPage(guestPage); + await submission.goto(route); + + // Wait for the Submit button — confirms the renderer mounted past + // initial load (the form layout is reactive to the API fetch). + await expect(guestPage.getByRole("button", { name: "Submit" })).toBeVisible( + { timeout: 10000 } + ); + + // Row should render with exactly one column (the one holding A); the + // column wrapping B must be unmounted (v-if), not just emptied. + const row = guestPage.locator('[data-form-renderer-component="form-row"]'); + await expect(row).toHaveCount(1); + const columns = row.locator('[data-form-renderer-component="form-column"]'); + await expect(columns).toHaveCount(1); + + // Only A's input should render — B is unmounted by the conditional rule + // (frappe-ui's FormControl doesn't expose `name`, so count text inputs + // inside the form column instead). + await expect(columns.locator("input[type='text']")).toHaveCount(1); + + await guestCtx.close(); + }); +}); diff --git a/frontend/src/components/FormBuilderContent.vue b/frontend/src/components/FormBuilderContent.vue index e9abd92..d7b2e71 100644 --- a/frontend/src/components/FormBuilderContent.vue +++ b/frontend/src/components/FormBuilderContent.vue @@ -1,28 +1,87 @@ + diff --git a/frontend/src/components/builder/ColumnDropZone.vue b/frontend/src/components/builder/ColumnDropZone.vue new file mode 100644 index 0000000..d7dd39e --- /dev/null +++ b/frontend/src/components/builder/ColumnDropZone.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/components/builder/FieldActions.vue b/frontend/src/components/builder/FieldActions.vue index e5dbfc4..6befe0b 100644 --- a/frontend/src/components/builder/FieldActions.vue +++ b/frontend/src/components/builder/FieldActions.vue @@ -1,14 +1,16 @@