---
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"
---
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
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.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
```typescript
export async function addTagToService(serviceId: string, tagName: string): Promise;
export async function removeTagFromService(serviceId: string, tagName: string): Promise;
```
```typescript
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
```typescript
export interface BadgeProps extends React.HTMLAttributes, VariantProps {}
function Badge({ className, variant, ...props }: BadgeProps): JSX.Element;
```
Task 1: Build EditableCell component (text/number/textarea/toggle)src/components/ui/editable-cell.tsx
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")
- 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, `` 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
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(null);
const inputRef = useRef(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) {
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 (
{display}
);
}
return (
{type === "textarea" ? (
);
}
```
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
npx tsc --noEmit 2>&1 | grep -i "editable-cell" ; echo "exit: $?"
- `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`
`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.
Task 2: Build TagMultiSelect component with deterministic color hashingsrc/components/ui/tag-multi-select.tsx
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)
- 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`
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(null);
const inputRef = useRef(null);
const containerRef = useRef(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 (
);
}
```
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
npx tsc --noEmit 2>&1 | grep -i "tag-multi-select" ; echo "exit: $?"
- `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`
`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.
## 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.
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)
- `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