Zum Inhalt

Code-Review — Block 7.2 (Templates-CRUD-UI)

Branch: platform/block-7-2-templates-crud gegen platform/v1.0.0. 3 Commits, 20 Dateien (+2515/-3), Frontend-only. Backend (Block 5 /api/v1/operator/templates*) unverändert; Pre-Flight (Path A) in PORTING-7.2.md dokumentiert.

Verifications: Vitest 74/74, Playwright 4/4, Backend 55/55, smoke-block7-1a 12/12, npm run build 130 KB raw / 56 KB gzip, npm run type-check exit 0.

Summary

ID Sev Datei:Zeile Titel Entscheidung
Keine Merge-Blocker
M1 60 ListInput.vue:80–98 A11y — kein Keyboard-Reorder, kein Live-Region Deferred
M2 55 useTemplates.ts:39–54 Clientseitige Suche skaliert nicht > ~200 Items Deferred
M3 55 TemplatesEditPage.vue:88–91, 128–134 listsEqual ignoriert Reorder Deferred
L1 45 TemplatesCreatePage.vue:78–87 [] immer im POST, nie weggelassen Deferred
L2 40 Templates-/Tenants-Pages EntityForm-Refactor — Trigger jetzt sinnvoll Deferred
L3 35 helpers.ts:65–70 Busy-Wait-Sleep im Token-Mint-Retry Deferred
L4 30 TemplatesEditPage.vue:98, 102 Trim-Diff bei description/shared_collection_name inkonsistent zur Tenants-Edit Deferred

Plan-Alignment sauber: Scope-Boundaries (kein Code-Editor, kein EntityForm-Refactor, kein RLS-Update, kein tenant-ui-Refactor) gehalten. Pre-Flight-Discoveries (global statt tenant-scoped, is_active=false statt deleted_at, Operator-Prefix statt Platform-Prefix, bare list[]-Antwort) sauber dokumentiert; alle Backend-Annahmen aus dem Prompt im Commit-1 korrigiert. ID-Regex (useTemplateIdValidator) spiegelt Backend exakt inkl. Underscore-Erlaubnis und 1-Char-Edge-Case.

Critical (≥80)

Keine. Die fünf Auftraggeber-Fokus-Punkte:

  1. <ListInput> A11y (1): Tab durch Items + Buttons funktioniert, Enter im Draft-Input addiert, Buttons haben aria-label="Eintrag N entfernen". Was fehlt: ein aria-live-Region für Add/Remove-Feedback und Keyboard-Reorder (Shift-Arrow). Templates haben < 10 Items, daher Reorder-UX kein Block. → M1.
  2. Clientseitige Suche (2): toLowerCase().includes() über die bereits geladene Liste; bei 40 Items unter 1 ms, bei 200 noch < 5 ms, ab ~500 spürbar. Kein akutes Risiko, weil das Backend kein ?search=-Param ableitet. → M2.
  3. PATCH-Diff Reorder (3): listsEqual([a,b], [b,a]) → true → PATCH-Payload enthält das Feld nicht → Reorder schlägt nicht durch. Snapshot-relevante Felder (suggested_suggestions) sind betroffen, aber Reorder-UI fehlt ohnehin (kein Drag-and-Drop in 7.2). → M3.
  4. Token-Cache (4): Module-State in einem Helper, der pro Playwright- Worker als eigener Node-Prozess läuft → kein Cross-Worker-Sharing, und genau das ist gewollt. Saubere 60-s-TTL, 3-fach-Retry mit linearem Backoff. Korrekt — kein Finding.
  5. Empty suggested_suggestions (5): TemplatesCreatePage.vue:83 schickt das Feld immer mit (form.value.suggested_suggestions, default []). Backend-Default ist [], also semantisch identisch zum Weglassen, aber nicht "omit". Verbose, nicht falsch. → L1.

Deferrable (<80)

M1 — <ListInput> ohne Live-Region und Keyboard-Reorder (Sev 60)

Datei: frontend/operator-ui/src/components/ListInput.vue:80–98 Problem: Add/Remove signalisiert keine Statusänderung an Screen-Reader; Tab landet nach Remove auf dem Draft-Input (Standard-Verhalten der Browser), das ist akzeptabel, aber kein expliziter Focus-Move. Kein Keyboard-Reorder (Shift-Arrow), Reorder per Drag-and-Drop bewusst ausgespart. Fix: <div role="status" aria-live="polite"> neben der Liste, nach add() "Eintrag hinzugefügt" und nach removeAt() "Eintrag entfernt" setzen. Reorder mit Shift-Arrow-Up/Down auf jedem list-item-input. Rationale: A11y-Audit ist Block 18-Scope (geplant nach Phase D); Templates-Listen haben < 10 Items, Reorder-Use-Case ist hypothetisch.

M2 — Clientseitige Suche skaliert nicht (Sev 55)

Datei: frontend/operator-ui/src/composables/useTemplates.ts:39–54 Problem: applySearch() filtert allItems mit String.includes(). Bei

~200 aktiven Templates wird das Tippen spürbar. Backend exposed kein ?search=, aber Operator könnte über Reactivate-Flow inaktive Templates sammeln, die mitgeladen werden, sobald active_only=false. Fix: Threshold bei allItems.value.length > 200 einen Toast oder Hinweis zeigen, alternativ Backend-Search als Block-7.4-Folge-Item einplanen. Im Backend ein ?search= mit ILIKE auf id und display_name mit LIMIT 100 ist trivial. Rationale: Templates-Bestand wächst eher in Jahren als in Wochen. Trigger: erstes Tenant-Onboarding mit > 50 Templates.

M3 — listsEqual ignoriert Reordering (Sev 55)

Datei: frontend/operator-ui/src/pages/TemplatesEditPage.vue:88–91, 128–134 Problem: listsEqual([a,b], [b,a]) → true. Wenn die UI später Reorder erlaubt (Block 7.4 oder Drag-and-Drop in <ListInput>), kommt die Reihenfolge im PATCH-Body nicht an, der version_counter bumpt nicht, Tenants sehen den Update-Hinweis nicht. Aktuell kein Schaden, weil <ListInput> Reorder nicht erlaubt — aber das Komponenten-Contract ist falsch. Fix: dirty-Vergleich per JSON.stringify(a) !== JSON.stringify(b) oder Index-vergleich aufgeben und das Feld immer ins PATCH-Payload aufnehmen, wenn der Nutzer in <ListInput> interagiert hat. Rationale: Reorder-UX ist nicht in 7.2-Scope. Trigger: Erste Reorder-Funktion (Block 7.4 oder Connector-Reordering).

L1 — Empty Arrays werden immer mitgeschickt (Sev 45)

Datei: frontend/operator-ui/src/pages/TemplatesCreatePage.vue:78–87 Problem: Auch wenn der Nutzer keine Suggestion und keinen Connector eintippt, schickt der POST suggested_suggestions: [] und recommended_connectors: []. Das Backend-TemplateCreate-Pydantic-Schema akzeptiert das (Default ist ohnehin []), aber semantisch wäre "Feld omitten" sauberer. Fix: Vor dem POST if (form.value.suggested_suggestions.length === 0) delete payload.suggested_suggestions; (analog für Connectors). Rationale: Verbose, nicht falsch. Keine Audit-Log-Differenz, keine Migration-Fallstricke.

L2 — <EntityForm>-Refactor — Trigger-Bewertung (Sev 40)

Datei: Tenants-Create+Edit, Templates-Create+Edit Problem: Form-Markup-Overlap: Tenants ~120 LOC, Templates ~200 LOC (8 vs. 4 Felder). <ListInput> ist bereits ein erster Refactor-Schritt (Liste-Edit war doppelt vorhanden). PATCH-Diff-Logik (isDirty, listsEqual, Field-Level-Diff) ist in beiden Edit-Pages dupliziert. Empfehlung: Deferred bis nach 7.3 Modules-CRUD. Begründung: Module-Forms haben eine andere Schema-Form (config-JSON-Editor, enabled-Toggle), das wären drei verschiedene Form-Shapes statt drei gleicher. Der EntityForm-Refactor wird wertvoll, sobald drei sehr ähnliche CRUDs existieren — Modules wird das wahrscheinlich nicht sein. Nach 7.3 entscheiden, ob Refactor sich lohnt oder ob <ListInput> als einziges geteiltes Primitive ausreicht. → TODO-B7-2-04. Rationale: Premature DRY ist teurer als zwei Edit-Pages. Trigger: Vier identische Form-Shapes (vermutlich erst Block 8 Connector-Configs).

L3 — Busy-Wait-Sleep im Token-Mint-Retry (Sev 35)

Datei: frontend/operator-ui/e2e/helpers.ts:65–70 Problem: while (Date.now() - start < 800 * attempt) { /* spin */ } blockiert den Event-Loop für 800–1600 ms. Im Worker-Prozess unkritisch, aber CPU-Verschwendung. Fix: Sync-Sleep durch Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 800) ersetzen oder den Helper auf async umstellen (alle Caller sind ohnehin in test.beforeEach). Rationale: E2E-Helper, kein Hot-Path. Trigger: Wenn der Helper aus dem Operator-UI noch in andere UIs portiert wird.

L4 — Trim-Diff inkonsistent (Sev 30)

Datei: frontend/operator-ui/src/pages/TemplatesEditPage.vue:98, 102 Problem: description.trim() !== i.description.trim() und shared_collection_name.trim() — Trim auf beiden Seiten des Vergleichs. In TenantsEditPage.vue:62 wird notes ohne Trim verglichen (siehe TODO-B7-1b-04 / L4 dort). Die Templates-Variante ist robuster, aber inkonsistent. Fix: TenantsEditPage an Templates-Pattern angleichen (Trim auf beiden Seiten + im Payload-Build). Rationale: Verbessertes Pattern hier, Tenants-Edit hatte bereits ein Defer-Item — gleiches Defer-Item, gleicher Trigger.

Looks good

  • Backend-Treue: template.ts spiegelt chatbot_template.py 1:1 (alle Felder, Optionalität, Defaults). Kein Drift, kein Schein-Type.
  • useTemplate.reactivate(): PATCH is_active=true ist die einzig korrekte Inversion zu DELETE (das Backend erwartet exakt das); die Detail-Page-Buttons toggeln idempotent.
  • 422-Field-Mapping: entry.loc[entry.loc.length - 1] extrahiert das letzte Pfadsegment — robust gegen Pydantic-V2-Loc-Strukturen wie ["body", "language"]. Identisches Pattern wie TenantsCreatePage.
  • 409-Inline: Statt Toast wird die Server-Error-Map gefüllt, der ID-Computed-Errror zeigt "ID bereits vergeben — andere wählen oder reaktivieren". Operator-Hint exakt richtig (Reactivate ist erlaubt, weil Slug-Reservierung nicht greift).
  • Deactivate-Reactivate-Cycle (E2E): Voller Zyklus durchgespielt, inkl. Banner "Inaktiv" + Reactivate-Button. Eine seltene Coverage- Tiefe für CRUD-E2E.
  • Sidebar-Aktivierung: BaseLayout.vue korrekt von Tooltip disabled auf <router-link to="/templates"> umgestellt; kein Layout-Sprung, kein neues Icon.
  • Bundle-Δ: +0.8 KB gzipped für vier neue lazy chunks ist im Rahmen.

Empfehlung: Merge-ready. Sieben Deferred-Items unter Score 80, alle mit konkretem Trigger; in offene-todos.md als TODO-B7-2-01 bis TODO-B7-2-07 aufnehmen. EntityForm-Refactor explizit auf Block 8 (oder später) verschieben — 7.3 Modules wird die Frage klären, dann Re-Evaluierung.