From 1ddf208766894a09c8541abb2d15a866e0213596 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Tue, 5 May 2026 00:11:31 +0530 Subject: [PATCH 01/18] feat(FormField): add row_index and column_index for multi-column layout Add row_index and column_index Int fields to FormField schema to support side-by-side field placement. Both fields are layout-only and excluded from DocType sync via to_frappe_field. Includes backfill patch (direct SQL, idempotent) and tests covering sync regression and patch correctness. --- forms_pro/forms_pro/doctype/form/test_form.py | 102 ++++++++++++++++++ .../doctype/form_field/form_field.json | 18 +++- .../doctype/form_field/form_field.py | 2 + forms_pro/patches.txt | 3 +- forms_pro/patches/v0_x/__init__.py | 0 .../patches/v0_x/backfill_field_layout.py | 15 +++ forms_pro/tests/test_form_field.py | 24 +++++ .../src/types/FormsPro/form_field.types.ts | 4 + 8 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 forms_pro/patches/v0_x/__init__.py create mode 100644 forms_pro/patches/v0_x/backfill_field_layout.py 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..04a7ffc 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,8 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "row_index", + "column_index", "reqd", "hidden", "label", @@ -75,13 +77,27 @@ "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 } ], "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..262d9b8 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,7 @@ class FormField(Document): if TYPE_CHECKING: from frappe.types import DF + column_index: DF.Int conditional_logic: DF.Code | None default: DF.SmallText | None description: DF.SmallText | None @@ -83,6 +84,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..1b943a2 --- /dev/null +++ b/forms_pro/patches/v0_x/backfill_field_layout.py @@ -0,0 +1,15 @@ +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. + """ + 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..23dafb3 100644 --- a/forms_pro/tests/test_form_field.py +++ b/forms_pro/tests/test_form_field.py @@ -74,3 +74,27 @@ 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 and column_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_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 + result = field.to_frappe_field + self.assertNotIn("row_index", result) + self.assertNotIn("column_index", result) diff --git a/frontend/src/types/FormsPro/form_field.types.ts b/frontend/src/types/FormsPro/form_field.types.ts index 288e265..6b4c335 100644 --- a/frontend/src/types/FormsPro/form_field.types.ts +++ b/frontend/src/types/FormsPro/form_field.types.ts @@ -34,6 +34,10 @@ export interface FormField { parentfield?: string; parenttype?: string; idx?: number; + /** Row Index : Int */ + row_index?: number; + /** Column Index : Int */ + column_index?: number; /** Mandatory : Check */ reqd?: 0 | 1; /** Hidden : Check */ From 0d079665a518f987e775b14bb862e021d26b1f6f Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Tue, 5 May 2026 00:13:02 +0530 Subject: [PATCH 02/18] feat(store): add layout helpers and useGroupedRows composable Add row_index/column_index to FormField type. Add moveField, insertNewRow, and compact helpers to editForm store; update addField, addFieldFromDoctype, and removeField to maintain layout invariants. Extract useGroupedRows as a shared composable for use in both the builder and submission renderer. --- frontend/src/composables/useGroupedRows.ts | 21 ++++++ frontend/src/stores/editForm.ts | 88 +++++++++++++++++++++- frontend/src/types/formfield.ts | 2 + 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 frontend/src/composables/useGroupedRows.ts diff --git a/frontend/src/composables/useGroupedRows.ts b/frontend/src/composables/useGroupedRows.ts new file mode 100644 index 0000000..902b8f4 --- /dev/null +++ b/frontend/src/composables/useGroupedRows.ts @@ -0,0 +1,21 @@ +import { computed, type Ref } from "vue"; +import type { FormField } from "@/types/formfield"; + +export function useGroupedRows(fields: Ref) { + return computed(() => { + const rows = new Map(); + for (const f of fields.value) { + const r = f.row_index ?? 0; + if (!rows.has(r)) rows.set(r, []); + rows.get(r)!.push(f); + } + return [...rows.keys()] + .sort((a, b) => a - b) + .map((r) => + rows + .get(r)! + .slice() + .sort((a, b) => (a.column_index ?? 0) - (b.column_index ?? 0)) + ); + }); +} diff --git a/frontend/src/stores/editForm.ts b/frontend/src/stores/editForm.ts index 6c4cea9..8a04baa 100644 --- a/frontend/src/stores/editForm.ts +++ b/frontend/src/stores/editForm.ts @@ -194,34 +194,111 @@ export const useEditForm = defineStore("editForm", () => { } } + function compact() { + const fs: FormField[] = formResource.value?.doc?.fields ?? []; + if (!fs.length) return; + + // Remap row_index values to 0..N-1 (closes gaps left by deletions/moves) + const distinctRows = [...new Set(fs.map((f) => f.row_index ?? 0))].sort( + (a, b) => a - b + ); + const rowRemap = new Map(distinctRows.map((r, i) => [r, i])); + for (const f of fs) { + f.row_index = rowRemap.get(f.row_index ?? 0) ?? 0; + } + + // Renumber column_index within each row to 0..M-1 + const rowMap = new Map(); + for (const f of fs) { + const r = f.row_index!; + if (!rowMap.has(r)) rowMap.set(r, []); + rowMap.get(r)!.push(f); + } + for (const row of rowMap.values()) { + row + .sort((a, b) => (a.column_index ?? 0) - (b.column_index ?? 0)) + .forEach((f, i) => { + f.column_index = i; + }); + } + } + function addField(fieldtype: Fieldtype) { if (formResource.value?.doc) { + const fs: FormField[] = formResource.value.doc.fields; + const lastRow = + fs.length > 0 ? Math.max(...fs.map((f) => f.row_index ?? 0)) : -1; + const newField: FormField = { - idx: formResource.value.doc.fields.length + 1, + idx: fs.length + 1, fieldtype, label: "", fieldname: "", options: "", default: "", description: "", + row_index: lastRow + 1, + column_index: 0, }; - formResource.value.doc.fields.push(newField); + fs.push(newField); } } function addFieldFromDoctype(field: any) { + const fs: FormField[] = formResource.value.doc.fields; + const lastRow = + fs.length > 0 ? Math.max(...fs.map((f) => f.row_index ?? 0)) : -1; + const _newField: FormField = { - idx: formResource.value.doc.fields.length + 1, + idx: fs.length + 1, fieldtype: field.fieldtype, label: field.label, fieldname: field.fieldname, options: field.options, default: field.default, description: field.description, + row_index: lastRow + 1, + column_index: 0, }; - formResource.value.doc.fields.push(_newField); + fs.push(_newField); + } + + function moveField(field: FormField, targetRow: number, targetCol: number) { + const fs: FormField[] = formResource.value?.doc?.fields ?? []; + + // Shift existing columns in target row to open a slot + for (const f of fs) { + if ( + f !== field && + (f.row_index ?? 0) === targetRow && + (f.column_index ?? 0) >= targetCol + ) { + f.column_index = (f.column_index ?? 0) + 1; + } + } + + field.row_index = targetRow; + field.column_index = targetCol; + + compact(); + } + + function insertNewRow(field: FormField, atRow: number) { + const fs: FormField[] = formResource.value?.doc?.fields ?? []; + + // Push all rows at or below atRow down by 1 + for (const f of fs) { + if (f !== field && (f.row_index ?? 0) >= atRow) { + f.row_index = (f.row_index ?? 0) + 1; + } + } + + field.row_index = atRow; + field.column_index = 0; + + compact(); } function removeField(field: FormField) { @@ -229,6 +306,7 @@ export const useEditForm = defineStore("editForm", () => { formResource.value.doc.fields = formResource.value.doc.fields.filter( (f: FormField) => f !== field ); + compact(); } } @@ -277,5 +355,7 @@ export const useEditForm = defineStore("editForm", () => { selectField, updateField, removeField, + moveField, + insertNewRow, }; }); diff --git a/frontend/src/types/formfield.ts b/frontend/src/types/formfield.ts index edfa075..3fa86f6 100644 --- a/frontend/src/types/formfield.ts +++ b/frontend/src/types/formfield.ts @@ -12,4 +12,6 @@ export type FormField = { default?: string; idx?: number; conditional_logic?: string; + row_index?: number; + column_index?: number; }; From 6196777ac3d7c3890c9b3dd90f4238660bca831a Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Tue, 5 May 2026 00:28:31 +0530 Subject: [PATCH 03/18] fix(store): guard addFieldFromDoctype, validate membership in move helpers Add null guard to addFieldFromDoctype matching addField's pattern. Add fs.includes() guard to moveField and insertNewRow to prevent corrupting layout when a stale field ref is passed. Extract lastRowIndex helper using reduce to avoid spread-on-large-array. --- frontend/src/stores/editForm.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/src/stores/editForm.ts b/frontend/src/stores/editForm.ts index 8a04baa..ab05bbd 100644 --- a/frontend/src/stores/editForm.ts +++ b/frontend/src/stores/editForm.ts @@ -223,11 +223,13 @@ export const useEditForm = defineStore("editForm", () => { } } + function lastRowIndex(fs: FormField[]): number { + return fs.reduce((m, f) => Math.max(m, f.row_index ?? 0), -1); + } + function addField(fieldtype: Fieldtype) { if (formResource.value?.doc) { const fs: FormField[] = formResource.value.doc.fields; - const lastRow = - fs.length > 0 ? Math.max(...fs.map((f) => f.row_index ?? 0)) : -1; const newField: FormField = { idx: fs.length + 1, @@ -237,7 +239,7 @@ export const useEditForm = defineStore("editForm", () => { options: "", default: "", description: "", - row_index: lastRow + 1, + row_index: lastRowIndex(fs) + 1, column_index: 0, }; @@ -246,9 +248,8 @@ export const useEditForm = defineStore("editForm", () => { } function addFieldFromDoctype(field: any) { + if (!formResource.value?.doc) return; const fs: FormField[] = formResource.value.doc.fields; - const lastRow = - fs.length > 0 ? Math.max(...fs.map((f) => f.row_index ?? 0)) : -1; const _newField: FormField = { idx: fs.length + 1, @@ -258,7 +259,7 @@ export const useEditForm = defineStore("editForm", () => { options: field.options, default: field.default, description: field.description, - row_index: lastRow + 1, + row_index: lastRowIndex(fs) + 1, column_index: 0, }; @@ -267,6 +268,7 @@ export const useEditForm = defineStore("editForm", () => { function moveField(field: FormField, targetRow: number, targetCol: number) { const fs: FormField[] = formResource.value?.doc?.fields ?? []; + if (!fs.includes(field)) return; // Shift existing columns in target row to open a slot for (const f of fs) { @@ -287,6 +289,7 @@ export const useEditForm = defineStore("editForm", () => { function insertNewRow(field: FormField, atRow: number) { const fs: FormField[] = formResource.value?.doc?.fields ?? []; + if (!fs.includes(field)) return; // Push all rows at or below atRow down by 1 for (const f of fs) { From fe603e5d25dbff760596fe3e5a99e54694eda4c9 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Tue, 5 May 2026 00:30:17 +0530 Subject: [PATCH 04/18] feat(builder): row-based canvas render using groupedRows Replace flat vuedraggable list with row/column grid derived from useGroupedRows. Fields with the same row_index render side-by-side as flex items. Extract FieldCard.vue from FormBuilderContent. Drag-and-drop temporarily removed; restored in next chunk. --- .../src/components/FormBuilderContent.vue | 71 +++++-------------- frontend/src/components/builder/FieldCard.vue | 33 +++++++++ 2 files changed, 52 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/builder/FieldCard.vue diff --git a/frontend/src/components/FormBuilderContent.vue b/frontend/src/components/FormBuilderContent.vue index e9abd92..3184d09 100644 --- a/frontend/src/components/FormBuilderContent.vue +++ b/frontend/src/components/FormBuilderContent.vue @@ -1,28 +1,25 @@ + diff --git a/frontend/src/components/builder/FieldCard.vue b/frontend/src/components/builder/FieldCard.vue new file mode 100644 index 0000000..a01b617 --- /dev/null +++ b/frontend/src/components/builder/FieldCard.vue @@ -0,0 +1,33 @@ + + + From dc26994250b64249739d6e9a54a645d60667b867 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Tue, 5 May 2026 00:37:15 +0530 Subject: [PATCH 05/18] fix(builder): stable v-for keys and restore row vertical spacing Key rows by row_index value instead of array index to prevent wrong-row DOM patching on delete. Key FieldCard by row_index+column_index instead of idx which is undefined on new fields before save. Restore gap-3 between rows to match previous my-3 spacing. --- frontend/src/components/FormBuilderContent.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/FormBuilderContent.vue b/frontend/src/components/FormBuilderContent.vue index 3184d09..7868f02 100644 --- a/frontend/src/components/FormBuilderContent.vue +++ b/frontend/src/components/FormBuilderContent.vue @@ -137,15 +137,15 @@ onClickOutside(fieldContentRef, (event) => {

Click on fields to add them to the form.

-
+
From c86f145855f507e7ba8a58313e486246b133a16d Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Tue, 5 May 2026 00:40:16 +0530 Subject: [PATCH 06/18] feat(builder): restore drag-and-drop with nested vuedraggable Replace flat draggable with per-row inner draggables using group="fields" for cross-row drag. onFieldChange handles evt.moved (within-row reorder via direct column_index renumber) and evt.added (cross-row via moveField). Source row evt.removed is a no-op; target evt.added owns the move. --- .../src/components/FormBuilderContent.vue | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/FormBuilderContent.vue b/frontend/src/components/FormBuilderContent.vue index 7868f02..7253f1f 100644 --- a/frontend/src/components/FormBuilderContent.vue +++ b/frontend/src/components/FormBuilderContent.vue @@ -2,9 +2,11 @@ import { computed, ref } from "vue"; import { LoadingIndicator, TextEditor } from "frappe-ui"; import { onClickOutside, useEventListener } from "@vueuse/core"; +import draggableComponent from "vuedraggable"; import { useEditForm } from "@/stores/editForm"; import { useGroupedRows } from "@/composables/useGroupedRows"; +import type { FormField } from "@/types/formfield"; import FieldCard from "@/components/builder/FieldCard.vue"; const editFormStore = useEditForm(); @@ -14,6 +16,30 @@ const isDraggingField = ref(false); const groupedRows = useGroupedRows(computed(() => editFormStore.fields)); +function fieldKey(field: FormField): string { + return `${field.row_index ?? 0}-${field.column_index ?? 0}`; +} + +function onFieldChange(evt: any, rowIndex: number) { + if (evt.moved) { + // Reorder within the same row: renumber column_index based on new position + const { element, newIndex } = evt.moved; + const rowFields: FormField[] = editFormStore.fields + .filter((f: FormField) => (f.row_index ?? 0) === rowIndex) + .sort((a: FormField, b: FormField) => (a.column_index ?? 0) - (b.column_index ?? 0)); + const oldIdx = rowFields.indexOf(element); + if (oldIdx !== -1) rowFields.splice(oldIdx, 1); + rowFields.splice(newIndex, 0, element); + rowFields.forEach((f: FormField, i: number) => { + f.column_index = i; + }); + } else if (evt.added) { + // Field dropped from another row — move it here at the given column + editFormStore.moveField(evt.added.element, rowIndex, evt.added.newIndex); + } + // evt.removed: no-op — the target row's evt.added owns the move +} + // Function to check if an element is a dropdown/popover (including portals) const isDropdownOrPopover = (element: Element | null): boolean => { if (!element) return false; @@ -143,12 +169,22 @@ onClickOutside(fieldContentRef, (event) => { :key="row[0]?.row_index ?? rIdx" class="flex flex-row gap-2 items-stretch" > - + + +
From ed581cdde8795b5e50af33dacf63e557afadb328 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Tue, 5 May 2026 01:04:58 +0530 Subject: [PATCH 07/18] feat(FormBuilder): row drop zones, eject-to-row, and layout fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RowDropZone: thin drop target between rows; expands on drag-start so fields can be inserted between existing rows instead of merging as a new column. Uses an empty vuedraggable list with put=true/pull=false and clears its buffer on nextTick after emitting. - Eject to own row: SquareSplitVertical button appears on FieldActions when a field shares its row with at least one other field. Clicking calls insertNewRow(field, row_index + 1), pushing the field to a new row immediately below. - Code-review fixes: extract rowIndexOf() helper (eliminates repeated row[0]?.row_index ?? rIdx), remove outer wrapper div (key + classes moved to draggableComponent via diff --git a/frontend/src/components/builder/FieldActions.vue b/frontend/src/components/builder/FieldActions.vue index e5dbfc4..1c46937 100644 --- a/frontend/src/components/builder/FieldActions.vue +++ b/frontend/src/components/builder/FieldActions.vue @@ -1,14 +1,16 @@