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:
2026-04-26 14:49:23 +02:00
parent 6e284b0c0c
commit 2a2d40bec9
5 changed files with 192 additions and 63 deletions
@@ -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: &quot; for ").
value = (
value.replace("&quot;", '"')
.replace("&#34;", '"')
.replace("&#39;", "'")
.replace("&amp;", "&")
)
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")