fix(admin): two more apostrophe-in-translation regressions + UX rework
UX rework — recipe assignment modal on /admin/stations
- Replace the single "select recipe + Assegna" dropdown with a two-
column layout: Ricette disponibili (left) and Assegnate alla
stazione (right), each row with an inline action button. Top search
filter narrows both columns at once. Empty states explain *why* the
list is empty (no recipes in system, all already assigned, no match
for the filter).
- Rationale: the old dropdown silently hid every option once a recipe
was assigned, leaving the user unable to tell whether the system
was broken or simply out of unassigned recipes.
Apostrophe regressions
- /admin/stations alert/errorMsg literals reworded with double-quoted
outer JS strings ("Errore nella eliminazione" / "...assegnazione").
- /admin/users toggle confirm modal: x-text expression contained
'{{ _('… l\'utente') }}'. Inside a Jinja-rendered HTML attribute,
the apostrophe in "l'utente" closed the JS literal early, killing
the binding. Fixed by using " as the JS string delimiter so
the inner apostrophe is harmless.
Alpine x-if templates can't host nested templates
- Replaced two nested-template empty-state blocks with x-text bound
to computed getters (unassignedEmptyMessage,
assignedEmptyMessage). Alpine errored with
"Cannot set properties of null (setting '_x_dataStack')" when the
outer template's child wasn't a single root element.
Test guard widened
- src/frontend/flask_app/tests/test_template_js_syntax.py now also
parses every Alpine attribute (x-*, @*, :*) on the rendered HTML
and runs `node --check` on each expression wrapped in `void (…)`.
Previously it only inspected inline <script> bodies, which is why
the x-text bug on /admin/users slipped through. Verified the
extended test catches the original l'utente regression by reverting
+ running + restoring.
Layout regression — UPLOAD_DIR defaulted to server/uploads
- The previous .env.example shipped UPLOAD_DIR=server/uploads, which
matched the V1.x layout but pointed outside the new project tree.
Updated to UPLOAD_DIR=uploads so files land in the project-root
uploads/ volume that src/backend/config.py.upload_path resolves.
- Added uploads/general/ to .gitignore (per-user uploads, not source).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,10 +32,56 @@ _INLINE_SCRIPT_RX = re.compile(
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Match every Alpine.js / event-binding attribute on a tag — the bug class we
|
||||
# care about (apostrophe in IT translation closing a JS literal) hides equally
|
||||
# well inside x-text="…" as inside <script>…</script>.
|
||||
#
|
||||
# Covers: x-data, x-init, x-text, x-html, x-show, x-if, x-for, x-bind:foo,
|
||||
# x-on:foo, x-model, x-effect, x-transition:*, plus the @evt and :attr
|
||||
# shortcuts.
|
||||
_ALPINE_ATTR_RX = re.compile(
|
||||
r"""(?P<name>(?:x-[a-zA-Z][a-zA-Z0-9:-]*|@[a-zA-Z][a-zA-Z0-9.:-]*|:[a-zA-Z][a-zA-Z0-9.:-]*))="(?P<value>[^"]*)\"""",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def _node_check(script: str, label: str) -> None:
|
||||
"""Fail the test if `node --check` rejects `script`."""
|
||||
if not script.strip():
|
||||
# Skip these — they're plain identifiers / object shapes, not JS expressions
|
||||
# that node --check should reject for things like a stray apostrophe.
|
||||
_ALPINE_NON_EXPR_NAMES = {
|
||||
# transition modifiers like x-transition:enter-start get bare class names
|
||||
"x-transition:enter",
|
||||
"x-transition:enter-start",
|
||||
"x-transition:enter-end",
|
||||
"x-transition:leave",
|
||||
"x-transition:leave-start",
|
||||
"x-transition:leave-end",
|
||||
# x-cloak has no value, x-data is parsed below as an expression but Alpine
|
||||
# also accepts an object literal.
|
||||
}
|
||||
|
||||
|
||||
def _alpine_attribute_expressions(html: str):
|
||||
"""Yield (attr_name, value) tuples for every Alpine expression attribute."""
|
||||
for m in _ALPINE_ATTR_RX.finditer(html):
|
||||
name = m.group("name")
|
||||
if name in _ALPINE_NON_EXPR_NAMES:
|
||||
continue
|
||||
value = m.group("value")
|
||||
if not value.strip():
|
||||
continue
|
||||
# HTML entities used to embed quotes inside the attribute value
|
||||
# (most common: " for ").
|
||||
value = (
|
||||
value.replace(""", '"')
|
||||
.replace(""", '"')
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
)
|
||||
yield name, value
|
||||
|
||||
|
||||
def _node_check(source: str, label: str) -> None:
|
||||
"""Fail the test if `node --check` rejects `source`."""
|
||||
if not source.strip():
|
||||
return
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
@@ -44,7 +90,7 @@ def _node_check(script: str, label: str) -> None:
|
||||
fd, path = tempfile.mkstemp(suffix=".js")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
f.write(source)
|
||||
result = subprocess.run(
|
||||
[node, "--check", path],
|
||||
capture_output=True,
|
||||
@@ -55,13 +101,30 @@ def _node_check(script: str, label: str) -> None:
|
||||
os.unlink(path)
|
||||
|
||||
if result.returncode != 0:
|
||||
snippet = script.strip()
|
||||
snippet = source.strip()
|
||||
if len(snippet) > 600:
|
||||
snippet = snippet[:600] + "\n…(truncated)…"
|
||||
pytest.fail(
|
||||
f"{label}: node rejected the inline <script> as invalid JS.\n"
|
||||
f"{label}: node rejected this code as invalid JS.\n"
|
||||
f"--- node stderr ---\n{result.stderr.strip()}\n"
|
||||
f"--- script (first 600 chars) ---\n{snippet}"
|
||||
f"--- source (first 600 chars) ---\n{snippet}"
|
||||
)
|
||||
|
||||
|
||||
def _check_alpine_attributes(html: str, page_label: str) -> None:
|
||||
"""Validate every Alpine expression attribute on the page.
|
||||
|
||||
Wraps each value in `void (…)` so node parses it as an expression rather
|
||||
than a statement. Function-body forms like `async () => { … }` parse fine
|
||||
inside that wrapper too.
|
||||
"""
|
||||
for name, value in _alpine_attribute_expressions(html):
|
||||
# Some Alpine attrs accept a function call shorthand (e.g.
|
||||
# x-data="myComponent(window.__x)"); those parse fine as expressions.
|
||||
wrapper = f"void ({value});\n"
|
||||
_node_check(
|
||||
wrapper,
|
||||
f"{page_label} attribute {name}=\"…\" did not parse as JS",
|
||||
)
|
||||
|
||||
|
||||
@@ -106,6 +169,8 @@ def test_admin_stations_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
for i, body in enumerate(scripts):
|
||||
_node_check(body, f"/admin/stations script[{i}]")
|
||||
|
||||
_check_alpine_attributes(html, "/admin/stations")
|
||||
|
||||
|
||||
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
|
||||
@@ -125,3 +190,5 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
|
||||
for i, body in enumerate(scripts):
|
||||
_node_check(body, f"/admin/users script[{i}]")
|
||||
|
||||
_check_alpine_attributes(html, "/admin/users")
|
||||
|
||||
Reference in New Issue
Block a user