mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
a695251f38
* fix(labels): add nodeId filter, existence check, stale cleanup, concurrency guard, and body validation - Fix getStacksForLabel to filter by node_id (prevents cross-node data leak) - Add getLabel(id, nodeId) for single-label existence check - Add getLabelCount(nodeId) for enforcing per-node label limit (50) - Add cleanupStaleAssignments to remove orphaned assignments for deleted stacks - Add label/assignment cleanup to deleteNode transaction - Add label existence check on bulk action endpoint (returns 404 for missing labels) - Add concurrency guard on bulk actions (returns 429 if already in-flight) - Add requireBody guard on all mutation endpoints - Extract isSqliteUniqueViolation helper to deduplicate constraint checks - Add MAX_LABELS_PER_NODE constant (50) with limit enforcement on create - Add diagnostic logging on all label endpoints (gated behind developer_mode) - Add operational log line for bulk action results * fix(labels): use ScrollArea, show failure details, add loading feedback, deduplicate constants - Replace overflow-y-auto div with ScrollArea in LabelAssignPopover (design system) - Show failed stack names in bulk action error toast - Add loading toast for context menu label toggle - Disable bulk action menu items while a bulk action is running - Disable "New Label" button at 50-label limit with "Limit reached" text - Export LABEL_COLORS and MAX_LABELS_PER_NODE from LabelPill (single source of truth) - Import shared constants in LabelAssignPopover and LabelsSection (remove duplicates) - Add BulkActionResult interface to replace inline type assertion * test(labels): add comprehensive coverage for label CRUD, assignments, and bulk edge cases 42 tests covering: - getLabels: empty, ordered, node isolation - getLabel: found, wrong node, nonexistent - createLabel: returns with ID, duplicate name constraint - getLabelCount: correct count, zero for empty node - updateLabel: name, color, both, not found, wrong node - deleteLabel: removes label, cascades assignments, wrong node no-op - setStackLabels: assign, replace, clear, invalid ID throws - getLabelsForStacks: correct mapping, empty result - getStacksForLabel: correct results, node filter, empty for nonexistent - cleanupStaleAssignments: removes stale, preserves valid, handles empty - deleteNode: cascades labels and assignments - Edge cases: atomicity, cascade across stacks, multi-label assignment * docs(labels): document 50-label limit and bulk action failure details * fix(labels): add missing LabelColor type imports and explicit parameter types * refactor(labels): extract label types and constants to label-types.ts Moves LabelColor, Label, LABEL_COLORS, and MAX_LABELS_PER_NODE out of LabelPill.tsx into a dedicated non-component file. This fixes the react-refresh/only-export-components lint error caused by mixing constant exports with component exports.
59 lines
3.2 KiB
TypeScript
59 lines
3.2 KiB
TypeScript
import { type MouseEvent, type ReactNode } from 'react';
|
|
import { type LabelColor, type Label } from './label-types';
|
|
|
|
const COLOR_STYLES: Record<LabelColor, { bg: string; text: string; border: string; activeBg: string }> = {
|
|
teal: { bg: 'bg-[var(--label-teal-bg)]', text: 'text-[var(--label-teal)]', border: 'border-[var(--label-teal)]/30', activeBg: 'bg-[var(--label-teal)]' },
|
|
blue: { bg: 'bg-[var(--label-blue-bg)]', text: 'text-[var(--label-blue)]', border: 'border-[var(--label-blue)]/30', activeBg: 'bg-[var(--label-blue)]' },
|
|
purple: { bg: 'bg-[var(--label-purple-bg)]', text: 'text-[var(--label-purple)]', border: 'border-[var(--label-purple)]/30', activeBg: 'bg-[var(--label-purple)]' },
|
|
rose: { bg: 'bg-[var(--label-rose-bg)]', text: 'text-[var(--label-rose)]', border: 'border-[var(--label-rose)]/30', activeBg: 'bg-[var(--label-rose)]' },
|
|
amber: { bg: 'bg-[var(--label-amber-bg)]', text: 'text-[var(--label-amber)]', border: 'border-[var(--label-amber)]/30', activeBg: 'bg-[var(--label-amber)]' },
|
|
green: { bg: 'bg-[var(--label-green-bg)]', text: 'text-[var(--label-green)]', border: 'border-[var(--label-green)]/30', activeBg: 'bg-[var(--label-green)]' },
|
|
orange: { bg: 'bg-[var(--label-orange-bg)]', text: 'text-[var(--label-orange)]', border: 'border-[var(--label-orange)]/30', activeBg: 'bg-[var(--label-orange)]' },
|
|
pink: { bg: 'bg-[var(--label-pink-bg)]', text: 'text-[var(--label-pink)]', border: 'border-[var(--label-pink)]/30', activeBg: 'bg-[var(--label-pink)]' },
|
|
cyan: { bg: 'bg-[var(--label-cyan-bg)]', text: 'text-[var(--label-cyan)]', border: 'border-[var(--label-cyan)]/30', activeBg: 'bg-[var(--label-cyan)]' },
|
|
slate: { bg: 'bg-[var(--label-slate-bg)]', text: 'text-[var(--label-slate)]', border: 'border-[var(--label-slate)]/30', activeBg: 'bg-[var(--label-slate)]' },
|
|
};
|
|
|
|
interface LabelPillProps {
|
|
label: Label;
|
|
active?: boolean;
|
|
onClick?: (e: MouseEvent) => void;
|
|
onContextMenu?: (e: MouseEvent) => void;
|
|
size?: 'sm' | 'md';
|
|
children?: ReactNode;
|
|
}
|
|
|
|
export function LabelPill({ label, active, onClick, onContextMenu, size = 'md', children }: LabelPillProps) {
|
|
const styles = COLOR_STYLES[label.color] ?? COLOR_STYLES.slate;
|
|
const sizeClasses = size === 'sm' ? 'text-[10px] px-1.5 py-0' : 'text-[11px] px-2 py-0.5';
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
onContextMenu={onContextMenu}
|
|
className={`
|
|
inline-flex items-center gap-1 rounded-md border font-mono
|
|
${sizeClasses}
|
|
${active
|
|
? `${styles.activeBg} text-white border-transparent`
|
|
: `${styles.bg} ${styles.text} ${styles.border} hover:border-[var(--label-${label.color})]/60`
|
|
}
|
|
transition-colors cursor-pointer shrink-0
|
|
`}
|
|
>
|
|
{children ?? label.name}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function LabelDot({ color }: { color: LabelColor }) {
|
|
return (
|
|
<span
|
|
className="inline-block w-2 h-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: `var(--label-${color})` }}
|
|
/>
|
|
);
|
|
}
|
|
|