Skip to content

Commit 22e9127

Browse files
committed
Flatten block editing forms and respect field ordering from metadata
1 parent ef97952 commit 22e9127

15 files changed

Lines changed: 643 additions & 78 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Task Changelog: Improve Block Editing
2+
*Date: 2026-05-22 | Status: Approved*
3+
4+
## 📋 Alignment & Documentation (Robert)
5+
* **Documentation Reviewed:** `README.md`, `AGENTS.md`, `controlpanel.py`.
6+
* **Strategic Fit & Actions:** This task implements a Hybrid Metadata System to simplify block editing. Developers can now define default field visibility in code, while administrators retain override power in the Registry. This reduces the friction of adding new blocks.
7+
8+
## 👨‍💻 Reuse Analysis (Jekyll)
9+
* **Leveraged Base Code:**
10+
* Existing row views in `src/cs_dynamicpages/views/` were refactored to use a new base class.
11+
* `plone.dexterity.browser` forms were used as a basis for server-side filtering.
12+
* **Proposed Approach:**
13+
* Defined `IRowTypeMetadata` interface.
14+
* Implemented server-side field filtering, **flattening**, and **explicit ordering** in `RowEditForm` and `RowAddForm`.
15+
* Reordered fields based on the metadata configuration.
16+
* Merged Code Metadata with Registry Overrides in `utils.py`.
17+
* Optimized JS to use pre-loaded configuration from the DOM.
18+
19+
## 👹 Risk Audit (Hyde)
20+
* **Detected Flaws / Vulnerabilities:**
21+
* [Mutable Class Attributes]: Fixed `RUF012` by using tuples for `allowed_fields`.
22+
* [Widget Naming]: Handled dot/hyphen normalization for behavior fields.
23+
* [Group Support]: Added support for filtering widgets inside fieldsets/groups in `z3c.form` and eventually flattened them into a single fieldset.
24+
* [Hidden Template Crash]: Fixed `ComponentLookupError` when complex widgets (e.g. `OrderedSelectWidget`) were set to `HIDDEN_MODE` by removing them from `self.fields` in `updateFields`.
25+
* [Field Ordering]: The form now strictly obeys the order defined in the configuration.
26+
27+
28+
## 🏛️ PM Sign-off (Robert)
29+
* **Scope Verification:** The implementation matches the approved plan. All 209 tests pass, and `make check` is clean. The system is now more robust and developer-friendly.
30+
31+
## 🛠️ Net Repository State
32+
* **Modified Files:**
33+
* `src/cs_dynamicpages/interfaces.py`: Added `IRowTypeMetadata`.
34+
* `src/cs_dynamicpages/views/base.py`: New `RowViewBase` class.
35+
* `src/cs_dynamicpages/views/featured_view.py`: Specialized classes with metadata.
36+
* `src/cs_dynamicpages/views/query_columns_view.py`: Added metadata.
37+
* `src/cs_dynamicpages/views/slider_view.py`: Specialized classes with metadata.
38+
* `src/cs_dynamicpages/views/configure.zcml`: Updated view registrations.
39+
* `src/cs_dynamicpages/utils.py`: Refactored `get_available_views_for_row` and added `get_row_config`.
40+
* `src/cs_dynamicpages/browser/forms.py`: New custom Add/Edit forms.
41+
* `src/cs_dynamicpages/browser/row_form.pt`: New form template with JSON data.
42+
* `src/cs_dynamicpages/browser/configure.zcml`: Registered new forms.
43+
* `src/cs_dynamicpages/browser/static/edit_dynamicpagerow.js`: Simplified to use local config.
44+
* `src/cs_dynamicpages/tests/test_block_editing_forms.py`: New comprehensive tests.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Task Changelog: Project Analysis
2+
*Date: 2026-05-22 | Status: Approved*
3+
4+
## 📋 Alignment & Documentation (Robert)
5+
* **Documentation Reviewed:** `README.md`, `AGENTS.md`, `pyproject.toml`, `interfaces.py`.
6+
* **Strategic Fit & Actions:** The project is a Plone 6 add-on for dynamic pages. It aligns with Plone's best practices and provides a robust framework for block-based content management in Classic UI.
7+
8+
## 👨‍💻 Reuse Analysis (Jekyll)
9+
* **Leveraged Base Code:**
10+
* `src/cs_dynamicpages/content/dynamic_page_row.py`: Main rendering logic.
11+
* `src/cs_dynamicpages/behaviors/`: Extension points.
12+
* `src/cs_dynamicpages/controlpanels/`: Configuration management.
13+
* **Proposed Approach:** The current architecture is sound and should be followed for any new features (e.g., adding new row types or behaviors).
14+
15+
## 👹 Risk Audit (Hyde)
16+
* **Detected Flaws / Vulnerabilities:**
17+
* [Generic Error Handling]: `DynamicPageRow.render` swallows exceptions.
18+
* [Implicit Schema Dependencies]: Reliance on behavior-provided fields without explicit checks.
19+
* **Enforced Corrections:** None required at this stage as this was purely an analysis task. Recommendations for future work: improve logging in `render`.
20+
21+
## 🏛️ PM Sign-off (Robert)
22+
* **Scope Verification:** Initial analysis complete. Codebase is healthy (passing tests and linting). Documentation is up to date.
23+
24+
## 🛠️ Net Repository State
25+
* **Modified Files:**
26+
* `.tasks/CHANGELOG_project-analysis.md`: Task documentation.
69.7 KB
Binary file not shown.

src/cs_dynamicpages/browser/configure.zcml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@
1414
type="plone"
1515
/>
1616

17+
<browser:page
18+
name="edit"
19+
for="cs_dynamicpages.content.dynamic_page_row.IDynamicPageRow"
20+
class=".forms.RowEditForm"
21+
permission="cmf.ModifyPortalContent"
22+
layer="cs_dynamicpages.interfaces.IBrowserLayer"
23+
/>
24+
25+
<adapter
26+
factory=".forms.RowAddView"
27+
provides="zope.publisher.interfaces.browser.IBrowserPage"
28+
for="Products.CMFCore.interfaces.IFolderish
29+
cs_dynamicpages.interfaces.IBrowserLayer
30+
plone.dexterity.interfaces.IDexterityFTI"
31+
name="DynamicPageRow"
32+
/>
33+
1734
<adapter
1835
factory=".body_class.DynamicViewFolderClasses"
1936
name="cs_dynamicpages_body_class"
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from cs_dynamicpages.utils import get_available_views_for_row
2+
from cs_dynamicpages.utils import get_row_config
3+
from plone.dexterity.browser.add import DefaultAddForm
4+
from plone.dexterity.browser.add import DefaultAddView
5+
from plone.dexterity.browser.edit import DefaultEditForm
6+
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
7+
from z3c.form.field import Fields
8+
9+
import json
10+
11+
12+
class RowFormMixin:
13+
def _get_current_row_type(self):
14+
if hasattr(self.context, "row_type"):
15+
return self.context.row_type
16+
return self.request.get("row_type") or self.request.form.get(
17+
"form.widgets.IDynamicPageRow.row_type"
18+
)
19+
20+
def _collect_all_fields(self):
21+
all_fields_dict = {}
22+
for name, field in self.fields.items():
23+
all_fields_dict[name] = field
24+
for group in self.groups:
25+
if hasattr(group, "fields"):
26+
for name, field in group.fields.items():
27+
all_fields_dict[name] = field
28+
return all_fields_dict
29+
30+
def _get_ordered_fields(self, all_fields_dict, allowed_fields):
31+
new_fields_list = []
32+
33+
# Always keep row_type
34+
for n in ["IDynamicPageRow.row_type", "row_type"]:
35+
if n in all_fields_dict:
36+
new_fields_list.append(all_fields_dict[n])
37+
del all_fields_dict[n]
38+
break
39+
40+
# Add allowed fields in order
41+
for field_name in allowed_fields:
42+
matched_name = self._find_matched_field(all_fields_dict, field_name)
43+
if matched_name:
44+
new_fields_list.append(all_fields_dict[matched_name])
45+
del all_fields_dict[matched_name]
46+
47+
# Always include Title and ID
48+
for n in ["IBasic.title", "title", "IShortName.id", "id"]:
49+
if n in all_fields_dict:
50+
new_fields_list.append(all_fields_dict[n])
51+
del all_fields_dict[n]
52+
53+
return new_fields_list
54+
55+
def _find_matched_field(self, all_fields_dict, field_name):
56+
if field_name in all_fields_dict:
57+
return field_name
58+
for sep_from, sep_to in [(".", "-"), ("-", ".")]:
59+
norm_name = field_name.replace(sep_from, sep_to)
60+
if norm_name in all_fields_dict:
61+
return norm_name
62+
return None
63+
64+
def _flatten_and_order_fields(self):
65+
row_type = self._get_current_row_type()
66+
if not row_type:
67+
return
68+
69+
config = get_row_config(row_type)
70+
if not config:
71+
return
72+
73+
allowed_fields = config.get("each_row_type_fields", [])
74+
all_fields_dict = self._collect_all_fields()
75+
new_fields_list = self._get_ordered_fields(all_fields_dict, allowed_fields)
76+
77+
self.fields = Fields(*new_fields_list)
78+
self.groups = []
79+
80+
81+
class RowEditForm(RowFormMixin, DefaultEditForm):
82+
template = ViewPageTemplateFile("row_form.pt")
83+
84+
def updateFields(self):
85+
super().updateFields()
86+
self._flatten_and_order_fields()
87+
88+
def row_type_configs(self):
89+
return json.dumps(get_available_views_for_row())
90+
91+
92+
class RowAddForm(RowFormMixin, DefaultAddForm):
93+
template = ViewPageTemplateFile("row_form.pt")
94+
95+
def updateFields(self):
96+
super().updateFields()
97+
self._flatten_and_order_fields()
98+
99+
def row_type_configs(self):
100+
return json.dumps(get_available_views_for_row())
101+
102+
103+
class RowAddView(DefaultAddView):
104+
form = RowAddForm
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<html xmlns="http://www.w3.org/1999/xhtml"
2+
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
3+
xmlns:metal="http://xml.zope.org/namespaces/metal"
4+
xmlns:tal="http://xml.zope.org/namespaces/tal"
5+
metal:use-macro="context/main_template/macros/master"
6+
i18n:domain="plone"
7+
>
8+
<body>
9+
<metal:main fill-slot="main">
10+
<div id="row-config-data"
11+
style="display: none;"
12+
tal:attributes="
13+
data-config view/row_type_configs;
14+
"
15+
></div>
16+
<metal:use-macro use-macro="context/@@ploneform-macros/titlelessform" />
17+
</metal:main>
18+
</body>
19+
</html>

src/cs_dynamicpages/browser/static/edit_dynamicpagerow.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@
1111
let rowTypeSelect = null;
1212

1313
function initialize(context = document) {
14+
// Check for pre-loaded configuration in the DOM
15+
const configEl = document.getElementById("row-config-data");
16+
if (configEl) {
17+
try {
18+
const data = JSON.parse(configEl.dataset.config);
19+
if (data && data.length > 0) {
20+
processRowTypeFields(data);
21+
return;
22+
}
23+
} catch (e) {
24+
console.error("Error parsing pre-loaded row config:", e);
25+
}
26+
}
27+
1428
// Check if we're in an edit form or an add form
1529
const isEditForm =
1630
context === document &&
@@ -26,7 +40,7 @@
2640
return;
2741
}
2842

29-
// Get configuration from control panel
43+
// Get configuration from control panel (Fallback)
3044
const baseUrl = document.body.dataset.portalUrl || "";
3145
fetch(
3246
`${baseUrl}/@registry/cs_dynamicpages.dynamic_pages_control_panel.row_type_fields`,

src/cs_dynamicpages/interfaces.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,32 @@
11
"""Module where all interfaces, events and exceptions live."""
22

3+
from zope import schema
4+
from zope.interface import Interface
35
from zope.publisher.interfaces.browser import IDefaultBrowserLayer
46

57

68
class IBrowserLayer(IDefaultBrowserLayer):
79
"""Marker interface that defines a browser layer."""
10+
11+
12+
class IRowTypeMetadata(Interface):
13+
"""Metadata for a row type view."""
14+
15+
allowed_fields = schema.List(
16+
title="Allowed fields",
17+
value_type=schema.TextLine(),
18+
required=False,
19+
default=[],
20+
)
21+
22+
has_featured = schema.Bool(
23+
title="Has featured",
24+
required=False,
25+
default=False,
26+
)
27+
28+
icon = schema.TextLine(
29+
title="Icon",
30+
required=False,
31+
default="bricks",
32+
)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from cs_dynamicpages.browser.forms import RowEditForm
2+
from cs_dynamicpages.testing import CS_DYNAMICPAGES_INTEGRATION_TESTING
3+
from plone.app.testing import setRoles
4+
from plone.app.testing import TEST_USER_ID
5+
6+
import unittest
7+
8+
9+
class TestBlockEditingForms(unittest.TestCase):
10+
layer = CS_DYNAMICPAGES_INTEGRATION_TESTING
11+
12+
def setUp(self):
13+
self.portal = self.layer["portal"]
14+
setRoles(self.portal, TEST_USER_ID, ["Manager"])
15+
self.container = self.portal[
16+
self.portal.invokeFactory("DynamicPageFolder", "f1")
17+
]
18+
self.row = self.portal.f1[
19+
self.portal.f1.invokeFactory(
20+
"DynamicPageRow", "r1", row_type="cs_dynamicpages-text-view"
21+
)
22+
]
23+
24+
def test_metadata_extraction(self):
25+
from cs_dynamicpages.utils import get_row_config
26+
27+
config = get_row_config("cs_dynamicpages-text-view")
28+
self.assertIsNotNone(config)
29+
self.assertIn("IRichTextBehavior-text", config["each_row_type_fields"])
30+
self.assertEqual(config["row_type_icon"], "body-text")
31+
32+
def test_edit_form_filters_widgets(self):
33+
from plone.app.z3cform.interfaces import IPloneFormLayer
34+
from zope.interface import alsoProvides
35+
36+
request = self.portal.REQUEST.clone()
37+
alsoProvides(request, IPloneFormLayer)
38+
39+
form = RowEditForm(self.row, request)
40+
form.update()
41+
42+
def get_widget(form, name):
43+
widget = form.widgets.get(name)
44+
if widget:
45+
return widget
46+
for group in form.groups:
47+
widget = group.widgets.get(name)
48+
if widget:
49+
return widget
50+
return None
51+
52+
# In text-view, IRichTextBehavior.text should be visible
53+
text_widget = get_widget(form, "IRichTextBehavior.text")
54+
if not text_widget:
55+
text_widget = get_widget(form, "IRichTextBehavior-text")
56+
57+
self.assertIsNotNone(text_widget)
58+
59+
# IRelatedImage.related_image should be GONE in text-view
60+
image_widget = get_widget(form, "IRelatedImage.related_image")
61+
if not image_widget:
62+
image_widget = get_widget(form, "IRelatedImage-related_image")
63+
self.assertIsNone(image_widget)
64+
65+
def test_registry_override(self):
66+
from plone import api
67+
68+
record_name = "cs_dynamicpages.dynamic_pages_control_panel.row_type_fields"
69+
registry_values = api.portal.get_registry_record(record_name)
70+
71+
# Find text-view and change its icon in registry
72+
for val in registry_values:
73+
if val["row_type"] == "cs_dynamicpages-text-view":
74+
val["row_type_icon"] = "override-icon"
75+
break
76+
77+
api.portal.set_registry_record(record_name, registry_values)
78+
79+
from cs_dynamicpages.utils import get_row_config
80+
81+
config = get_row_config("cs_dynamicpages-text-view")
82+
self.assertEqual(config["row_type_icon"], "override-icon")
83+
84+
def test_complex_widget_hidden_crash(self):
85+
# Test that widgets like OrderedSelectWidget don't crash the form
86+
# when they are supposed to be hidden.
87+
from plone.app.z3cform.interfaces import IPloneFormLayer
88+
from zope.interface import alsoProvides
89+
90+
self.row.row_type = "cs_dynamicpages-text-view" # Hide ICollection fields
91+
92+
request = self.portal.REQUEST.clone()
93+
alsoProvides(request, IPloneFormLayer)
94+
95+
form = RowEditForm(self.row, request)
96+
form.update()
97+
# This should trigger the rendering which might crash
98+
try:
99+
rendered = form()
100+
self.assertIn("IRichTextBehavior-text", rendered)
101+
except Exception as e:
102+
self.fail(f"Form rendering failed with {type(e).__name__}: {e}")

0 commit comments

Comments
 (0)