(core) Automatically remove leaves from layout specs

Summary:
When fields or sections were being removed from Grist documents, any
layout specs that referred to them weren't being updated to no longer
do so. This mismatch was causing various buggy scenarios to manifest
in cases where the stale ids were being reused. We now automatically
update any affected layout specs whenever fields or sections are
removed.

Test Plan: Python tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4302
This commit is contained in:
George Gevoian 2024-07-23 11:13:06 -04:00
parent 4740f1f933
commit f2141851be
2 changed files with 110 additions and 6 deletions

View File

@ -536,6 +536,12 @@ class TestUserActions(test_engine.EngineTestCase):
def test_section_removes(self):
# Add a couple of tables and views, to trigger creation of some related items.
self.init_views_sample()
self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2], {
'layoutSpec': [
"{\"children\":[{\"children\":[{\"leaf\":10},{\"leaf\":4}]}],"
+ "\"collapsed\":[{\"leaf\":6}]}",
]
}])
self.assertViews([
View(1, sections=[
@ -596,6 +602,56 @@ class TestUserActions(test_engine.EngineTestCase):
])
])
# Check that the view layout spec also got updated.
self.assertTableData('_grist_Views', rows='subset', cols='subset', data=[
['id', 'layoutSpec'],
[2, "{\"children\": [{\"children\": [{\"leaf\": 4}]}], \"collapsed\": []}"],
])
#----------------------------------------------------------------------
def test_field_removes(self):
self.init_views_sample()
self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section', [1, 4, 6, 7], {
'layoutSpec': [
"",
"{\"children\":[{\"children\":[{\"leaf\":10},{\"leaf\":11}]},{\"leaf\":12}]}",
"{\"id\":\"d748210f-91e1-4209-8f56-c27f4f62efde\",\"type\":\"Layout\",\"children\":[{\"id"
+ "\":\"a939f6b0-c728-4b36-bdde-6bb727ce4027\",\"type\":\"Paragraph\",\"text\":\"## **F"
+ "orm Title**\",\"alignment\":\"center\"},{\"id\":\"428c5f4f-b1f8-4bb6-89e5-5cdab4fca5"
+ "d0\",\"type\":\"Paragraph\",\"text\":\"Your form description goes here.\",\"alignmen"
+ "t\":\"center\"},{\"id\":\"9bfaa427-7776-411e-9724-e351fb4d5bdf\",\"type\":\"Section"
+ "\",\"children\":[{\"id\":\"8192f1c8-918f-4b4f-b51c-c775588a6087\",\"type\":\"Paragra"
+ "ph\",\"text\":\"### **Header**\"},{\"id\":\"6005123f-069b-4b31-85d1-d8c677c53f7e\","
+ "\"type\":\"Paragraph\",\"text\":\"Description\"},{\"id\":\"444ffba4-5a99-4a3f-abb9-9"
+ "09deea5c059\",\"type\":\"Field\",\"leaf\":16},{\"id\":\"c7deaa62-3c06-45a8-aa79-122d"
+ "01c77832\",\"type\":\"Field\",\"leaf\":17},{\"id\":\"b7326be0-a73e-4cc1-8f8b-6c5033d"
+ "227e4\",\"type\":\"Field\",\"leaf\":18}]},{\"id\":\"3a70d09f-2b08-4875-a813-3432d2d0"
+ "ae3d\",\"type\":\"Submit\"}]}",
"invalid",
]
}])
# Remove a couple of fields. Ensure the layout specs of their sections get updated.
self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section_field', [1, 10, 12, 16, 19]])
self.assertTableData('_grist_Views_section', rows='subset', cols='subset', data=[
['id', 'layoutSpec'],
[1, ""],
[4, "{\"children\": [{\"children\": [{\"leaf\": 11}]}]}"],
[6, "{\"children\": [{\"alignment\": \"center\", \"id\": \"a939f6b0-c728-4b36-bdde-6bb727ce4"
+ "027\", \"text\": \"## **Form Title**\", \"type\": \"Paragraph\"}, {\"alignment\": \"cen"
+ "ter\", \"id\": \"428c5f4f-b1f8-4bb6-89e5-5cdab4fca5d0\", \"text\": \"Your form descript"
+ "ion goes here.\", \"type\": \"Paragraph\"}, {\"children\": [{\"id\": \"8192f1c8-918f-4b"
+ "4f-b51c-c775588a6087\", \"text\": \"### **Header**\", \"type\": \"Paragraph\"}, {\"id\""
+ ": \"6005123f-069b-4b31-85d1-d8c677c53f7e\", \"text\": \"Description\", \"type\": \"Para"
+ "graph\"}, {\"id\": \"c7deaa62-3c06-45a8-aa79-122d01c77832\", \"leaf\": 17, \"type\": \""
+ "Field\"}, {\"id\": \"b7326be0-a73e-4cc1-8f8b-6c5033d227e4\", \"leaf\": 18, \"type\": \""
+ "Field\"}], \"id\": \"9bfaa427-7776-411e-9724-e351fb4d5bdf\", \"type\": \"Section\"}, {"
+ "\"id\": \"3a70d09f-2b08-4875-a813-3432d2d0ae3d\", \"type\": \"Submit\"}], \"id\": \"d74"
+ "8210f-91e1-4209-8f56-c27f4f62efde\", \"type\": \"Layout\"}"],
[7, "invalid"],
])
#----------------------------------------------------------------------
def test_schema_consistency_check(self):

View File

@ -1267,9 +1267,8 @@ class UserActions(object):
# Remove all view fields for all removed columns.
# Bypass the check for raw data view sections.
field_ids = [f.id for c in col_recs for f in c.viewFields]
self.doBulkRemoveRecord("_grist_Views_section_field", field_ids)
fields = [f for c in col_recs for f in c.viewFields]
self._doRemoveViewSectionFieldRecords(fields)
# If there is a displayCol, it may get auto-removed, but may first produce calc actions
# triggered by the removal of this column. To avoid those, remove displayCols immediately.
@ -1353,12 +1352,20 @@ class UserActions(object):
Remove view sections, including their fields, without checking for raw view sections.
"""
self.doBulkRemoveRecord('_grist_Views_section_field', [f.id for vs in recs for f in vs.fields])
self.doBulkRemoveRecord('_grist_Views_section', [r.id for r in recs])
views = {r.parentId.id: r.parentId for r in recs}.values()
rec_ids = [r.id for r in recs]
updates = _get_layout_spec_updates(views, set(rec_ids))
if updates:
self._do_doc_action(actions.BulkUpdateRecord('_grist_Views',
[row_id for (row_id, _) in updates],
{'layoutSpec': [value for (_, value) in updates]}
))
self.doBulkRemoveRecord('_grist_Views_section', rec_ids)
@override_action('BulkRemoveRecord', '_grist_Views_section_field')
def _removeViewSectionFieldRecords(self, table_id, row_ids):
"""
Remove view sections, including their fields.
Remove view section fields.
Raises an error if trying to remove a field of a table's rawViewSectionRef,
i.e. hiding a column in a raw data widget.
"""
@ -1366,7 +1373,21 @@ class UserActions(object):
for rec in recs:
if rec.parentId.isRaw:
raise ValueError("Cannot remove raw view section field")
self.doBulkRemoveRecord(table_id, row_ids)
self._doRemoveViewSectionFieldRecords(recs)
def _doRemoveViewSectionFieldRecords(self, recs):
"""
Remove view section fields, without checking for raw view section fields.
"""
view_sections = {r.parentId.id: r.parentId for r in recs}.values()
rec_ids = [r.id for r in recs]
updates = _get_layout_spec_updates(view_sections, rec_ids)
if updates:
self._do_doc_action(actions.BulkUpdateRecord('_grist_Views_section',
[row_id for (row_id, _) in updates],
{'layoutSpec': [value for (_, value) in updates]}
))
self.doBulkRemoveRecord('_grist_Views_section_field', rec_ids)
#----------------------------------------
# User actions on columns.
@ -2361,3 +2382,30 @@ def _is_transform_col(col_id):
'gristHelper_Transform',
'gristHelper_Converted',
))
def _get_layout_spec_updates(resources, removed_ids):
def get_patched_layout_spec(layout_spec):
if not isinstance(layout_spec, dict):
return layout_spec
if 'leaf' in layout_spec and layout_spec['leaf'] in removed_ids_set:
return None
patched_layout_spec = layout_spec.copy()
for key in ('children', 'collapsed'):
if key not in layout_spec or not isinstance(layout_spec[key], list):
continue
patched_values = [get_patched_layout_spec(v) for v in layout_spec[key]]
patched_layout_spec[key] = [v for v in patched_values if v is not None]
return patched_layout_spec
updates = []
removed_ids_set = set(removed_ids)
for (row_id, layout_spec) in [(r.id, r.layoutSpec) for r in resources if r.layoutSpec != ""]:
try:
layout_spec_parsed = json.loads(layout_spec)
except ValueError:
continue
new_layout_spec = json.dumps(get_patched_layout_spec(layout_spec_parsed), sort_keys=True)
updates.append((row_id, new_layout_spec))
return updates