@baack-software/baack-ts-tasks 0.0.0-stage → 0.0.1

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 (229) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +158 -2
  3. package/dist/activity.d.ts +20 -0
  4. package/dist/activity.d.ts.map +1 -0
  5. package/dist/activity.js +75 -0
  6. package/dist/activity.js.map +1 -0
  7. package/dist/blockers.d.ts +28 -0
  8. package/dist/blockers.d.ts.map +1 -0
  9. package/dist/blockers.js +134 -0
  10. package/dist/blockers.js.map +1 -0
  11. package/dist/conflicts.d.ts +50 -0
  12. package/dist/conflicts.d.ts.map +1 -0
  13. package/dist/conflicts.js +73 -0
  14. package/dist/conflicts.js.map +1 -0
  15. package/dist/draft.d.ts +45 -0
  16. package/dist/draft.d.ts.map +1 -0
  17. package/dist/draft.js +155 -0
  18. package/dist/draft.js.map +1 -0
  19. package/dist/errors.d.ts +30 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +37 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/index.d.ts +11 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +11 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/react/TaskActivity.d.ts +15 -0
  28. package/dist/react/TaskActivity.d.ts.map +1 -0
  29. package/dist/react/TaskActivity.js +60 -0
  30. package/dist/react/TaskActivity.js.map +1 -0
  31. package/dist/react/TaskBoard.d.ts +38 -0
  32. package/dist/react/TaskBoard.d.ts.map +1 -0
  33. package/dist/react/TaskBoard.js +85 -0
  34. package/dist/react/TaskBoard.js.map +1 -0
  35. package/dist/react/TaskConflicts.d.ts +26 -0
  36. package/dist/react/TaskConflicts.d.ts.map +1 -0
  37. package/dist/react/TaskConflicts.js +87 -0
  38. package/dist/react/TaskConflicts.js.map +1 -0
  39. package/dist/react/TaskCreateForm.d.ts +13 -0
  40. package/dist/react/TaskCreateForm.d.ts.map +1 -0
  41. package/dist/react/TaskCreateForm.js +68 -0
  42. package/dist/react/TaskCreateForm.js.map +1 -0
  43. package/dist/react/TaskEditor.d.ts +33 -0
  44. package/dist/react/TaskEditor.d.ts.map +1 -0
  45. package/dist/react/TaskEditor.js +172 -0
  46. package/dist/react/TaskEditor.js.map +1 -0
  47. package/dist/react/TaskFilters.d.ts +15 -0
  48. package/dist/react/TaskFilters.d.ts.map +1 -0
  49. package/dist/react/TaskFilters.js +48 -0
  50. package/dist/react/TaskFilters.js.map +1 -0
  51. package/dist/react/TaskView.d.ts +30 -0
  52. package/dist/react/TaskView.d.ts.map +1 -0
  53. package/dist/react/TaskView.js +108 -0
  54. package/dist/react/TaskView.js.map +1 -0
  55. package/dist/react/TasksAbout.d.ts +24 -0
  56. package/dist/react/TasksAbout.d.ts.map +1 -0
  57. package/dist/react/TasksAbout.js +82 -0
  58. package/dist/react/TasksAbout.js.map +1 -0
  59. package/dist/react/copy.d.ts +154 -0
  60. package/dist/react/copy.d.ts.map +1 -0
  61. package/dist/react/copy.js +173 -0
  62. package/dist/react/copy.js.map +1 -0
  63. package/dist/react/fields.d.ts +36 -0
  64. package/dist/react/fields.d.ts.map +1 -0
  65. package/dist/react/fields.js +21 -0
  66. package/dist/react/fields.js.map +1 -0
  67. package/dist/react/hooks.d.ts +9 -0
  68. package/dist/react/hooks.d.ts.map +1 -0
  69. package/dist/react/hooks.js +31 -0
  70. package/dist/react/hooks.js.map +1 -0
  71. package/dist/react/index.d.ts +13 -0
  72. package/dist/react/index.d.ts.map +1 -0
  73. package/dist/react/index.js +13 -0
  74. package/dist/react/index.js.map +1 -0
  75. package/dist/react/parts.d.ts +55 -0
  76. package/dist/react/parts.d.ts.map +1 -0
  77. package/dist/react/parts.js +92 -0
  78. package/dist/react/parts.js.map +1 -0
  79. package/dist/react/ui/alert.d.ts +11 -0
  80. package/dist/react/ui/alert.d.ts.map +1 -0
  81. package/dist/react/ui/alert.js +29 -0
  82. package/dist/react/ui/alert.js.map +1 -0
  83. package/dist/react/ui/avatar.d.ts +12 -0
  84. package/dist/react/ui/avatar.d.ts.map +1 -0
  85. package/dist/react/ui/avatar.js +24 -0
  86. package/dist/react/ui/avatar.js.map +1 -0
  87. package/dist/react/ui/badge.d.ts +10 -0
  88. package/dist/react/ui/badge.d.ts.map +1 -0
  89. package/dist/react/ui/badge.js +26 -0
  90. package/dist/react/ui/badge.js.map +1 -0
  91. package/dist/react/ui/button.d.ts +11 -0
  92. package/dist/react/ui/button.d.ts.map +1 -0
  93. package/dist/react/ui/button.js +37 -0
  94. package/dist/react/ui/button.js.map +1 -0
  95. package/dist/react/ui/card.d.ts +12 -0
  96. package/dist/react/ui/card.d.ts.map +1 -0
  97. package/dist/react/ui/card.js +26 -0
  98. package/dist/react/ui/card.js.map +1 -0
  99. package/dist/react/ui/input.d.ts +4 -0
  100. package/dist/react/ui/input.d.ts.map +1 -0
  101. package/dist/react/ui/input.js +8 -0
  102. package/dist/react/ui/input.js.map +1 -0
  103. package/dist/react/ui/label.d.ts +5 -0
  104. package/dist/react/ui/label.d.ts.map +1 -0
  105. package/dist/react/ui/label.js +10 -0
  106. package/dist/react/ui/label.js.map +1 -0
  107. package/dist/react/ui/radio-group.d.ts +6 -0
  108. package/dist/react/ui/radio-group.d.ts.map +1 -0
  109. package/dist/react/ui/radio-group.js +12 -0
  110. package/dist/react/ui/radio-group.js.map +1 -0
  111. package/dist/react/ui/select.d.ts +16 -0
  112. package/dist/react/ui/select.d.ts.map +1 -0
  113. package/dist/react/ui/select.js +37 -0
  114. package/dist/react/ui/select.js.map +1 -0
  115. package/dist/react/ui/separator.d.ts +5 -0
  116. package/dist/react/ui/separator.d.ts.map +1 -0
  117. package/dist/react/ui/separator.js +10 -0
  118. package/dist/react/ui/separator.js.map +1 -0
  119. package/dist/react/ui/sheet.d.ts +15 -0
  120. package/dist/react/ui/sheet.d.ts.map +1 -0
  121. package/dist/react/ui/sheet.js +38 -0
  122. package/dist/react/ui/sheet.js.map +1 -0
  123. package/dist/react/ui/skeleton.d.ts +3 -0
  124. package/dist/react/ui/skeleton.d.ts.map +1 -0
  125. package/dist/react/ui/skeleton.js +7 -0
  126. package/dist/react/ui/skeleton.js.map +1 -0
  127. package/dist/react/ui/table.d.ts +11 -0
  128. package/dist/react/ui/table.d.ts.map +1 -0
  129. package/dist/react/ui/table.js +29 -0
  130. package/dist/react/ui/table.js.map +1 -0
  131. package/dist/react/ui/tabs.d.ts +12 -0
  132. package/dist/react/ui/tabs.d.ts.map +1 -0
  133. package/dist/react/ui/tabs.js +31 -0
  134. package/dist/react/ui/tabs.js.map +1 -0
  135. package/dist/react/ui/textarea.d.ts +4 -0
  136. package/dist/react/ui/textarea.d.ts.map +1 -0
  137. package/dist/react/ui/textarea.js +8 -0
  138. package/dist/react/ui/textarea.js.map +1 -0
  139. package/dist/react/ui/toggle-group.d.ts +11 -0
  140. package/dist/react/ui/toggle-group.d.ts.map +1 -0
  141. package/dist/react/ui/toggle-group.js +25 -0
  142. package/dist/react/ui/toggle-group.js.map +1 -0
  143. package/dist/react/ui/toggle.d.ts +10 -0
  144. package/dist/react/ui/toggle.d.ts.map +1 -0
  145. package/dist/react/ui/toggle.js +27 -0
  146. package/dist/react/ui/toggle.js.map +1 -0
  147. package/dist/refs.d.ts +16 -0
  148. package/dist/refs.d.ts.map +1 -0
  149. package/dist/refs.js +43 -0
  150. package/dist/refs.js.map +1 -0
  151. package/dist/server/index.d.ts +4 -0
  152. package/dist/server/index.d.ts.map +1 -0
  153. package/dist/server/index.js +4 -0
  154. package/dist/server/index.js.map +1 -0
  155. package/dist/server/recipes.d.ts +39 -0
  156. package/dist/server/recipes.d.ts.map +1 -0
  157. package/dist/server/recipes.js +81 -0
  158. package/dist/server/recipes.js.map +1 -0
  159. package/dist/server/webhooks.d.ts +135 -0
  160. package/dist/server/webhooks.d.ts.map +1 -0
  161. package/dist/server/webhooks.js +243 -0
  162. package/dist/server/webhooks.js.map +1 -0
  163. package/dist/store.d.ts +124 -0
  164. package/dist/store.d.ts.map +1 -0
  165. package/dist/store.js +443 -0
  166. package/dist/store.js.map +1 -0
  167. package/dist/task-client.d.ts +137 -0
  168. package/dist/task-client.d.ts.map +1 -0
  169. package/dist/task-client.js +271 -0
  170. package/dist/task-client.js.map +1 -0
  171. package/dist/tasks.d.ts +84 -0
  172. package/dist/tasks.d.ts.map +1 -0
  173. package/dist/tasks.js +208 -0
  174. package/dist/tasks.js.map +1 -0
  175. package/dist/time.d.ts +15 -0
  176. package/dist/time.d.ts.map +1 -0
  177. package/dist/time.js +34 -0
  178. package/dist/time.js.map +1 -0
  179. package/dist/types.d.ts +74 -0
  180. package/dist/types.d.ts.map +1 -0
  181. package/dist/types.js +9 -0
  182. package/dist/types.js.map +1 -0
  183. package/package.json +101 -4
  184. package/src/activity.ts +86 -0
  185. package/src/blockers.ts +134 -0
  186. package/src/conflicts.ts +115 -0
  187. package/src/draft.ts +162 -0
  188. package/src/errors.ts +40 -0
  189. package/src/index.ts +10 -0
  190. package/src/react/TaskActivity.tsx +187 -0
  191. package/src/react/TaskBoard.tsx +264 -0
  192. package/src/react/TaskConflicts.tsx +217 -0
  193. package/src/react/TaskCreateForm.tsx +165 -0
  194. package/src/react/TaskEditor.tsx +485 -0
  195. package/src/react/TaskFilters.tsx +122 -0
  196. package/src/react/TaskView.tsx +291 -0
  197. package/src/react/TasksAbout.tsx +226 -0
  198. package/src/react/copy.tsx +253 -0
  199. package/src/react/fields.tsx +104 -0
  200. package/src/react/hooks.ts +41 -0
  201. package/src/react/index.ts +12 -0
  202. package/src/react/parts.tsx +176 -0
  203. package/src/react/ui/README.md +6 -0
  204. package/src/react/ui/alert.tsx +75 -0
  205. package/src/react/ui/avatar.tsx +109 -0
  206. package/src/react/ui/badge.tsx +48 -0
  207. package/src/react/ui/button.tsx +66 -0
  208. package/src/react/ui/card.tsx +102 -0
  209. package/src/react/ui/input.tsx +18 -0
  210. package/src/react/ui/label.tsx +23 -0
  211. package/src/react/ui/radio-group.tsx +41 -0
  212. package/src/react/ui/select.tsx +189 -0
  213. package/src/react/ui/separator.tsx +27 -0
  214. package/src/react/ui/sheet.tsx +145 -0
  215. package/src/react/ui/skeleton.tsx +13 -0
  216. package/src/react/ui/table.tsx +113 -0
  217. package/src/react/ui/tabs.tsx +89 -0
  218. package/src/react/ui/textarea.tsx +17 -0
  219. package/src/react/ui/toggle-group.tsx +89 -0
  220. package/src/react/ui/toggle.tsx +44 -0
  221. package/src/refs.ts +49 -0
  222. package/src/server/index.ts +3 -0
  223. package/src/server/recipes.ts +107 -0
  224. package/src/server/webhooks.ts +369 -0
  225. package/src/store.ts +538 -0
  226. package/src/task-client.ts +373 -0
  227. package/src/tasks.ts +263 -0
  228. package/src/time.ts +35 -0
  229. package/src/types.ts +101 -0
@@ -0,0 +1,485 @@
1
+ import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
2
+ import type { Task, TaskPriority, TaskStatus } from '@baack-software/baack-ts-client';
3
+ import { Building2, FileText, Link2, Trash2, UserRound, X } from 'lucide-react';
4
+ import { wouldCreateCycle } from '../blockers.ts';
5
+ import { resolveConflicts, type TaskConflict, type TaskField } from '../conflicts.ts';
6
+ import { isEmptyChange } from '../draft.ts';
7
+ import { TaskConflictError } from '../errors.ts';
8
+ import { withoutRef, withRef } from '../refs.ts';
9
+ import { isOpen, TASK_PRIORITIES, TASK_STATUSES, workflowStages } from '../tasks.ts';
10
+ import { fromLocalInput, toLocalInput } from '../time.ts';
11
+ import type { TaskInput, TaskListField } from '../types.ts';
12
+ import { priorityCopy, statusCopy, useCopy, type TaskCopyName } from './copy.tsx';
13
+ import { ChoiceSelect, Field, NONE } from './fields.tsx';
14
+ import { nameOf, PersonAvatar, StatusBadge, Timestamp, type Named, type TaskDirectory } from './parts.tsx';
15
+ import { Alert, AlertDescription } from './ui/alert.tsx';
16
+ import { Button } from './ui/button.tsx';
17
+ import { Input } from './ui/input.tsx';
18
+ import { Separator } from './ui/separator.tsx';
19
+ import { Textarea } from './ui/textarea.tsx';
20
+ import { ChangedElsewhere, TaskConflicts } from './TaskConflicts.tsx';
21
+
22
+ export interface TaskEditorProps {
23
+ task: Task;
24
+ directory: TaskDirectory;
25
+ /**
26
+ * Sends a change. Rejections are shown in the editor. A `TaskConflictError` (from `TaskStore.update`, or
27
+ * `TaskClient.update` with `base: task`) opens a panel to choose, field by field, between this person's value and
28
+ * the other one; the choices are sent as another change. Without the store, update `task` to the error's `current`
29
+ * so that change is checked against what the person now sees.
30
+ */
31
+ onChange: (changes: TaskInput) => Promise<unknown>;
32
+ /** Fields someone else changed while this person was editing, shown as a note. */
33
+ changedElsewhere?: readonly (TaskField | TaskListField)[] | undefined;
34
+ onDismissChangedElsewhere?: (() => void) | undefined;
35
+ onDelete?: (() => Promise<unknown>) | undefined;
36
+ onClose?: (() => void) | undefined;
37
+ /** Shown while a change is being saved. */
38
+ saving?: boolean;
39
+ /** The activity log, usually a `TaskActivity`. */
40
+ activity?: ReactNode;
41
+ /** Focus the heading when the editor opens, for keyboard and screen reader users. Default true. */
42
+ focusOnOpen?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Every field of a task, saved as it changes: selects at once, text when it loses focus. Blockers can be added
47
+ * and removed, related records are listed, and the activity log goes below.
48
+ */
49
+ export function TaskEditor({
50
+ task,
51
+ directory,
52
+ onChange,
53
+ onDelete,
54
+ onClose,
55
+ saving,
56
+ activity,
57
+ focusOnOpen = true,
58
+ changedElsewhere,
59
+ onDismissChangedElsewhere,
60
+ }: TaskEditorProps) {
61
+ const copy = useCopy();
62
+ const ids = useId();
63
+ const heading = useRef<HTMLHeadingElement>(null);
64
+ const [error, setError] = useState<string | undefined>();
65
+ const [confirmDelete, setConfirmDelete] = useState(false);
66
+ const stages = (directory.workflows ?? []).flatMap((workflow) => workflow.stages ?? []);
67
+ const [conflict, setConflict] = useState<{ conflicts: TaskConflict[]; changes: TaskInput } | undefined>();
68
+
69
+ useEffect(() => {
70
+ if (focusOnOpen) heading.current?.focus();
71
+ setError(undefined);
72
+ setConfirmDelete(false);
73
+ setConflict(undefined);
74
+ }, [task.urn, focusOnOpen]);
75
+
76
+ const save = async (changes: TaskInput) => {
77
+ setError(undefined);
78
+ try {
79
+ await onChange(changes);
80
+ } catch (failure) {
81
+ if (failure instanceof TaskConflictError) {
82
+ // Builds on a conflict already open, so choices cover every field this person changed.
83
+ setConflict((open) => ({
84
+ conflicts: [
85
+ ...(open?.conflicts.filter((c) => !failure.conflicts.some((f) => f.field === c.field)) ?? []),
86
+ ...failure.conflicts,
87
+ ],
88
+ changes: { ...open?.changes, ...changes },
89
+ }));
90
+ return;
91
+ }
92
+ const detail = failure instanceof Error && failure.name === 'TaskValidationError' ? failure.message : undefined;
93
+ setError(detail ? `${copy('editor_save_failed')} ${detail}` : copy('editor_save_failed'));
94
+ }
95
+ };
96
+
97
+ const others = directory.tasks.filter((t) => t.urn !== task.urn);
98
+ const blockerChoices = others.filter(
99
+ (t) =>
100
+ isOpen(t) &&
101
+ !task.blockedBy?.some((b) => b.urn === t.urn) &&
102
+ !wouldCreateCycle(directory.tasks, task.urn, t.urn),
103
+ );
104
+
105
+ return (
106
+ <article aria-labelledby={`${ids}-heading`} className="grid gap-4">
107
+ <header className="flex items-start justify-between gap-2">
108
+ <div className="min-w-0">
109
+ <p className="text-muted-foreground text-xs">{copy('editor_label')}</p>
110
+ <h2
111
+ id={`${ids}-heading`}
112
+ ref={heading}
113
+ tabIndex={-1}
114
+ className="text-lg leading-snug font-bold break-words outline-none"
115
+ >
116
+ {task.title}
117
+ </h2>
118
+ </div>
119
+ <div className="flex shrink-0 items-center gap-2">
120
+ <span aria-live="polite" className="text-muted-foreground text-xs">
121
+ {saving ? copy('editor_saving') : ''}
122
+ </span>
123
+ {onClose && (
124
+ <Button variant="ghost" size="icon-sm" onClick={onClose} aria-label={copy('editor_close')}>
125
+ <X aria-hidden="true" />
126
+ </Button>
127
+ )}
128
+ </div>
129
+ </header>
130
+
131
+ {error && (
132
+ <Alert variant="destructive">
133
+ <AlertDescription>{error}</AlertDescription>
134
+ </Alert>
135
+ )}
136
+
137
+ {conflict && (
138
+ <TaskConflicts
139
+ conflicts={conflict.conflicts}
140
+ directory={directory}
141
+ onResolve={(choices) => {
142
+ const resolved = resolveConflicts(conflict.changes, choices);
143
+ setConflict(undefined);
144
+ if (!isEmptyChange(resolved)) void save(resolved);
145
+ }}
146
+ onDiscard={() => setConflict(undefined)}
147
+ />
148
+ )}
149
+
150
+ {changedElsewhere && changedElsewhere.length > 0 && (
151
+ <ChangedElsewhere fields={changedElsewhere} onDismiss={onDismissChangedElsewhere} />
152
+ )}
153
+
154
+ <TextField
155
+ key={`title-${task.urn}`}
156
+ id={`${ids}-title`}
157
+ label={copy('field_title')}
158
+ value={task.title}
159
+ onCommit={(title) => save({ title })}
160
+ required
161
+ />
162
+
163
+ <div className="grid grid-cols-2 gap-x-3 gap-y-3">
164
+ <Field id={`${ids}-status`} label={copy('field_status')}>
165
+ <ChoiceSelect
166
+ id={`${ids}-status`}
167
+ value={task.status}
168
+ choices={TASK_STATUSES.map((status) => ({ value: status, label: copy(statusCopy(status)) }))}
169
+ onChange={(status) => save({ status: status as TaskStatus })}
170
+ />
171
+ </Field>
172
+ <Field id={`${ids}-priority`} label={copy('field_priority')}>
173
+ <ChoiceSelect
174
+ id={`${ids}-priority`}
175
+ value={task.priority}
176
+ choices={[...TASK_PRIORITIES]
177
+ .reverse()
178
+ .map((priority) => ({ value: priority, label: copy(priorityCopy(priority)) }))}
179
+ onChange={(priority) => save({ priority: priority as TaskPriority })}
180
+ />
181
+ </Field>
182
+ <Field id={`${ids}-assignee`} label={copy('field_assignee')}>
183
+ <ChoiceSelect
184
+ id={`${ids}-assignee`}
185
+ value={task.assignee?.urn ?? NONE}
186
+ choices={[
187
+ { value: NONE, label: copy('option_unassigned') },
188
+ ...withCurrent(directory.people, task.assignee, directory).map((person) => ({
189
+ value: person.urn,
190
+ label: person.name,
191
+ icon: <PersonAvatar person={person} />,
192
+ })),
193
+ ]}
194
+ onChange={(urn) => save({ assignee: urn === NONE ? null : { urn } })}
195
+ />
196
+ </Field>
197
+ <Field id={`${ids}-project`} label={copy('field_project')}>
198
+ <ChoiceSelect
199
+ id={`${ids}-project`}
200
+ value={task.project?.urn ?? NONE}
201
+ choices={[
202
+ { value: NONE, label: copy('option_no_project') },
203
+ ...withCurrent(directory.projects, task.project, directory).map((project) => ({
204
+ value: project.urn,
205
+ label: project.name,
206
+ })),
207
+ ]}
208
+ onChange={(urn) => save({ project: urn === NONE ? null : { urn } })}
209
+ />
210
+ </Field>
211
+ {(directory.workflows?.length || task.workflowStage) && (
212
+ <Field id={`${ids}-stage`} label={copy('field_stage')}>
213
+ <ChoiceSelect
214
+ id={`${ids}-stage`}
215
+ value={task.workflowStage?.urn ?? NONE}
216
+ choices={[
217
+ { value: NONE, label: copy('option_no_stage') },
218
+ // A stage of a workflow the directory doesn't have still shows.
219
+ ...(task.workflowStage && !stages.some((stage) => stage.urn === task.workflowStage?.urn)
220
+ ? [{ value: task.workflowStage.urn, label: nameOf(directory, task.workflowStage) }]
221
+ : []),
222
+ ]}
223
+ groups={(directory.workflows ?? []).map((workflow) => ({
224
+ label: workflow.name,
225
+ choices: workflowStages(workflow).map((stage) => ({ value: stage.urn, label: stage.name })),
226
+ }))}
227
+ onChange={(urn) => save({ workflowStage: urn === NONE ? null : { urn } })}
228
+ />
229
+ </Field>
230
+ )}
231
+ <DueField key={`due-${task.urn}`} id={`${ids}-due`} task={task} onCommit={(dueTimestamp) => save({ dueTimestamp })} />
232
+ <div className="text-muted-foreground grid content-end gap-1 pb-1 text-xs">
233
+ <span>{copy('field_updated')}</span>
234
+ <Timestamp value={task.updatedTimestamp} className="text-foreground" />
235
+ </div>
236
+ </div>
237
+
238
+ <TextField
239
+ key={`description-${task.urn}`}
240
+ id={`${ids}-description`}
241
+ label={copy('field_description')}
242
+ value={task.description}
243
+ onCommit={(description) => save({ description })}
244
+ multiline
245
+ />
246
+
247
+ <section aria-labelledby={`${ids}-blockers`} className="grid gap-2">
248
+ <h3 id={`${ids}-blockers`} className="text-xs font-semibold tracking-wide uppercase">
249
+ {copy('blockers_heading')}
250
+ </h3>
251
+ {(task.blockedBy?.length ?? 0) === 0 ? (
252
+ <p className="text-muted-foreground text-sm">{copy('blockers_none')}</p>
253
+ ) : (
254
+ <ul className="grid gap-1.5">
255
+ {task.blockedBy.map((blocker) => {
256
+ const known = directory.tasks.find((t) => t.urn === blocker.urn);
257
+ const name = nameOf(directory, blocker);
258
+ return (
259
+ <li key={blocker.urn} className="flex items-center gap-2 text-sm">
260
+ {known && <StatusBadge status={known.status} />}
261
+ <span className="min-w-0 flex-1 truncate">{name}</span>
262
+ <Button
263
+ variant="ghost"
264
+ size="icon-xs"
265
+ aria-label={copy('blockers_remove', { name })}
266
+ onClick={() => save({ blockedBy: withoutRef(task.blockedBy, blocker) })}
267
+ >
268
+ <X aria-hidden="true" />
269
+ </Button>
270
+ </li>
271
+ );
272
+ })}
273
+ </ul>
274
+ )}
275
+ {blockerChoices.length > 0 && (
276
+ <ChoiceSelect
277
+ key={`add-blocker-${task.blockedBy?.length ?? 0}`}
278
+ value={NONE}
279
+ label={copy('blockers_add')}
280
+ choices={[
281
+ { value: NONE, label: copy('blockers_add') },
282
+ ...blockerChoices.map((t) => ({ value: t.urn, label: t.title })),
283
+ ]}
284
+ onChange={(urn) => urn !== NONE && save({ blockedBy: withRef(task.blockedBy, urn) })}
285
+ />
286
+ )}
287
+ </section>
288
+
289
+ <Related task={task} directory={directory} onChange={save} />
290
+
291
+ {activity && (
292
+ <>
293
+ <Separator />
294
+ {activity}
295
+ </>
296
+ )}
297
+
298
+ {onDelete && (
299
+ <>
300
+ <Separator />
301
+ {confirmDelete ? (
302
+ <div role="alertdialog" aria-labelledby={`${ids}-delete`} className="grid gap-2">
303
+ <p id={`${ids}-delete`} className="text-sm">
304
+ {copy('task_delete_confirm')}
305
+ </p>
306
+ <div className="flex gap-2">
307
+ <Button variant="destructive" size="sm" onClick={() => onDelete().catch(() => setError(copy('editor_save_failed')))}>
308
+ <Trash2 aria-hidden="true" />
309
+ {copy('task_delete')}
310
+ </Button>
311
+ <Button variant="ghost" size="sm" autoFocus onClick={() => setConfirmDelete(false)}>
312
+ {copy('task_delete_cancel')}
313
+ </Button>
314
+ </div>
315
+ </div>
316
+ ) : (
317
+ <Button variant="ghost" size="sm" className="text-destructive justify-self-start" onClick={() => setConfirmDelete(true)}>
318
+ <Trash2 aria-hidden="true" />
319
+ {copy('task_delete')}
320
+ </Button>
321
+ )}
322
+ </>
323
+ )}
324
+ </article>
325
+ );
326
+ }
327
+
328
+ /** The directory's choices, plus the current value when the directory doesn't list it, so it still shows. */
329
+ function withCurrent(
330
+ choices: readonly { urn: string; name: string }[],
331
+ current: { urn: string; name?: string | null } | null | undefined,
332
+ directory: TaskDirectory,
333
+ ) {
334
+ if (!current || choices.some((choice) => choice.urn === current.urn)) return choices;
335
+ return [...choices, { urn: current.urn, name: nameOf(directory, current) }];
336
+ }
337
+
338
+ /** A text input or textarea that saves when it loses focus (or on Enter for a single line), if the value changed. */
339
+ function TextField({
340
+ id,
341
+ label,
342
+ value,
343
+ onCommit,
344
+ multiline,
345
+ required,
346
+ }: {
347
+ id: string;
348
+ label: string;
349
+ value: string;
350
+ onCommit: (value: string) => void;
351
+ multiline?: boolean;
352
+ required?: boolean;
353
+ }) {
354
+ const [draft, setDraft] = useState(value);
355
+ useEffect(() => setDraft(value), [value]);
356
+ const commit = () => {
357
+ if (draft === value) return;
358
+ if (required && !draft.trim()) return setDraft(value);
359
+ onCommit(draft);
360
+ };
361
+ return (
362
+ <Field id={id} label={label}>
363
+ {multiline ? (
364
+ <Textarea id={id} rows={5} value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={commit} />
365
+ ) : (
366
+ <Input
367
+ id={id}
368
+ value={draft}
369
+ required={required}
370
+ onChange={(e) => setDraft(e.target.value)}
371
+ onBlur={commit}
372
+ onKeyDown={(e) => {
373
+ if (e.key === 'Enter') commit();
374
+ if (e.key === 'Escape') setDraft(value);
375
+ }}
376
+ />
377
+ )}
378
+ </Field>
379
+ );
380
+ }
381
+
382
+ function DueField({ id, task, onCommit }: { id: string; task: Task; onCommit: (due: string | null) => void }) {
383
+ const copy = useCopy();
384
+ const value = toLocalInput(task.dueTimestamp);
385
+ const [draft, setDraft] = useState(value);
386
+ useEffect(() => setDraft(value), [value]);
387
+ const commit = (next: string) => {
388
+ if (next === value) return;
389
+ onCommit(fromLocalInput(next) ?? null);
390
+ };
391
+ return (
392
+ <Field id={id} label={copy('field_due')}>
393
+ <Input
394
+ id={id}
395
+ type="datetime-local"
396
+ className="h-7 text-sm"
397
+ value={draft}
398
+ onChange={(e) => setDraft(e.target.value)}
399
+ onBlur={(e) => commit(e.target.value)}
400
+ />
401
+ </Field>
402
+ );
403
+ }
404
+
405
+ type AssociationField = 'associatedTasks' | 'associatedIdentities' | 'associatedCompanies' | 'associatedEntities';
406
+
407
+ /** The kinds of related item, in the order they're shown, with their icon and the heading for adding one. */
408
+ const ASSOCIATIONS = [
409
+ { field: 'associatedIdentities', Icon: UserRound, heading: 'related_people' },
410
+ { field: 'associatedCompanies', Icon: Building2, heading: 'related_companies' },
411
+ { field: 'associatedEntities', Icon: FileText, heading: 'related_entities' },
412
+ { field: 'associatedTasks', Icon: Link2, heading: 'related_tasks' },
413
+ ] as const satisfies readonly { field: AssociationField; Icon: unknown; heading: TaskCopyName }[];
414
+
415
+ /** The people, companies, content and tasks the task is associated with, each removable, and a picker to add more. */
416
+ function Related({
417
+ task,
418
+ directory,
419
+ onChange,
420
+ }: {
421
+ task: Task;
422
+ directory: TaskDirectory;
423
+ onChange: (changes: TaskInput) => void;
424
+ }) {
425
+ const copy = useCopy();
426
+ const ids = useId();
427
+ const items = ASSOCIATIONS.flatMap(({ field, Icon }) => (task[field] ?? []).map((item) => ({ field, item, Icon })));
428
+ const offered: Record<AssociationField, readonly Named[]> = {
429
+ associatedIdentities: directory.people,
430
+ associatedCompanies: directory.companies ?? [],
431
+ associatedEntities: directory.entities ?? [],
432
+ associatedTasks: directory.tasks.filter((t) => t.urn !== task.urn).map((t) => ({ urn: t.urn, name: t.title })),
433
+ };
434
+ const groups = ASSOCIATIONS.map(({ field, heading }) => ({
435
+ label: copy(heading),
436
+ choices: offered[field]
437
+ .filter((choice) => !(task[field] ?? []).some((item) => item.urn === choice.urn))
438
+ .map((choice) => ({ value: `${field} ${choice.urn}`, label: choice.name })),
439
+ }));
440
+ return (
441
+ <section aria-labelledby={`${ids}-related`} className="grid gap-2">
442
+ <h3 id={`${ids}-related`} className="text-xs font-semibold tracking-wide uppercase">
443
+ {copy('related_heading')}
444
+ </h3>
445
+ {items.length === 0 ? (
446
+ <p className="text-muted-foreground text-sm">{copy('related_none')}</p>
447
+ ) : (
448
+ <ul className="flex flex-wrap gap-1.5 text-xs">
449
+ {items.map(({ field, item, Icon }) => {
450
+ const name = nameOf(directory, item as { urn: string; name?: string | null });
451
+ return (
452
+ <li key={`${field} ${item.urn}`} className="bg-muted inline-flex items-center gap-1 rounded py-0.5 ps-1.5 pe-0.5">
453
+ <Icon aria-hidden="true" className="size-3" />
454
+ {name}
455
+ <Button
456
+ variant="ghost"
457
+ size="icon-xs"
458
+ className="size-5"
459
+ aria-label={copy('related_remove', { name })}
460
+ onClick={() => onChange({ [field]: withoutRef(task[field], item) })}
461
+ >
462
+ <X aria-hidden="true" />
463
+ </Button>
464
+ </li>
465
+ );
466
+ })}
467
+ </ul>
468
+ )}
469
+ {groups.some((group) => group.choices.length > 0) && (
470
+ <ChoiceSelect
471
+ key={`add-related-${items.length}`}
472
+ value={NONE}
473
+ label={copy('related_add')}
474
+ choices={[{ value: NONE, label: copy('related_add') }]}
475
+ groups={groups}
476
+ onChange={(value) => {
477
+ if (value === NONE) return;
478
+ const [field, urn] = value.split(' ') as [AssociationField, string];
479
+ onChange({ [field]: withRef(task[field], urn) });
480
+ }}
481
+ />
482
+ )}
483
+ </section>
484
+ );
485
+ }
@@ -0,0 +1,122 @@
1
+ import { useId } from 'react';
2
+ import type { TaskPriority, TaskStatus } from '@baack-software/baack-ts-client';
3
+ import { Search, X } from 'lucide-react';
4
+ import { urnOf } from '../refs.ts';
5
+ import { TASK_PRIORITIES, TASK_STATUSES, type TaskFilter } from '../tasks.ts';
6
+ import { priorityCopy, statusCopy, useCopy } from './copy.tsx';
7
+ import { ANY, ChoiceSelect, Field, NONE } from './fields.tsx';
8
+ import type { TaskDirectory } from './parts.tsx';
9
+ import { Button } from './ui/button.tsx';
10
+ import { Input } from './ui/input.tsx';
11
+ import { Toggle } from './ui/toggle.tsx';
12
+
13
+ export interface TaskFiltersProps {
14
+ value: TaskFilter;
15
+ onChange: (filter: TaskFilter) => void;
16
+ directory: TaskDirectory;
17
+ /** The signed-in person, for "Assigned to me". */
18
+ me?: string | undefined;
19
+ }
20
+
21
+ function first(list: readonly unknown[] | undefined): string {
22
+ const value = list?.[0];
23
+ if (value === undefined) return ANY;
24
+ if (value === null) return NONE;
25
+ return urnOf(value as string | { urn: string });
26
+ }
27
+
28
+ function without<K extends keyof TaskFilter>(filter: TaskFilter, key: K): TaskFilter {
29
+ const { [key]: _removed, ...rest } = filter;
30
+ return rest;
31
+ }
32
+
33
+ /**
34
+ * A toolbar of filters: text search, status, priority, assignee, project and open only. Controlled: pass the filter
35
+ * and apply it with `filterTasks` or `selectTasks`.
36
+ */
37
+ export function TaskFilters({ value, onChange, directory, me }: TaskFiltersProps) {
38
+ const copy = useCopy();
39
+ const ids = useId();
40
+ const any = { value: ANY, label: copy('filter_any') };
41
+
42
+ const set = <K extends keyof TaskFilter>(key: K, next: TaskFilter[K] | undefined) =>
43
+ onChange(next === undefined ? without(value, key) : { ...value, [key]: next });
44
+ const pick = (selected: string) => (selected === ANY ? undefined : selected === NONE ? [null] : [selected]);
45
+ const active = Object.keys(value).length > 0;
46
+
47
+ return (
48
+ <div role="search" aria-label={copy('filter_label')} className="flex flex-wrap items-end gap-3">
49
+ <div className="relative min-w-48 flex-1">
50
+ <Search aria-hidden="true" className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
51
+ <Input
52
+ type="search"
53
+ aria-label={copy('filter_search')}
54
+ placeholder={copy('filter_search')}
55
+ value={value.text ?? ''}
56
+ onChange={(event) => set('text', event.target.value || undefined)}
57
+ className="pl-8"
58
+ />
59
+ </div>
60
+ <Field id={`${ids}-status`} label={copy('field_status')} className="w-36">
61
+ <ChoiceSelect
62
+ id={`${ids}-status`}
63
+ value={first(value.status)}
64
+ choices={[any, ...TASK_STATUSES.map((status) => ({ value: status, label: copy(statusCopy(status)) }))]}
65
+ onChange={(selected) => set('status', selected === ANY ? undefined : [selected as TaskStatus])}
66
+ />
67
+ </Field>
68
+ <Field id={`${ids}-priority`} label={copy('field_priority')} className="w-32">
69
+ <ChoiceSelect
70
+ id={`${ids}-priority`}
71
+ value={first(value.priority)}
72
+ choices={[
73
+ any,
74
+ ...[...TASK_PRIORITIES].reverse().map((priority) => ({ value: priority, label: copy(priorityCopy(priority)) })),
75
+ ]}
76
+ onChange={(selected) => set('priority', selected === ANY ? undefined : [selected as TaskPriority])}
77
+ />
78
+ </Field>
79
+ <Field id={`${ids}-assignee`} label={copy('field_assignee')} className="w-44">
80
+ <ChoiceSelect
81
+ id={`${ids}-assignee`}
82
+ value={first(value.assignee)}
83
+ choices={[
84
+ any,
85
+ ...(me ? [{ value: me, label: copy('filter_mine') }] : []),
86
+ { value: NONE, label: copy('option_unassigned') },
87
+ ...directory.people.filter((p) => p.urn !== me).map((p) => ({ value: p.urn, label: p.name })),
88
+ ]}
89
+ onChange={(selected) => set('assignee', pick(selected))}
90
+ />
91
+ </Field>
92
+ {directory.projects.length > 0 && (
93
+ <Field id={`${ids}-project`} label={copy('field_project')} className="w-44">
94
+ <ChoiceSelect
95
+ id={`${ids}-project`}
96
+ value={first(value.project)}
97
+ choices={[
98
+ any,
99
+ { value: NONE, label: copy('option_no_project') },
100
+ ...directory.projects.map((p) => ({ value: p.urn, label: p.name })),
101
+ ]}
102
+ onChange={(selected) => set('project', pick(selected))}
103
+ />
104
+ </Field>
105
+ )}
106
+ <Toggle
107
+ variant="outline"
108
+ size="sm"
109
+ pressed={value.open === true}
110
+ onPressedChange={(pressed) => set('open', pressed ? true : undefined)}
111
+ >
112
+ {copy('filter_open')}
113
+ </Toggle>
114
+ {active && (
115
+ <Button variant="ghost" size="sm" onClick={() => onChange({})}>
116
+ <X aria-hidden="true" />
117
+ {copy('filter_clear')}
118
+ </Button>
119
+ )}
120
+ </div>
121
+ );
122
+ }