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
+4 -1
View File
@@ -26,7 +26,10 @@ API_SERVER_URL=http://localhost:8000
STATION_CODE=ST-DEFAULT
# --- File Storage ---
UPLOAD_DIR=server/uploads
# Resolved against the project root in src/backend/config.py.
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
# volume in production.
UPLOAD_DIR=uploads
MAX_UPLOAD_SIZE_MB=50
# --- Setup Page ---
+1
View File
@@ -38,6 +38,7 @@ uploads/images/*
uploads/pdfs/*
uploads/logos/*
uploads/reports/*
uploads/general/
!uploads/images/.gitkeep
!uploads/pdfs/.gitkeep
!uploads/logos/.gitkeep
@@ -221,7 +221,7 @@
<div class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div>
<div x-show="showAssignments"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<div>
@@ -236,57 +236,88 @@
</div>
<div class="px-6 py-4 space-y-4">
<!-- Add recipe -->
<div class="flex items-end gap-2">
<div class="flex-1">
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Aggiungi ricetta') }}</label>
<select x-model="recipeToAdd"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
<!-- Search filter -->
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" x-model="recipeSearch"
placeholder="{{ _('Filtra per codice o nome ricetta...') }}"
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
<option value="">{{ _('-- Seleziona ricetta --') }}</option>
<template x-for="r in unassignedRecipes" :key="r.id">
<option :value="r.id" x-text="r.code + ' — ' + r.name"></option>
</template>
</select>
</div>
<button @click="assignRecipe()"
:disabled="!recipeToAdd || saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Available recipes (left column) -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Ricette disponibili') }} (<span x-text="filteredUnassignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in filteredUnassignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div class="min-w-0 flex-1">
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
</div>
<button @click="assignRecipe(r.id)"
:disabled="saving"
class="ml-2 inline-flex items-center gap-1 px-2.5 py-1 bg-primary text-white text-xs font-medium rounded-md
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50"
:title="'{{ _('Assegna a questa stazione') }}'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Assegna') }}
</button>
</div>
</template>
<template x-if="filteredUnassignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]"
x-text="unassignedEmptyMessage"></div>
</template>
</div>
</div>
<!-- Assigned list -->
<!-- Assigned recipes (right column) -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2">
{{ _('Ricette correntemente assegnate') }} (<span x-text="assignedRecipes.length"></span>)
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{{ _('Assegnate alla stazione') }} (<span x-text="filteredAssignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in assignedRecipes" :key="r.id">
<template x-for="r in filteredAssignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div>
<span class="font-mono text-xs font-semibold text-[var(--text-primary)]" x-text="r.code"></span>
<span class="ml-2 text-sm text-[var(--text-secondary)]" x-text="r.name"></span>
<div class="min-w-0 flex-1">
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
</div>
<button @click="unassignRecipe(r.id)"
:disabled="saving"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
:title="'{{ _('Rimuovi') }}'">
class="ml-2 p-1.5 rounded-md text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
:title="'{{ _('Rimuovi assegnazione') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<template x-if="assignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]">
{{ _('Nessuna ricetta assegnata') }}
</div>
<template x-if="filteredAssignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]"
x-text="assignedEmptyMessage"></div>
</template>
</div>
</div>
</div>
<template x-if="errorMsg">
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
@@ -351,7 +382,7 @@ function stationManagement(initialStations, initialRecipes) {
deleteTarget: null,
assignmentStation: null,
assignedRecipes: [],
recipeToAdd: '',
recipeSearch: '',
form: {
code: '',
name: '',
@@ -380,6 +411,32 @@ function stationManagement(initialStations, initialRecipes) {
return this.allRecipes.filter(r => !assignedIds.has(r.id) && r.active !== false);
},
_matchesRecipeSearch(r) {
if (!this.recipeSearch.trim()) return true;
const q = this.recipeSearch.toLowerCase();
return (r.code && r.code.toLowerCase().includes(q)) ||
(r.name && r.name.toLowerCase().includes(q));
},
get filteredUnassignedRecipes() {
return this.unassignedRecipes.filter(r => this._matchesRecipeSearch(r));
},
get filteredAssignedRecipes() {
return this.assignedRecipes.filter(r => this._matchesRecipeSearch(r));
},
get unassignedEmptyMessage() {
if (this.recipeSearch) return '{{ _("Nessun risultato per il filtro") }}';
if (this.allRecipes.length === 0) return '{{ _("Nessuna ricetta nel sistema") }}';
return '{{ _("Tutte le ricette sono già assegnate") }}';
},
get assignedEmptyMessage() {
if (this.recipeSearch) return '{{ _("Nessun risultato per il filtro") }}';
return '{{ _("Nessuna ricetta assegnata") }}';
},
openCreateModal() {
this.isEditing = false;
this.editingId = null;
@@ -493,7 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
async openAssignmentsModal(station) {
this.assignmentStation = station;
this.assignedRecipes = [];
this.recipeToAdd = '';
this.recipeSearch = '';
this.errorMsg = '';
this.showAssignments = true;
try {
@@ -513,27 +570,28 @@ function stationManagement(initialStations, initialRecipes) {
this.showAssignments = false;
this.assignmentStation = null;
this.assignedRecipes = [];
this.recipeSearch = '';
this.errorMsg = '';
},
async assignRecipe() {
if (!this.recipeToAdd || !this.assignmentStation) return;
async assignRecipe(recipeId) {
if (!recipeId || !this.assignmentStation) return;
const id = parseInt(recipeId, 10);
this.saving = true;
this.errorMsg = '';
try {
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ recipe_id: parseInt(this.recipeToAdd, 10) }),
body: JSON.stringify({ recipe_id: id }),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || "{{ _('Errore nella assegnazione') }}";
return;
}
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
const recipe = this.allRecipes.find(r => r.id === id);
if (recipe) this.assignedRecipes.push({ id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active });
this.recipeToAdd = '';
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
@@ -307,8 +307,8 @@
x-text="toggleUser?.active ? '{{ _('Conferma Disattivazione') }}' : '{{ _('Conferma Riattivazione') }}'"></h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
<span x-text="toggleUser?.active
? '{{ _('Sei sicuro di voler disattivare l\'utente') }}'
: '{{ _('Sei sicuro di voler riattivare l\'utente') }}'"></span>
? &quot;{{ _('Sei sicuro di voler disattivare l\'utente') }}&quot;
: &quot;{{ _('Sei sicuro di voler riattivare l\'utente') }}&quot;"></span>
<strong x-text="toggleUser?.username"></strong>?
</p>
<div class="flex justify-end gap-3">
@@ -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")