@startsimpli/ui 0.4.167 → 0.4.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/package.json +17 -5
  4. package/src/components/charts/PeriodBars.tsx +36 -0
  5. package/src/components/charts/index.ts +2 -0
  6. package/src/components/index.ts +3 -0
  7. package/src/components/reports/ExportButtons.tsx +28 -0
  8. package/src/components/reports/PeriodReport.tsx +124 -0
  9. package/src/components/reports/__tests__/PeriodReport.test.tsx +75 -0
  10. package/src/components/reports/index.ts +4 -0
  11. package/src/components/timer/ColorPicker.tsx +43 -0
  12. package/src/components/timer/EntryFormDialog.tsx +104 -0
  13. package/src/components/timer/EntryList.tsx +42 -0
  14. package/src/components/timer/IdlePromptDialog.tsx +65 -0
  15. package/src/components/timer/ProjectSelect.tsx +52 -0
  16. package/src/components/timer/ProjectSwatch.tsx +15 -0
  17. package/src/components/timer/ProjectsDialog.tsx +221 -0
  18. package/src/components/timer/TimerBar.tsx +74 -0
  19. package/src/components/timer/__tests__/interval-logic.test.ts +121 -0
  20. package/src/components/timer/__tests__/projects-dialog.test.tsx +113 -0
  21. package/src/components/timer/__tests__/timer-components.test.tsx +147 -0
  22. package/src/components/timer/__tests__/useIdleReturn.test.tsx +59 -0
  23. package/src/components/timer/__tests__/useRunningTimer.test.tsx +167 -0
  24. package/src/components/timer/index.ts +20 -0
  25. package/src/components/timer/interval-logic.ts +140 -0
  26. package/src/components/timer/useIdleReturn.ts +49 -0
  27. package/src/components/timer/useRunningTimer.ts +209 -0
  28. package/src/utils/__tests__/xlsx.test.ts +36 -0
  29. package/src/utils/index.ts +1 -0
  30. package/src/utils/xlsx.ts +35 -0
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { ProjectSwatch } from './ProjectSwatch'
4
+ import type { EntryId } from './interval-logic'
5
+
6
+ export interface ProjectOption {
7
+ id: EntryId
8
+ name: string
9
+ color?: string | null
10
+ /** The client this project bills to, when the host models clients. */
11
+ clientId?: EntryId | null
12
+ /** Per-hour rate new time on this project defaults to. */
13
+ defaultRate?: number | null
14
+ /** Store-owned passthrough (the record's other attributes), so a whole-record update can send them back. */
15
+ extra?: Record<string, unknown>
16
+ }
17
+
18
+ export interface ProjectSelectProps {
19
+ projects: ProjectOption[]
20
+ value: EntryId | null | undefined
21
+ onChange: (id: EntryId | null) => void
22
+ label?: string
23
+ id?: string
24
+ noneLabel?: string
25
+ }
26
+
27
+ /** A native select (reliable in every shell) with the chosen project's colour beside it. */
28
+ export function ProjectSelect({ projects, value, onChange, label = 'Project', id, noneLabel = 'No project' }: ProjectSelectProps) {
29
+ const chosen = projects.find((p) => String(p.id) === String(value ?? ''))
30
+ return (
31
+ <span className="inline-flex items-center gap-2">
32
+ <ProjectSwatch color={chosen?.color} />
33
+ <select
34
+ id={id}
35
+ aria-label={label}
36
+ value={value == null ? '' : String(value)}
37
+ onChange={(e) => {
38
+ const v = e.target.value
39
+ onChange(v === '' ? null : (projects.find((p) => String(p.id) === v)?.id ?? v))
40
+ }}
41
+ className="h-9 rounded-md border border-gray-300 bg-white px-2 text-sm"
42
+ >
43
+ <option value="">{noneLabel}</option>
44
+ {projects.map((p) => (
45
+ <option key={String(p.id)} value={String(p.id)}>
46
+ {p.name}
47
+ </option>
48
+ ))}
49
+ </select>
50
+ </span>
51
+ )
52
+ }
@@ -0,0 +1,15 @@
1
+ 'use client'
2
+
3
+ export const NO_COLOR = '#9ca3af'
4
+
5
+ /** A project's colour as a dot. Decorative: the project name always sits beside it. */
6
+ export function ProjectSwatch({ color, size = 10 }: { color?: string | null; size?: number }) {
7
+ return (
8
+ <span
9
+ data-testid="swatch"
10
+ aria-hidden="true"
11
+ className="inline-block shrink-0 rounded-full"
12
+ style={{ backgroundColor: color || NO_COLOR, width: size, height: size }}
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,221 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { BaseDialog } from '../dialog/BaseDialog'
5
+ import { Button } from '../ui/button'
6
+ import { ColorPicker, PROJECT_COLORS } from './ColorPicker'
7
+ import type { EntryId } from './interval-logic'
8
+ import type { ProjectOption } from './ProjectSelect'
9
+ import { ProjectSwatch } from './ProjectSwatch'
10
+
11
+ export interface ProjectSave {
12
+ id?: EntryId
13
+ name: string
14
+ color: string
15
+ /** Absent = leave as is; null = unlink. */
16
+ clientId?: EntryId | null
17
+ /** Absent = leave as is; null = clear. */
18
+ defaultRate?: number | null
19
+ }
20
+
21
+ export interface ClientOption {
22
+ id: EntryId
23
+ name: string
24
+ }
25
+
26
+ export interface ProjectsDialogProps {
27
+ open: boolean
28
+ projects: ProjectOption[]
29
+ /** When given, projects can be linked to one of these. */
30
+ clients?: ClientOption[]
31
+ busy: boolean
32
+ onSave: (project: ProjectSave) => void
33
+ onDelete?: (project: ProjectOption) => void
34
+ onClose: () => void
35
+ }
36
+
37
+ const FIELD = 'h-9 rounded-md border border-gray-300 px-3 text-sm'
38
+
39
+ /** '' or junk -> null; a non-negative number otherwise. */
40
+ function parseRate(raw: string): number | null {
41
+ const n = Number(raw)
42
+ return raw.trim() === '' || !Number.isFinite(n) || n < 0 ? null : n
43
+ }
44
+
45
+ function ClientSelect({ label, clients, value, onChange }: { label: string; clients: ClientOption[]; value: string; onChange: (v: string) => void }) {
46
+ return (
47
+ <select aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} className={`${FIELD} w-full bg-white`}>
48
+ <option value="">No client</option>
49
+ {clients.map((c) => (
50
+ <option key={String(c.id)} value={String(c.id)}>
51
+ {c.name}
52
+ </option>
53
+ ))}
54
+ </select>
55
+ )
56
+ }
57
+
58
+ function RateInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
59
+ return (
60
+ <input
61
+ aria-label={label}
62
+ placeholder="Default rate per hour"
63
+ type="number"
64
+ min="0"
65
+ step="any"
66
+ inputMode="decimal"
67
+ value={value}
68
+ onChange={(e) => onChange(e.target.value)}
69
+ className={`${FIELD} w-full`}
70
+ />
71
+ )
72
+ }
73
+
74
+ interface RowEditorProps {
75
+ project: ProjectOption
76
+ clients?: ClientOption[]
77
+ busy: boolean
78
+ onSave: (project: ProjectSave) => void
79
+ onDelete?: (project: ProjectOption) => void
80
+ }
81
+
82
+ /** Rename, relink and re-rate one project. Colour keeps its own picker on the row. */
83
+ function ProjectRowEditor({ project: p, clients, busy, onSave, onDelete }: RowEditorProps) {
84
+ const [name, setName] = useState(p.name)
85
+ const [clientId, setClientId] = useState(p.clientId == null ? '' : String(p.clientId))
86
+ const [rate, setRate] = useState(p.defaultRate == null ? '' : String(p.defaultRate))
87
+
88
+ function submit(e: React.FormEvent) {
89
+ e.preventDefault()
90
+ const trimmed = name.trim()
91
+ if (!trimmed) return
92
+ const save: ProjectSave = { id: p.id, name: trimmed, color: p.color ?? PROJECT_COLORS[0] }
93
+ if (clients) {
94
+ save.clientId = clientId === '' ? null : (clients.find((c) => String(c.id) === clientId)?.id ?? clientId)
95
+ save.defaultRate = parseRate(rate)
96
+ }
97
+ onSave(save)
98
+ }
99
+
100
+ return (
101
+ <form onSubmit={submit} className="mt-2 space-y-2 pl-5">
102
+ <input aria-label={`Name of ${p.name}`} value={name} onChange={(e) => setName(e.target.value)} className={`${FIELD} w-full`} />
103
+ {clients ? (
104
+ <>
105
+ <ClientSelect label={`Client of ${p.name}`} clients={clients} value={clientId} onChange={setClientId} />
106
+ <RateInput label={`Default rate of ${p.name}`} value={rate} onChange={setRate} />
107
+ </>
108
+ ) : null}
109
+ <div className="flex gap-2">
110
+ <Button type="submit" disabled={busy} aria-label={`Save ${p.name}`}>
111
+ Save
112
+ </Button>
113
+ {onDelete ? (
114
+ <Button type="button" variant="outline" disabled={busy} aria-label={`Delete ${p.name}`} onClick={() => onDelete(p)}>
115
+ Delete
116
+ </Button>
117
+ ) : null}
118
+ </div>
119
+ </form>
120
+ )
121
+ }
122
+
123
+ /** Projects: add one (with its client and default rate), recolour, rename, relink or delete an existing one. */
124
+ export function ProjectsDialog({ open, projects, clients, busy, onSave, onDelete, onClose }: ProjectsDialogProps) {
125
+ const [name, setName] = useState('')
126
+ const [color, setColor] = useState<string>(PROJECT_COLORS[0])
127
+ const [clientId, setClientId] = useState('')
128
+ const [rate, setRate] = useState('')
129
+ const [recolouring, setRecolouring] = useState<EntryId | null>(null)
130
+ const [editing, setEditing] = useState<EntryId | null>(null)
131
+ const clientName = (id: EntryId | null | undefined) =>
132
+ id == null ? null : (clients?.find((c) => String(c.id) === String(id))?.name ?? null)
133
+
134
+ function add(e: React.FormEvent) {
135
+ e.preventDefault()
136
+ const trimmed = name.trim()
137
+ if (!trimmed) return
138
+ const save: ProjectSave = { name: trimmed, color }
139
+ if (clients) {
140
+ if (clientId !== '') save.clientId = clients.find((c) => String(c.id) === clientId)?.id ?? clientId
141
+ const r = parseRate(rate)
142
+ if (r !== null) save.defaultRate = r
143
+ }
144
+ onSave(save)
145
+ setName('')
146
+ setClientId('')
147
+ setRate('')
148
+ }
149
+
150
+ return (
151
+ <BaseDialog open={open} onOpenChange={(o) => !o && onClose()} size="md" loading={busy}>
152
+ <BaseDialog.Header>
153
+ <BaseDialog.Title>Projects</BaseDialog.Title>
154
+ </BaseDialog.Header>
155
+ <BaseDialog.Body>
156
+ <ul className="space-y-2">
157
+ {projects.map((p) => (
158
+ <li key={String(p.id)} className="text-sm">
159
+ <div className="flex items-center gap-2">
160
+ <ProjectSwatch color={p.color} />
161
+ <span className="flex-1 text-gray-900">{p.name}</span>
162
+ {clientName(p.clientId) ? <span className="text-gray-500">{clientName(p.clientId)}</span> : null}
163
+ {p.defaultRate != null ? <span className="text-gray-500">{`${p.defaultRate}/h`}</span> : null}
164
+ <button
165
+ type="button"
166
+ className="text-indigo-600 hover:underline"
167
+ aria-label={`Edit ${p.name}`}
168
+ onClick={() => setEditing(editing === p.id ? null : p.id)}
169
+ >
170
+ Edit
171
+ </button>
172
+ <button
173
+ type="button"
174
+ className="text-indigo-600 hover:underline"
175
+ aria-label={`Change colour of ${p.name}`}
176
+ onClick={() => setRecolouring(recolouring === p.id ? null : p.id)}
177
+ >
178
+ Colour
179
+ </button>
180
+ </div>
181
+ {editing === p.id ? (
182
+ <ProjectRowEditor key={`${String(p.id)}:${p.name}:${String(p.clientId)}:${p.defaultRate}`} project={p} clients={clients} busy={busy} onSave={(v) => { onSave(v); setEditing(null) }} onDelete={onDelete ? (x) => { onDelete(x); setEditing(null) } : undefined} />
183
+ ) : null}
184
+ {recolouring === p.id ? (
185
+ <div className="mt-2 pl-5">
186
+ <ColorPicker
187
+ label={`Colour of ${p.name}`}
188
+ value={p.color}
189
+ onChange={(c) => {
190
+ onSave({ id: p.id, name: p.name, color: c })
191
+ setRecolouring(null)
192
+ }}
193
+ />
194
+ </div>
195
+ ) : null}
196
+ </li>
197
+ ))}
198
+ </ul>
199
+ <form onSubmit={add} className="mt-4 space-y-2 border-t border-gray-100 pt-4">
200
+ <input
201
+ aria-label="New project name"
202
+ placeholder="New project"
203
+ value={name}
204
+ onChange={(e) => setName(e.target.value)}
205
+ className="h-9 w-full rounded-md border border-gray-300 px-3 text-sm"
206
+ />
207
+ {clients ? (
208
+ <>
209
+ <ClientSelect label="New project client" clients={clients} value={clientId} onChange={setClientId} />
210
+ <RateInput label="New project default rate" value={rate} onChange={setRate} />
211
+ </>
212
+ ) : null}
213
+ <ColorPicker value={color} onChange={setColor} />
214
+ <Button type="submit" disabled={busy}>
215
+ Add project
216
+ </Button>
217
+ </form>
218
+ </BaseDialog.Body>
219
+ </BaseDialog>
220
+ )
221
+ }
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import { Button } from '../ui/button'
4
+ import { formatElapsed, type EntryId } from './interval-logic'
5
+ import { ProjectSelect, type ProjectOption } from './ProjectSelect'
6
+
7
+ export interface TimerBarProps {
8
+ status: 'loading' | 'stopped' | 'running'
9
+ elapsed: number
10
+ projects: ProjectOption[]
11
+ projectId: EntryId | null
12
+ note: string
13
+ /** A paused interval can be resumed. */
14
+ paused: boolean
15
+ busy: boolean
16
+ notice: 'already-running' | null
17
+ onProjectChange: (id: EntryId | null) => void
18
+ onNoteChange: (note: string) => void
19
+ onStart: () => void
20
+ onPause: () => void
21
+ onStop: () => void
22
+ onResume: () => void
23
+ }
24
+
25
+ /** The timer's one control surface: what, which project, and one main action. */
26
+ export function TimerBar(p: TimerBarProps) {
27
+ const running = p.status === 'running'
28
+ return (
29
+ <section className="space-y-2 rounded-lg border border-gray-200 bg-white p-4">
30
+ <div className="flex flex-wrap items-center gap-3">
31
+ <input
32
+ aria-label="What are you working on?"
33
+ placeholder="What are you working on?"
34
+ value={p.note}
35
+ disabled={running}
36
+ onChange={(e) => p.onNoteChange(e.target.value)}
37
+ className="h-9 min-w-[12rem] flex-1 rounded-md border border-gray-300 px-3 text-sm disabled:bg-gray-50"
38
+ />
39
+ <ProjectSelect projects={p.projects} value={p.projectId} onChange={p.onProjectChange} />
40
+ <span role="timer" aria-live="off" className="min-w-[5.5rem] text-right font-mono text-xl tabular-nums text-gray-900">
41
+ {formatElapsed(p.elapsed)}
42
+ </span>
43
+ {running ? (
44
+ <>
45
+ <Button variant="outline" onClick={p.onPause} disabled={p.busy}>
46
+ Pause
47
+ </Button>
48
+ <Button onClick={p.onStop} disabled={p.busy}>
49
+ Stop
50
+ </Button>
51
+ </>
52
+ ) : p.paused ? (
53
+ <>
54
+ <Button onClick={p.onResume} disabled={p.busy}>
55
+ Resume
56
+ </Button>
57
+ <Button variant="outline" onClick={p.onStart} disabled={p.busy}>
58
+ Start new
59
+ </Button>
60
+ </>
61
+ ) : (
62
+ <Button onClick={p.onStart} disabled={p.busy || p.status === 'loading'}>
63
+ Start
64
+ </Button>
65
+ )}
66
+ </div>
67
+ {p.notice === 'already-running' ? (
68
+ <p role="status" className="text-sm text-amber-700">
69
+ A timer was already running (started elsewhere), so it is shown here.
70
+ </p>
71
+ ) : null}
72
+ </section>
73
+ )
74
+ }
@@ -0,0 +1,121 @@
1
+ import {
2
+ formatElapsed,
3
+ elapsedMs,
4
+ planIdleResolution,
5
+ isStatus,
6
+ toLocalInput,
7
+ fromLocalInput,
8
+ validateRange,
9
+ type TimedEntry,
10
+ } from '../interval-logic'
11
+
12
+ const T = (s: string) => new Date(s).getTime()
13
+ const entry = (over: Partial<TimedEntry> = {}): TimedEntry => ({
14
+ id: 'e1',
15
+ version: 3,
16
+ startedAt: '2026-10-31T09:00:00.000Z',
17
+ endedAt: null,
18
+ projectId: 'p1',
19
+ note: 'write spec',
20
+ ...over,
21
+ })
22
+
23
+ describe('formatElapsed', () => {
24
+ it('formats h:mm:ss with no day rollover', () => {
25
+ expect(formatElapsed(0)).toBe('0:00:00')
26
+ expect(formatElapsed(59_999)).toBe('0:00:59')
27
+ expect(formatElapsed(3_661_000)).toBe('1:01:01')
28
+ expect(formatElapsed(100 * 3_600_000)).toBe('100:00:00')
29
+ })
30
+ it('clamps negative (clock skew) to zero', () => {
31
+ expect(formatElapsed(-5000)).toBe('0:00:00')
32
+ })
33
+ })
34
+
35
+ describe('elapsedMs', () => {
36
+ it('is now minus started, never negative', () => {
37
+ expect(elapsedMs('2026-10-31T09:00:00.000Z', T('2026-10-31T09:00:05.000Z'))).toBe(5000)
38
+ expect(elapsedMs('2026-10-31T09:00:10.000Z', T('2026-10-31T09:00:05.000Z'))).toBe(0)
39
+ })
40
+ })
41
+
42
+ describe('planIdleResolution', () => {
43
+ const idleSince = T('2026-10-31T10:00:00.000Z')
44
+ const now = T('2026-10-31T10:20:00.000Z')
45
+
46
+ it('keep writes nothing', () => {
47
+ expect(planIdleResolution({ entry: entry(), idleSince, now, choice: { kind: 'keep' } })).toEqual([])
48
+ })
49
+
50
+ it('discard closes at idle start, then resumes from now (close before create)', () => {
51
+ expect(planIdleResolution({ entry: entry(), idleSince, now, choice: { kind: 'discard' } })).toEqual([
52
+ { kind: 'update', patch: { endedAt: '2026-10-31T10:00:00.000Z' } },
53
+ { kind: 'create', input: { startedAt: '2026-10-31T10:20:00.000Z', endedAt: null, projectId: 'p1', note: 'write spec' } },
54
+ ])
55
+ })
56
+
57
+ it('assign closes, books the idle window to the other project, then resumes', () => {
58
+ expect(
59
+ planIdleResolution({ entry: entry(), idleSince, now, choice: { kind: 'assign', projectId: 'p2' } }),
60
+ ).toEqual([
61
+ { kind: 'update', patch: { endedAt: '2026-10-31T10:00:00.000Z' } },
62
+ { kind: 'create', input: { startedAt: '2026-10-31T10:00:00.000Z', endedAt: '2026-10-31T10:20:00.000Z', projectId: 'p2', note: '' } },
63
+ { kind: 'create', input: { startedAt: '2026-10-31T10:20:00.000Z', endedAt: null, projectId: 'p1', note: 'write spec' } },
64
+ ])
65
+ })
66
+
67
+ it('discard when the entry started inside the idle window moves started_at instead', () => {
68
+ const e = entry({ startedAt: '2026-10-31T10:05:00.000Z' })
69
+ expect(planIdleResolution({ entry: e, idleSince, now, choice: { kind: 'discard' } })).toEqual([
70
+ { kind: 'update', patch: { startedAt: '2026-10-31T10:20:00.000Z' } },
71
+ ])
72
+ })
73
+
74
+ it('assign when the entry started inside the idle window re-homes it and resumes', () => {
75
+ const e = entry({ startedAt: '2026-10-31T10:05:00.000Z' })
76
+ expect(planIdleResolution({ entry: e, idleSince, now, choice: { kind: 'assign', projectId: 'p2' } })).toEqual([
77
+ { kind: 'update', patch: { endedAt: '2026-10-31T10:20:00.000Z', projectId: 'p2' } },
78
+ { kind: 'create', input: { startedAt: '2026-10-31T10:20:00.000Z', endedAt: null, projectId: 'p1', note: 'write spec' } },
79
+ ])
80
+ })
81
+
82
+ it('always writes UTC ISO timestamps', () => {
83
+ const ops = planIdleResolution({ entry: entry(), idleSince, now, choice: { kind: 'discard' } })
84
+ for (const op of ops) {
85
+ const v = op.kind === 'update' ? Object.values(op.patch) : [op.input.startedAt, op.input.endedAt]
86
+ for (const s of v) if (typeof s === 'string' && /^\d{4}-/.test(s)) expect(s.endsWith('Z')).toBe(true)
87
+ }
88
+ })
89
+ })
90
+
91
+ describe('isStatus', () => {
92
+ it('reads status off ApiException-like errors', () => {
93
+ expect(isStatus(Object.assign(new Error('x'), { status: 409 }), 409)).toBe(true)
94
+ expect(isStatus(Object.assign(new Error('x'), { status: 412 }), 409)).toBe(false)
95
+ expect(isStatus(new Error('x'), 409)).toBe(false)
96
+ expect(isStatus(null, 409)).toBe(false)
97
+ })
98
+ })
99
+
100
+ describe('datetime-local helpers', () => {
101
+ it('round-trips through the local zone without drifting', () => {
102
+ const iso = '2026-10-31T09:15:00.000Z'
103
+ expect(fromLocalInput(toLocalInput(iso))).toBe(iso)
104
+ })
105
+ it('formats as yyyy-mm-ddThh:mm', () => {
106
+ expect(toLocalInput('2026-10-31T09:15:42.000Z')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)
107
+ })
108
+ it('returns null for blank or unparsable input', () => {
109
+ expect(fromLocalInput('')).toBeNull()
110
+ expect(fromLocalInput('nope')).toBeNull()
111
+ })
112
+ })
113
+
114
+ describe('validateRange', () => {
115
+ it('requires a start, and an end after it when given', () => {
116
+ expect(validateRange('2026-10-31T09:00:00.000Z', '2026-10-31T10:00:00.000Z')).toBeNull()
117
+ expect(validateRange('2026-10-31T09:00:00.000Z', null)).toBeNull()
118
+ expect(validateRange(null, null)).toMatch(/start/i)
119
+ expect(validateRange('2026-10-31T10:00:00.000Z', '2026-10-31T09:00:00.000Z')).toMatch(/after/i)
120
+ })
121
+ })
@@ -0,0 +1,113 @@
1
+ import { fireEvent, render, screen, within } from '@testing-library/react'
2
+ import { ProjectsDialog } from '../ProjectsDialog'
3
+ import { PROJECT_COLORS } from '../ColorPicker'
4
+
5
+ const projects = [
6
+ { id: 'p1', name: 'Alpha', color: '#3366ff' },
7
+ { id: 'p2', name: 'Beta', color: null },
8
+ ]
9
+
10
+ describe('ProjectsDialog', () => {
11
+ it('lists projects with their colours', () => {
12
+ render(<ProjectsDialog open projects={projects} busy={false} onSave={jest.fn()} onClose={jest.fn()} />)
13
+ expect(screen.getByText('Alpha')).toBeInTheDocument()
14
+ expect(screen.getByText('Beta')).toBeInTheDocument()
15
+ expect(screen.getAllByTestId('swatch').length).toBeGreaterThanOrEqual(2)
16
+ })
17
+
18
+ it('creates a project with a chosen colour', () => {
19
+ const onSave = jest.fn()
20
+ render(<ProjectsDialog open projects={projects} busy={false} onSave={onSave} onClose={jest.fn()} />)
21
+ fireEvent.change(screen.getByLabelText('New project name'), { target: { value: 'Gamma' } })
22
+ fireEvent.click(screen.getAllByRole('radio', { name: PROJECT_COLORS[3] })[0])
23
+ fireEvent.click(screen.getByRole('button', { name: 'Add project' }))
24
+ expect(onSave).toHaveBeenCalledWith({ name: 'Gamma', color: PROJECT_COLORS[3] })
25
+ })
26
+
27
+ it('does not create a nameless project', () => {
28
+ const onSave = jest.fn()
29
+ render(<ProjectsDialog open projects={projects} busy={false} onSave={onSave} onClose={jest.fn()} />)
30
+ fireEvent.click(screen.getByRole('button', { name: 'Add project' }))
31
+ expect(onSave).not.toHaveBeenCalled()
32
+ })
33
+
34
+ it('recolours an existing project', () => {
35
+ const onSave = jest.fn()
36
+ render(<ProjectsDialog open projects={projects} busy={false} onSave={onSave} onClose={jest.fn()} />)
37
+ fireEvent.click(screen.getByRole('button', { name: 'Change colour of Alpha' }))
38
+ const group = screen.getByRole('radiogroup', { name: 'Colour of Alpha' })
39
+ fireEvent.click(group.querySelectorAll('[role=radio]')[5])
40
+ expect(onSave).toHaveBeenCalledWith({ id: 'p1', name: 'Alpha', color: PROJECT_COLORS[5] })
41
+ })
42
+
43
+ describe('client and default rate', () => {
44
+ const clients = [
45
+ { id: 'c1', name: 'Acme' },
46
+ { id: 'c2', name: 'Globex' },
47
+ ]
48
+ const linked = [
49
+ { id: 'p1', name: 'Alpha', color: '#3366ff', clientId: 'c1', defaultRate: 90 },
50
+ { id: 'p2', name: 'Beta', color: null },
51
+ ]
52
+
53
+ it('shows the linked client and rate on the project row', () => {
54
+ render(<ProjectsDialog open projects={linked} clients={clients} busy={false} onSave={jest.fn()} onClose={jest.fn()} />)
55
+ const list = within(screen.getByRole('list'))
56
+ expect(list.getByText('Acme')).toBeInTheDocument()
57
+ expect(list.getByText('90/h')).toBeInTheDocument()
58
+ })
59
+
60
+ it('links a project to a client and sets its default rate', () => {
61
+ const onSave = jest.fn()
62
+ render(<ProjectsDialog open projects={linked} clients={clients} busy={false} onSave={onSave} onClose={jest.fn()} />)
63
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Beta' }))
64
+ fireEvent.change(screen.getByLabelText('Client of Beta'), { target: { value: 'c2' } })
65
+ fireEvent.change(screen.getByLabelText('Default rate of Beta'), { target: { value: '120.5' } })
66
+ fireEvent.click(screen.getByRole('button', { name: 'Save Beta' }))
67
+ expect(onSave).toHaveBeenCalledWith({ id: 'p2', name: 'Beta', color: expect.any(String), clientId: 'c2', defaultRate: 120.5 })
68
+ })
69
+
70
+ it('unlinks the client and clears the rate', () => {
71
+ const onSave = jest.fn()
72
+ render(<ProjectsDialog open projects={linked} clients={clients} busy={false} onSave={onSave} onClose={jest.fn()} />)
73
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Alpha' }))
74
+ fireEvent.change(screen.getByLabelText('Client of Alpha'), { target: { value: '' } })
75
+ fireEvent.change(screen.getByLabelText('Default rate of Alpha'), { target: { value: '' } })
76
+ fireEvent.click(screen.getByRole('button', { name: 'Save Alpha' }))
77
+ expect(onSave).toHaveBeenCalledWith({ id: 'p1', name: 'Alpha', color: '#3366ff', clientId: null, defaultRate: null })
78
+ })
79
+
80
+ it('renames a project', () => {
81
+ const onSave = jest.fn()
82
+ render(<ProjectsDialog open projects={linked} clients={clients} busy={false} onSave={onSave} onClose={jest.fn()} />)
83
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Alpha' }))
84
+ fireEvent.change(screen.getByLabelText('Name of Alpha'), { target: { value: 'Alpha 2' } })
85
+ fireEvent.click(screen.getByRole('button', { name: 'Save Alpha' }))
86
+ expect(onSave).toHaveBeenCalledWith({ id: 'p1', name: 'Alpha 2', color: '#3366ff', clientId: 'c1', defaultRate: 90 })
87
+ })
88
+
89
+ it('creates a project already linked to a client with a rate', () => {
90
+ const onSave = jest.fn()
91
+ render(<ProjectsDialog open projects={[]} clients={clients} busy={false} onSave={onSave} onClose={jest.fn()} />)
92
+ fireEvent.change(screen.getByLabelText('New project name'), { target: { value: 'Gamma' } })
93
+ fireEvent.change(screen.getByLabelText('New project client'), { target: { value: 'c1' } })
94
+ fireEvent.change(screen.getByLabelText('New project default rate'), { target: { value: '75' } })
95
+ fireEvent.click(screen.getByRole('button', { name: 'Add project' }))
96
+ expect(onSave).toHaveBeenCalledWith({ name: 'Gamma', color: PROJECT_COLORS[0], clientId: 'c1', defaultRate: 75 })
97
+ })
98
+
99
+ it('deletes a project', () => {
100
+ const onDelete = jest.fn()
101
+ render(<ProjectsDialog open projects={linked} clients={clients} busy={false} onSave={jest.fn()} onDelete={onDelete} onClose={jest.fn()} />)
102
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Alpha' }))
103
+ fireEvent.click(screen.getByRole('button', { name: 'Delete Alpha' }))
104
+ expect(onDelete).toHaveBeenCalledWith(linked[0])
105
+ })
106
+
107
+ it('hides client and rate controls the host does not support', () => {
108
+ render(<ProjectsDialog open projects={linked} busy={false} onSave={jest.fn()} onClose={jest.fn()} />)
109
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Alpha' }))
110
+ expect(screen.queryByLabelText('Client of Alpha')).toBeNull()
111
+ })
112
+ })
113
+ })