From 2a2d40bec9e522d519157f677a4abaaec912995b Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 26 Apr 2026 14:49:23 +0200 Subject: [PATCH] fix(admin): two more apostrophe-in-translation regressions + UX rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . +# +# 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(?:x-[a-zA-Z][a-zA-Z0-9:-]*|@[a-zA-Z][a-zA-Z0-9.:-]*|:[a-zA-Z][a-zA-Z0-9.:-]*))="(?P[^"]*)\"""", + 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