Skip to content

Commit 9050c7b

Browse files
committed
feat(players-teams): label CRUD component and tests
Add ManageLabels component and full test coverage for issue #110. Deletion is blocked when the label is in use by any player or team, with an error message naming the count of each entity type. The check runs in a single Promise.all to avoid two sequential DB reads. Behaviour mirrors ManageTags (inline edit row, colour picker, keyboard shortcuts Enter/Escape) so the UX is consistent across settings sections. Closes #110
1 parent 09195d4 commit 9050c7b

2 files changed

Lines changed: 456 additions & 0 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { useEffect, useState } from 'react'
2+
import { useLiveQuery } from 'dexie-react-hooks'
3+
import { db } from '@/db'
4+
import type { ManagedLabel } from '@/types/players-teams'
5+
import { Button, Input } from '@/components/ui'
6+
7+
interface EditState {
8+
id: string | null // null = new
9+
name: string
10+
color: string
11+
}
12+
13+
const DEFAULT_COLOR = '#6366f1'
14+
15+
function empty(): EditState {
16+
return { id: null, name: '', color: DEFAULT_COLOR }
17+
}
18+
19+
export default function ManageLabels() {
20+
const labels = useLiveQuery(() => db.managedLabels.orderBy('name').toArray(), [])
21+
const [editing, setEditing] = useState<EditState | null>(null)
22+
const [error, setError] = useState<string | null>(null)
23+
const [busy, setBusy] = useState(false)
24+
25+
useEffect(() => {
26+
setError(null)
27+
}, [editing])
28+
29+
async function save() {
30+
if (!editing) return
31+
const name = editing.name.trim()
32+
if (!name) {
33+
setError('Name is required')
34+
return
35+
}
36+
37+
setBusy(true)
38+
try {
39+
if (editing.id) {
40+
await db.managedLabels.update(editing.id, { name, color: editing.color })
41+
} else {
42+
await db.managedLabels.add({ id: crypto.randomUUID(), name, color: editing.color })
43+
}
44+
setEditing(null)
45+
} catch {
46+
setError('Failed to save — name may already be in use')
47+
} finally {
48+
setBusy(false)
49+
}
50+
}
51+
52+
async function remove(label: ManagedLabel) {
53+
const [playerCount, teamCount] = await Promise.all([
54+
db.managedPlayers.filter(p => p.labelIds.includes(label.id)).count(),
55+
db.managedTeams.filter(t => t.labelIds.includes(label.id)).count(),
56+
])
57+
const total = playerCount + teamCount
58+
if (total > 0) {
59+
const parts: string[] = []
60+
if (playerCount > 0) parts.push(`${playerCount} player${playerCount === 1 ? '' : 's'}`)
61+
if (teamCount > 0) parts.push(`${teamCount} team${teamCount === 1 ? '' : 's'}`)
62+
setError(`"${label.name}" is used by ${parts.join(' and ')} — detach it first`)
63+
return
64+
}
65+
await db.managedLabels.delete(label.id)
66+
if (editing?.id === label.id) setEditing(null)
67+
}
68+
69+
const isEditing = (id: string) => editing?.id === id
70+
71+
return (
72+
<section>
73+
<div className="flex items-center justify-between mb-3">
74+
<div>
75+
<h2 className="font-semibold text-base" style={{ color: 'var(--color-ink)' }}>
76+
Labels
77+
</h2>
78+
<p className="text-xs mt-0.5" style={{ color: 'var(--color-muted)' }}>
79+
Attach labels to players and teams for filtering
80+
</p>
81+
</div>
82+
{!editing && (
83+
<Button variant="secondary" size="sm" onClick={() => setEditing(empty())}>
84+
+ Add
85+
</Button>
86+
)}
87+
</div>
88+
89+
{error && (
90+
<p
91+
className="text-xs mb-3 px-3 py-2 rounded"
92+
style={{ color: 'var(--color-red)', background: 'var(--color-red)1a' }}
93+
>
94+
{error}
95+
</p>
96+
)}
97+
98+
<div
99+
className="rounded-lg overflow-hidden border"
100+
style={{ borderColor: 'var(--color-border)' }}
101+
>
102+
{/* Add row */}
103+
{editing?.id === null && (
104+
<div
105+
className="flex items-center gap-2 px-3 py-2 border-b"
106+
style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface)' }}
107+
>
108+
<input
109+
type="color"
110+
value={editing.color}
111+
onChange={e => setEditing(s => s && { ...s, color: e.target.value })}
112+
className="w-7 h-7 rounded cursor-pointer border"
113+
style={{ borderColor: 'var(--color-border)', padding: '1px' }}
114+
aria-label="Label colour"
115+
/>
116+
<Input
117+
value={editing.name}
118+
onChange={e => setEditing(s => s && { ...s, name: e.target.value })}
119+
placeholder="Label name"
120+
className="flex-1"
121+
onKeyDown={e => {
122+
if (e.key === 'Enter') save()
123+
if (e.key === 'Escape') setEditing(null)
124+
}}
125+
autoFocus
126+
/>
127+
<Button variant="primary" size="sm" onClick={save} disabled={busy}>
128+
Save
129+
</Button>
130+
<Button variant="ghost" size="sm" onClick={() => setEditing(null)}>
131+
Cancel
132+
</Button>
133+
</div>
134+
)}
135+
136+
{labels?.length === 0 && !editing && (
137+
<p className="px-4 py-6 text-sm text-center" style={{ color: 'var(--color-muted)' }}>
138+
No labels yet — add one above
139+
</p>
140+
)}
141+
142+
{labels?.map(label => (
143+
<div
144+
key={label.id}
145+
className="border-b last:border-b-0"
146+
style={{ borderColor: 'var(--color-border)' }}
147+
>
148+
{isEditing(label.id) ? (
149+
<div
150+
className="flex items-center gap-2 px-3 py-2"
151+
style={{ background: 'var(--color-surface)' }}
152+
>
153+
<input
154+
type="color"
155+
value={editing!.color}
156+
onChange={e => setEditing(s => s && { ...s, color: e.target.value })}
157+
className="w-7 h-7 rounded cursor-pointer border"
158+
style={{ borderColor: 'var(--color-border)', padding: '1px' }}
159+
aria-label="Label colour"
160+
/>
161+
<Input
162+
value={editing!.name}
163+
onChange={e => setEditing(s => s && { ...s, name: e.target.value })}
164+
className="flex-1"
165+
onKeyDown={e => {
166+
if (e.key === 'Enter') save()
167+
if (e.key === 'Escape') setEditing(null)
168+
}}
169+
autoFocus
170+
/>
171+
<Button variant="primary" size="sm" onClick={save} disabled={busy}>
172+
Save
173+
</Button>
174+
<Button variant="ghost" size="sm" onClick={() => setEditing(null)}>
175+
Cancel
176+
</Button>
177+
</div>
178+
) : (
179+
<div className="flex items-center gap-3 px-3 py-2.5">
180+
<span
181+
className="w-3 h-3 rounded-full shrink-0"
182+
style={{ background: label.color }}
183+
aria-hidden
184+
/>
185+
<span className="flex-1 text-sm" style={{ color: 'var(--color-ink)' }}>
186+
{label.name}
187+
</span>
188+
<button
189+
onClick={() => setEditing({ id: label.id, name: label.name, color: label.color })}
190+
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
191+
style={{ color: 'var(--color-muted)' }}
192+
aria-label={`Edit ${label.name}`}
193+
>
194+
Edit
195+
</button>
196+
<button
197+
onClick={() => remove(label)}
198+
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
199+
style={{ color: 'var(--color-red)' }}
200+
aria-label={`Delete ${label.name}`}
201+
>
202+
Delete
203+
</button>
204+
</div>
205+
)}
206+
</div>
207+
))}
208+
</div>
209+
</section>
210+
)
211+
}

0 commit comments

Comments
 (0)