Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1ddf208
feat(FormField): add row_index and column_index for multi-column layout
harshtandiya May 4, 2026
0d07966
feat(store): add layout helpers and useGroupedRows composable
harshtandiya May 4, 2026
6196777
fix(store): guard addFieldFromDoctype, validate membership in move he…
harshtandiya May 4, 2026
fe603e5
feat(builder): row-based canvas render using groupedRows
harshtandiya May 4, 2026
dc26994
fix(builder): stable v-for keys and restore row vertical spacing
harshtandiya May 4, 2026
c86f145
feat(builder): restore drag-and-drop with nested vuedraggable
harshtandiya May 4, 2026
ed581cd
feat(FormBuilder): row drop zones, eject-to-row, and layout fixes
harshtandiya May 4, 2026
a70f607
feat(FormBuilder): hover highlight on drop zones, hide bottom zone wh…
harshtandiya May 4, 2026
8c549c4
feat(FormRenderer): row-based render with mobile stacking
harshtandiya May 4, 2026
e604184
feat(layout): column stacking via cell_index third axis
harshtandiya May 4, 2026
63f9774
fix(FormBuilder): preserve last-row drag, polish drop zones
harshtandiya May 9, 2026
0a08d09
fix(FormBuilder): sleek drop zones, no layout shift on drag
harshtandiya May 9, 2026
5ac526b
fix(FormBuilder): scope drop zone transitions, respect reduced-motion
harshtandiya May 9, 2026
5fa91cf
fix(FormBuilder): smooth drag reorder via SortableJS animation
harshtandiya May 9, 2026
daea882
feat(FormBuilder): add data-* test hooks and force pointer-event fall…
harshtandiya May 9, 2026
ac3969f
test(e2e): cover row/column/cell layout flows
harshtandiya May 9, 2026
8b769bd
docs(patch): note backfill predicate handles Int NOT NULL DEFAULT 0
harshtandiya May 10, 2026
5f179c4
fix(FormBuilder): address review feedback on layout edges
harshtandiya May 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions forms_pro/forms_pro/doctype/form/test_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
26 changes: 25 additions & 1 deletion forms_pro/forms_pro/doctype/form_field/form_field.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"row_index",
"column_index",
"cell_index",
"reqd",
"hidden",
"label",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions forms_pro/forms_pro/doctype/form_field/form_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion forms_pro/patches.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
# Patches added in this section will be executed after doctypes are migrated
forms_pro.patches.v0_x.backfill_field_layout
Empty file.
19 changes: 19 additions & 0 deletions forms_pro/patches/v0_x/backfill_field_layout.py
Original file line number Diff line number Diff line change
@@ -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
""")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
31 changes: 31 additions & 0 deletions forms_pro/tests/test_form_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading