d1b1047368
Catalog Database-View UX & Legacy Consolidation (OFFER-07/08/09/10/13) - 11-01: tags table + polymorphic junction + additive legacy migration - 11-02: getAllServices tag join + inline-edit/tag/quick-add server actions - 11-03: EditableCell + TagMultiSelect components - 11-04: database-view ServiceTable + client-side search Verified by gsd-plan-checker (VERIFICATION PASSED). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
534 lines
25 KiB
Markdown
534 lines
25 KiB
Markdown
---
|
|
phase: 11-catalog-database-view-ux-legacy-consolidation
|
|
plan: 03
|
|
type: execute
|
|
wave: 3
|
|
depends_on: ["11-02"]
|
|
files_modified:
|
|
- src/components/ui/editable-cell.tsx
|
|
- src/components/ui/tag-multi-select.tsx
|
|
autonomous: true
|
|
requirements: [OFFER-07, OFFER-08]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Clicking a cell in display mode transitions it to an editable input/textarea/checkbox with a visible focus ring"
|
|
- "Pressing Enter (non-textarea) or blurring the field saves the value via the onSave callback"
|
|
- "Pressing Escape reverts to the previous value without calling onSave"
|
|
- "Tag badges display with deterministic colors derived from tag name (same name = same color, no stored color column)"
|
|
- "Typing a new tag name and pressing Enter in the TagMultiSelect dropdown calls addTagToService and the badge appears without a full page reload (via revalidation)"
|
|
artifacts:
|
|
- path: "src/components/ui/editable-cell.tsx"
|
|
provides: "EditableCell component — click-to-edit text/number/textarea/toggle cell"
|
|
contains: "export function EditableCell"
|
|
- path: "src/components/ui/tag-multi-select.tsx"
|
|
provides: "TagMultiSelect component — tag badges + add/remove dropdown with deterministic color hashing"
|
|
contains: "export function TagMultiSelect"
|
|
key_links:
|
|
- from: "src/components/ui/tag-multi-select.tsx"
|
|
to: "src/app/admin/catalog/actions.ts"
|
|
via: "addTagToService / removeTagFromService server action calls"
|
|
pattern: "addTagToService|removeTagFromService"
|
|
- from: "src/components/ui/editable-cell.tsx"
|
|
to: "src/components/ui/input.tsx, src/components/ui/textarea.tsx"
|
|
via: "renders Input/Textarea in edit mode with ring-1 ring-primary"
|
|
pattern: "ring-1 ring-primary"
|
|
---
|
|
|
|
<objective>
|
|
Build the two new shared UI primitives the database-view table (Plan 04) depends on: `EditableCell` (generic click-to-edit cell for text/number/textarea/toggle, per D-11/D-14 and DESIGN-SYSTEM.md inline-edit pattern) and `TagMultiSelect` (tag badges with deterministic color hashing per D-07, inline "+" dropdown to add/remove tags per D-06/D-08).
|
|
|
|
Purpose: Interface-first — these are pure, reusable components with no ServiceTable-specific logic. Plan 04 imports and wires them into `ServiceRow`/`ServiceTable` without needing to touch their internals.
|
|
|
|
Output:
|
|
- `src/components/ui/editable-cell.tsx` — `EditableCell` component + `EditableCellProps` type
|
|
- `src/components/ui/tag-multi-select.tsx` — `TagMultiSelect` component + color-hash utility
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md
|
|
@.planning/DESIGN-SYSTEM.md
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- From src/app/admin/catalog/actions.ts (Plan 02) — TagMultiSelect calls these directly -->
|
|
```typescript
|
|
export async function addTagToService(serviceId: string, tagName: string): Promise<void>;
|
|
export async function removeTagFromService(serviceId: string, tagName: string): Promise<void>;
|
|
```
|
|
|
|
<!-- src/lib/utils.ts — cn() helper, used by both new components -->
|
|
```typescript
|
|
import { clsx, type ClassValue } from "clsx";
|
|
import { twMerge } from "tailwind-merge";
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
```
|
|
|
|
<!-- src/components/ui/input.tsx — forwardRef<HTMLInputElement, ComponentProps<"input">> -->
|
|
<!-- src/components/ui/textarea.tsx — forwardRef<HTMLTextAreaElement, ComponentProps<"textarea">> -->
|
|
<!-- src/components/ui/badge.tsx — Badge component, accepts className override via cn() -->
|
|
```typescript
|
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
|
function Badge({ className, variant, ...props }: BadgeProps): JSX.Element;
|
|
```
|
|
|
|
<!-- lucide-react v1.14.0 is installed — X and Plus icons available -->
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Build EditableCell component (text/number/textarea/toggle)</name>
|
|
<files>src/components/ui/editable-cell.tsx</files>
|
|
<read_first>
|
|
src/components/admin/catalog/ServiceTable.tsx (current ServiceRow inline-edit state pattern, lines 11-35, for the useState/useTransition conventions to mirror — NOT the form-based editing, but the local state + save/cancel idiom)
|
|
src/components/ui/input.tsx (Input forwardRef signature)
|
|
src/components/ui/textarea.tsx (Textarea forwardRef signature)
|
|
.planning/DESIGN-SYSTEM.md (inline edit pattern: "click su cella -> diventa input/select borderless con ring-1 ring-primary on focus -> Enter salva, Esc annulla, blur salva")
|
|
</read_first>
|
|
<behavior>
|
|
- Test 1 (display mode): renders `value` as plain text (or "—" if empty/falsy for text/textarea; "✓ Attivo"/"✗ Disattivato" for toggle)
|
|
- Test 2 (click to edit): clicking the display div sets `isEditing=true` and renders the appropriate input (`Input` for text/number, `Textarea` for textarea, `<input type="checkbox">` for toggle), auto-focused
|
|
- Test 3 (Enter saves, non-textarea): pressing Enter in a text/number input calls `onSave(tempValue)` and exits edit mode
|
|
- Test 4 (Escape cancels): pressing Escape reverts `tempValue` to the original `value` and exits edit mode WITHOUT calling `onSave`
|
|
- Test 5 (blur saves): blurring any input calls `onSave(tempValue)` and exits edit mode
|
|
- Test 6 (required validation): if `required=true` and `tempValue.trim() === ""`, `onSave` is NOT called and an inline error message renders
|
|
- Test 7 (disabled): if `disabled=true`, clicking the display div does NOT enter edit mode (no `cursor-pointer`, has `cursor-not-allowed opacity-50`)
|
|
- Test 8 (toggle type): for `type="toggle"`, `onSave` receives `"true"` or `"false"` as a string based on checkbox state
|
|
</behavior>
|
|
<action>
|
|
Create `src/components/ui/editable-cell.tsx`:
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface EditableCellProps {
|
|
value: string | number | boolean;
|
|
type?: "text" | "number" | "textarea" | "toggle";
|
|
onSave: (value: string) => void;
|
|
required?: boolean;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
formatDisplay?: (value: string) => string;
|
|
}
|
|
|
|
export function EditableCell({
|
|
value,
|
|
type = "text",
|
|
onSave,
|
|
required,
|
|
placeholder,
|
|
disabled,
|
|
formatDisplay,
|
|
}: EditableCellProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [tempValue, setTempValue] = useState(String(value));
|
|
const [error, setError] = useState<string | null>(null);
|
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
|
|
|
useEffect(() => {
|
|
setTempValue(String(value));
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
if (isEditing && inputRef.current) {
|
|
inputRef.current.focus();
|
|
if ("select" in inputRef.current) {
|
|
inputRef.current.select();
|
|
}
|
|
}
|
|
}, [isEditing]);
|
|
|
|
function startEdit() {
|
|
if (disabled) return;
|
|
setError(null);
|
|
setTempValue(String(value));
|
|
setIsEditing(true);
|
|
}
|
|
|
|
function commit() {
|
|
if (required && tempValue.trim().length === 0) {
|
|
setError("Campo richiesto");
|
|
return;
|
|
}
|
|
setError(null);
|
|
onSave(tempValue);
|
|
setIsEditing(false);
|
|
}
|
|
|
|
function cancel() {
|
|
setTempValue(String(value));
|
|
setError(null);
|
|
setIsEditing(false);
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
|
if (e.key === "Enter" && type !== "textarea") {
|
|
e.preventDefault();
|
|
commit();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
cancel();
|
|
}
|
|
}
|
|
|
|
if (!isEditing) {
|
|
let display: string;
|
|
if (type === "toggle") {
|
|
display = tempValue === "true" ? "✓ Attivo" : "✗ Disattivato";
|
|
} else {
|
|
const raw = String(value);
|
|
display = formatDisplay ? formatDisplay(raw) : raw || "—";
|
|
}
|
|
|
|
return (
|
|
<div
|
|
onClick={startEdit}
|
|
className={cn(
|
|
"px-2 py-1 rounded transition-colors duration-150 text-sm",
|
|
disabled
|
|
? "cursor-not-allowed opacity-50"
|
|
: "cursor-pointer hover:bg-[#f0f0f0]",
|
|
type === "textarea" && "line-clamp-2 max-w-md"
|
|
)}
|
|
>
|
|
{display}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{type === "textarea" ? (
|
|
<Textarea
|
|
ref={inputRef as React.Ref<HTMLTextAreaElement>}
|
|
value={tempValue}
|
|
onChange={(e) => setTempValue(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
className={cn("ring-1 ring-primary resize-none text-sm", error && "ring-2 ring-red-500")}
|
|
rows={3}
|
|
/>
|
|
) : type === "toggle" ? (
|
|
<input
|
|
ref={inputRef as React.Ref<HTMLInputElement>}
|
|
type="checkbox"
|
|
checked={tempValue === "true"}
|
|
onChange={(e) => {
|
|
const next = e.target.checked ? "true" : "false";
|
|
setTempValue(next);
|
|
onSave(next);
|
|
setIsEditing(false);
|
|
}}
|
|
onBlur={commit}
|
|
onKeyDown={handleKeyDown}
|
|
className="h-4 w-4 cursor-pointer accent-[#1A463C]"
|
|
/>
|
|
) : (
|
|
<Input
|
|
ref={inputRef as React.Ref<HTMLInputElement>}
|
|
type={type}
|
|
value={tempValue}
|
|
onChange={(e) => setTempValue(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
step={type === "number" ? "0.01" : undefined}
|
|
min={type === "number" ? "0" : undefined}
|
|
className={cn("ring-1 ring-primary h-8 text-sm", error && "ring-2 ring-red-500")}
|
|
/>
|
|
)}
|
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Design notes implemented per DESIGN-SYSTEM.md:
|
|
- Display mode: `cursor-pointer hover:bg-[#f0f0f0] transition-colors duration-150`
|
|
- Edit mode: `ring-1 ring-primary`, borderless via `Input`/`Textarea`'s existing border being visually overridden by the ring (acceptable — `Input`/`Textarea` retain their `border-input` class; the ring sits alongside it, which is consistent with shadcn focus-ring conventions used elsewhere in this codebase)
|
|
- Toggle type saves immediately on change (no separate commit step) since a checkbox's `onChange` IS the user's deliberate action — this matches D-14 ("stato attivo/disattivo diventa una cella inline (toggle/checkbox cliccabile")
|
|
- `formatDisplay` prop allows the consumer (Plan 04's `ServiceRow`) to format prices as `€{...}` without baking currency logic into this generic component
|
|
</action>
|
|
<verify>
|
|
<automated>npx tsc --noEmit 2>&1 | grep -i "editable-cell" ; echo "exit: $?"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c "export function EditableCell" src/components/ui/editable-cell.tsx` returns `1`
|
|
- `grep -c "export interface EditableCellProps" src/components/ui/editable-cell.tsx` returns `1`
|
|
- `grep -c "ring-1 ring-primary" src/components/ui/editable-cell.tsx` returns >= 2 (Input and Textarea edit modes)
|
|
- `grep -c "case \"Escape\"\|e.key === \"Escape\"" src/components/ui/editable-cell.tsx` returns >= 1
|
|
- `grep -c "e.key === \"Enter\"" src/components/ui/editable-cell.tsx` returns >= 1
|
|
- `grep -c "hover:bg-\[#f0f0f0\]" src/components/ui/editable-cell.tsx` returns >= 1
|
|
- `npx tsc --noEmit` produces zero errors referencing `src/components/ui/editable-cell.tsx`
|
|
</acceptance_criteria>
|
|
<done>
|
|
`EditableCell` renders display mode by default, enters edit mode on click (unless disabled), supports text/number/textarea/toggle types, saves on Enter (non-textarea) and blur, cancels on Escape without saving, and validates required fields with an inline error. Typecheck passes.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Build TagMultiSelect component with deterministic color hashing</name>
|
|
<files>src/components/ui/tag-multi-select.tsx</files>
|
|
<read_first>
|
|
src/components/ui/badge.tsx (Badge component + BadgeProps, cn-based className override)
|
|
src/app/admin/catalog/actions.ts (addTagToService/removeTagFromService signatures from Plan 02 — read after Plan 02 completes)
|
|
.planning/DESIGN-SYSTEM.md (tag pattern: "Badge con colori derivati da una palette fissa a rotazione (6-8 colori pastello su sfondo, testo scuro per contrasto AA) + pulsante + inline per creare un nuovo tag senza uscire dalla riga")
|
|
.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-07: hash-derived color, no color column; D-08: "Offerta" is a normal tag)
|
|
</read_first>
|
|
<behavior>
|
|
- Test 1 (color determinism): `getTagColorIndex("Offerta")` always returns the same index across calls; `getTagColorIndex("Premium")` may return a different index (hash-based, not random)
|
|
- Test 2 (palette size): the color palette array has between 6 and 8 entries (per DESIGN-SYSTEM.md), all using `bg-*-100 text-*-900` pastel pairs for AA contrast
|
|
- Test 3 (display, no tags): renders a placeholder "—" when `tags=[]`
|
|
- Test 4 (display, with tags): renders one `Badge` per tag name, each colored via `TAG_COLORS[getTagColorIndex(tagName)]`
|
|
- Test 5 (open dropdown): clicking the cell container toggles `isOpen`, revealing an input + "+" button
|
|
- Test 6 (add tag on Enter): typing a name and pressing Enter in the dropdown input calls `addTagToService(serviceId, name)`, clears the input, and triggers `onTagsChanged` (revalidation)
|
|
- Test 7 (add tag, empty input): pressing Enter with an empty/whitespace input does NOT call `addTagToService`
|
|
- Test 8 (remove tag): clicking the "X" on a badge calls `removeTagFromService(serviceId, tagName)` and `onTagsChanged`, without opening the dropdown (event propagation stopped)
|
|
- Test 9 (click outside closes dropdown): clicking outside the component while `isOpen=true` sets `isOpen=false`
|
|
- Test 10 (Escape closes dropdown): pressing Escape in the dropdown input sets `isOpen=false` without calling `addTagToService`
|
|
</behavior>
|
|
<action>
|
|
Create `src/components/ui/tag-multi-select.tsx`:
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useState, useRef, useEffect, useTransition } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { X, Plus } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { addTagToService, removeTagFromService } from "@/app/admin/catalog/actions";
|
|
|
|
// D-07: deterministic name -> color index, no stored `color` column.
|
|
// 7-color pastel palette, bg-*-100/text-*-900 pairs for AA contrast on white.
|
|
const TAG_COLORS = [
|
|
"bg-blue-100 text-blue-900",
|
|
"bg-green-100 text-green-900",
|
|
"bg-purple-100 text-purple-900",
|
|
"bg-pink-100 text-pink-900",
|
|
"bg-amber-100 text-amber-900",
|
|
"bg-teal-100 text-teal-900",
|
|
"bg-orange-100 text-orange-900",
|
|
];
|
|
|
|
export function getTagColorIndex(name: string): number {
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) {
|
|
hash = (hash << 5) - hash + name.charCodeAt(i);
|
|
hash |= 0; // 32-bit int
|
|
}
|
|
return Math.abs(hash) % TAG_COLORS.length;
|
|
}
|
|
|
|
export interface TagMultiSelectProps {
|
|
tags: string[];
|
|
serviceId: string;
|
|
onTagsChanged?: () => void;
|
|
}
|
|
|
|
export function TagMultiSelect({ tags, serviceId, onTagsChanged }: TagMultiSelectProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [newTag, setNewTag] = useState("");
|
|
const [isPending, startTransition] = useTransition();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
function handleAddTag() {
|
|
const trimmed = newTag.trim();
|
|
if (!trimmed) return;
|
|
setError(null);
|
|
startTransition(async () => {
|
|
try {
|
|
await addTagToService(serviceId, trimmed);
|
|
setNewTag("");
|
|
onTagsChanged?.();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleRemoveTag(tagName: string) {
|
|
startTransition(async () => {
|
|
try {
|
|
await removeTagFromService(serviceId, tagName);
|
|
onTagsChanged?.();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Errore nella rimozione");
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative w-full min-w-[140px]">
|
|
<div
|
|
onClick={() => setIsOpen((v) => !v)}
|
|
className="flex flex-wrap gap-1 items-center px-2 py-1 rounded cursor-pointer hover:bg-[#f0f0f0] transition-colors duration-150 min-h-[28px]"
|
|
>
|
|
{tags.length === 0 ? (
|
|
<span className="text-xs text-[#71717a]">—</span>
|
|
) : (
|
|
tags.map((tag) => (
|
|
<Badge
|
|
key={tag}
|
|
variant="outline"
|
|
className={cn(
|
|
TAG_COLORS[getTagColorIndex(tag)],
|
|
"border-transparent text-xs px-2 py-0.5 gap-1 font-medium"
|
|
)}
|
|
>
|
|
{tag}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemoveTag(tag);
|
|
}}
|
|
disabled={isPending}
|
|
className="hover:opacity-70 disabled:opacity-30"
|
|
aria-label={`Rimuovi tag ${tag}`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))
|
|
)}
|
|
<Plus className="h-3.5 w-3.5 text-[#71717a]" />
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-2 z-10 min-w-[200px]">
|
|
<div className="flex gap-1">
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="Nome tag..."
|
|
value={newTag}
|
|
onChange={(e) => setNewTag(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleAddTag();
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setIsOpen(false);
|
|
}
|
|
}}
|
|
className="text-sm h-8 ring-1 ring-primary"
|
|
disabled={isPending}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={handleAddTag}
|
|
disabled={isPending || !newTag.trim()}
|
|
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90 h-8 px-2"
|
|
>
|
|
+
|
|
</Button>
|
|
</div>
|
|
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Design notes per DESIGN-SYSTEM.md / D-07 / D-08:
|
|
- 7-color palette (within the 6-8 range specified), `bg-*-100 text-*-900` for AA contrast on white backgrounds
|
|
- `getTagColorIndex` exported for potential reuse/testing — pure function, no I/O
|
|
- "Offerta" tag (D-08) is rendered identically to any other tag — no special-casing, no protection logic
|
|
- Dropdown closes on outside click and Escape; transition 150ms per checklist
|
|
</action>
|
|
<verify>
|
|
<automated>npx tsc --noEmit 2>&1 | grep -i "tag-multi-select" ; echo "exit: $?"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c "export function TagMultiSelect" src/components/ui/tag-multi-select.tsx` returns `1`
|
|
- `grep -c "export function getTagColorIndex" src/components/ui/tag-multi-select.tsx` returns `1`
|
|
- `grep -c "const TAG_COLORS" src/components/ui/tag-multi-select.tsx` returns `1`, and the array literal has between 6 and 8 string entries (verify with `grep -A8 "const TAG_COLORS" src/components/ui/tag-multi-select.tsx | grep -c "bg-.*-100 text-.*-900"` returns a number between 6 and 8)
|
|
- `grep -c "addTagToService\|removeTagFromService" src/components/ui/tag-multi-select.tsx` returns >= 2
|
|
- `grep -c "handleClickOutside" src/components/ui/tag-multi-select.tsx` returns >= 1
|
|
- `grep -c "e.key === \"Escape\"" src/components/ui/tag-multi-select.tsx` returns >= 1
|
|
- `npx tsc --noEmit` produces zero errors referencing `src/components/ui/tag-multi-select.tsx`
|
|
</acceptance_criteria>
|
|
<done>
|
|
`TagMultiSelect` renders tag badges colored via deterministic hash (no DB color column), supports adding a new tag via Enter/+ button (calling `addTagToService`), removing a tag via the badge's X (calling `removeTagFromService`), closes its dropdown on outside-click/Escape, and surfaces server errors inline. Typecheck passes.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|--------------|
|
|
| Admin browser -> TagMultiSelect -> Server Actions | Client component invokes `addTagToService`/`removeTagFromService` (Plan 02, already admin-gated) directly |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-11-11 | Information Disclosure | `EditableCell` error messages | accept | Errors shown are generic ("Campo richiesto", "Errore nel salvataggio") — no stack traces or internal details surfaced to the browser. |
|
|
| T-11-12 | Tampering | `TagMultiSelect` calling `addTagToService`/`removeTagFromService` with arbitrary `serviceId` | accept | Authorization is enforced server-side in Plan 02's `requireAdmin()` — this plan's components are presentation-only and inherit that protection. A malicious client could theoretically call these actions with any `serviceId`, but the action itself does not leak cross-entity data (it only inserts/deletes a `tags` row scoped to `entity_type="services"`), and the admin session requirement limits this to authenticated admins (single-admin app per REQUIREMENTS.md "Out of Scope: Multi-utente"). |
|
|
| T-11-13 | Denial of Service | Rapid-fire tag add/remove clicks | mitigate | `useTransition` + `isPending` disables the input/button and remove-buttons during in-flight requests, preventing duplicate concurrent submissions from a single user action. |
|
|
|
|
No HIGH-severity unmitigated threats — both new components are presentation-layer, deferring authorization to Plan 02's server actions.
|
|
</threat_model>
|
|
|
|
<verification>
|
|
1. `npx tsc --noEmit` passes with zero errors in both new files
|
|
2. `EditableCell` exports `EditableCell` + `EditableCellProps`
|
|
3. `TagMultiSelect` exports `TagMultiSelect`, `TagMultiSelectProps`, `getTagColorIndex`
|
|
4. Both components are client components (`"use client"` directive present)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- `EditableCell` supports text/number/textarea/toggle with click-to-edit, Enter/blur save, Escape cancel, required validation, disabled state (D-11, D-14)
|
|
- `TagMultiSelect` renders deterministically-colored tag badges (D-07), supports add-on-the-fly and remove (D-08, OFFER-08), scoped via Plan 02's actions
|
|
- Both components have zero ServiceTable-specific logic — pure, reusable primitives ready for Plan 04
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-03-SUMMARY.md`
|
|
</output>
|