@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,217 @@
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
+ import type { TaskPriority, TaskStatus } from '@baack-software/baack-ts-client';
3
+ import { TriangleAlert } from 'lucide-react';
4
+ import { cn } from 'cn';
5
+ import type { ConflictChoices, TaskConflict, TaskField, TaskFieldValue } from '../conflicts.ts';
6
+ import { parseTimestamp } from '../time.ts';
7
+ import type { Ref, TaskListField } from '../types.ts';
8
+ import { priorityCopy, statusCopy, useCopy, type TaskCopyName } from './copy.tsx';
9
+ import { nameOf, type TaskDirectory } from './parts.tsx';
10
+ import { Alert, AlertDescription } from './ui/alert.tsx';
11
+ import { Button } from './ui/button.tsx';
12
+ import { Label } from './ui/label.tsx';
13
+ import { RadioGroup, RadioGroupItem } from './ui/radio-group.tsx';
14
+
15
+ const FIELD_COPY: Record<TaskField | TaskListField, TaskCopyName> = {
16
+ title: 'field_title',
17
+ description: 'field_description',
18
+ status: 'field_status',
19
+ priority: 'field_priority',
20
+ dueTimestamp: 'field_due',
21
+ assignee: 'field_assignee',
22
+ project: 'field_project',
23
+ workflowStage: 'field_stage',
24
+ blockedBy: 'blockers_heading',
25
+ blocking: 'field_blocking',
26
+ associatedTasks: 'related_heading',
27
+ associatedEntities: 'related_heading',
28
+ associatedIdentities: 'related_heading',
29
+ associatedCompanies: 'related_heading',
30
+ };
31
+
32
+ /** The copy name of a field's label. */
33
+ export const fieldCopy = (field: TaskField | TaskListField): TaskCopyName => FIELD_COPY[field];
34
+
35
+ export interface TaskConflictsProps {
36
+ conflicts: readonly TaskConflict[];
37
+ directory: TaskDirectory;
38
+ /** Saves with the choices made. Fields left on "keep mine" are saved again; "use theirs" drops the change. */
39
+ onResolve: (choices: ConflictChoices) => void;
40
+ /** Keeps everything the other person saved, dropping this person's conflicting changes. */
41
+ onDiscard: () => void;
42
+ }
43
+
44
+ /**
45
+ * Shown when someone else changed the same fields while this person was editing: each field with both values, and a
46
+ * choice to keep mine or use theirs. Takes focus when it appears.
47
+ */
48
+ export function TaskConflicts({ conflicts, directory, onResolve, onDiscard }: TaskConflictsProps) {
49
+ const copy = useCopy();
50
+ const ids = useId();
51
+ const heading = useRef<HTMLHeadingElement>(null);
52
+ const [choices, setChoices] = useState<ConflictChoices>({});
53
+
54
+ useEffect(() => {
55
+ setChoices({});
56
+ heading.current?.focus();
57
+ }, [conflicts]);
58
+
59
+ return (
60
+ <section
61
+ role="alertdialog"
62
+ aria-labelledby={`${ids}-heading`}
63
+ aria-describedby={`${ids}-intro`}
64
+ className="grid gap-3 rounded-lg border border-amber-500/50 bg-amber-500/5 p-3"
65
+ >
66
+ <div className="grid gap-1">
67
+ <h3
68
+ id={`${ids}-heading`}
69
+ ref={heading}
70
+ tabIndex={-1}
71
+ className="flex items-center gap-1.5 text-sm font-semibold outline-none"
72
+ >
73
+ <TriangleAlert aria-hidden="true" className="size-4 text-amber-600 dark:text-amber-400" />
74
+ {copy('conflict_heading')}
75
+ </h3>
76
+ <p id={`${ids}-intro`} className="text-muted-foreground text-sm">
77
+ {copy('conflict_intro')}
78
+ </p>
79
+ </div>
80
+
81
+ {conflicts.map((conflict) => {
82
+ const id = `${ids}-${conflict.field}`;
83
+ const choice = choices[conflict.field] ?? 'mine';
84
+ return (
85
+ <div key={conflict.field} className="grid gap-1.5">
86
+ <p id={id} className="text-xs font-semibold tracking-wide uppercase">
87
+ {copy(fieldCopy(conflict.field))}
88
+ </p>
89
+ <RadioGroup
90
+ aria-labelledby={id}
91
+ value={choice}
92
+ onValueChange={(value) => setChoices({ ...choices, [conflict.field]: value as 'mine' | 'theirs' })}
93
+ className="gap-1.5"
94
+ >
95
+ {(['mine', 'theirs'] as const).map((side) => (
96
+ <Label
97
+ key={side}
98
+ htmlFor={`${id}-${side}`}
99
+ className={cn(
100
+ 'bg-card items-start gap-2.5 rounded-md border p-2 font-normal',
101
+ choice === side && 'border-primary',
102
+ )}
103
+ >
104
+ {/* The choice names the radio and the value describes it, so they're read apart. */}
105
+ <RadioGroupItem
106
+ id={`${id}-${side}`}
107
+ value={side}
108
+ aria-labelledby={`${id}-${side}-label`}
109
+ aria-describedby={`${id}-${side}-value`}
110
+ className="mt-0.5"
111
+ />
112
+ <span className="grid min-w-0 gap-0.5">
113
+ <span id={`${id}-${side}-label`} className="font-medium">
114
+ {copy(side === 'mine' ? 'conflict_mine' : 'conflict_theirs')}
115
+ </span>
116
+ <FieldValue
117
+ id={`${id}-${side}-value`}
118
+ field={conflict.field}
119
+ value={conflict[side]}
120
+ directory={directory}
121
+ />
122
+ </span>
123
+ </Label>
124
+ ))}
125
+ </RadioGroup>
126
+ </div>
127
+ );
128
+ })}
129
+
130
+ <div className="flex flex-wrap gap-2">
131
+ <Button size="sm" onClick={() => onResolve(choices)}>
132
+ {copy('conflict_save')}
133
+ </Button>
134
+ <Button size="sm" variant="ghost" onClick={onDiscard}>
135
+ {copy('conflict_discard')}
136
+ </Button>
137
+ </div>
138
+ </section>
139
+ );
140
+ }
141
+
142
+ /** A field's value as the reader would say it: names for references, labels for status and priority. */
143
+ function FieldValue({
144
+ id,
145
+ field,
146
+ value,
147
+ directory,
148
+ }: {
149
+ id: string;
150
+ field: TaskField;
151
+ value: TaskFieldValue;
152
+ directory: TaskDirectory;
153
+ }) {
154
+ const copy = useCopy();
155
+ const empty = value == null || value === '';
156
+ let text: string;
157
+ switch (field) {
158
+ case 'status':
159
+ text = empty ? copy('value_empty') : copy(statusCopy(value as TaskStatus));
160
+ break;
161
+ case 'priority':
162
+ text = empty ? copy('value_empty') : copy(priorityCopy(value as TaskPriority));
163
+ break;
164
+ case 'assignee':
165
+ text = empty ? copy('option_unassigned') : nameOf(directory, value as Ref);
166
+ break;
167
+ case 'project':
168
+ text = empty ? copy('option_no_project') : nameOf(directory, value as Ref);
169
+ break;
170
+ case 'workflowStage':
171
+ text = empty ? copy('value_empty') : nameOf(directory, value as Ref);
172
+ break;
173
+ case 'dueTimestamp': {
174
+ const due = parseTimestamp(value as string | null | undefined);
175
+ text = due ? due.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : copy('due_none');
176
+ break;
177
+ }
178
+ default:
179
+ text = empty ? copy('value_empty') : (value as string);
180
+ }
181
+ return (
182
+ <span
183
+ id={id}
184
+ className={cn(
185
+ 'text-muted-foreground text-sm break-words',
186
+ field === 'description' && 'max-h-32 overflow-y-auto whitespace-pre-wrap',
187
+ empty && 'italic',
188
+ )}
189
+ >
190
+ {text}
191
+ </span>
192
+ );
193
+ }
194
+
195
+ export interface ChangedElsewhereProps {
196
+ fields: readonly (TaskField | TaskListField)[];
197
+ onDismiss?: (() => void) | undefined;
198
+ }
199
+
200
+ /** A note that someone else changed other fields while this person was editing, which were kept. */
201
+ export function ChangedElsewhere({ fields, onDismiss }: ChangedElsewhereProps) {
202
+ const copy = useCopy();
203
+ const names = [...new Set(fields.map((field) => copy(fieldCopy(field))))];
204
+ const list = new Intl.ListFormat(undefined, { type: 'conjunction' }).format(names);
205
+ return (
206
+ <Alert>
207
+ <AlertDescription className="flex flex-wrap items-center justify-between gap-2">
208
+ <span>{copy('changed_elsewhere', { fields: list })}</span>
209
+ {onDismiss && (
210
+ <Button size="xs" variant="ghost" onClick={onDismiss}>
211
+ {copy('changed_elsewhere_dismiss')}
212
+ </Button>
213
+ )}
214
+ </AlertDescription>
215
+ </Alert>
216
+ );
217
+ }
@@ -0,0 +1,165 @@
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
+ import type { TaskPriority } from '@baack-software/baack-ts-client';
3
+ import { Plus, X } from 'lucide-react';
4
+ import type { DraftInput } from '../draft.ts';
5
+ import { TASK_PRIORITIES } from '../tasks.ts';
6
+ import { fromLocalInput } from '../time.ts';
7
+ import { priorityCopy, useCopy } from './copy.tsx';
8
+ import { ChoiceSelect, Field, NONE } from './fields.tsx';
9
+ import { PersonAvatar, type TaskDirectory } from './parts.tsx';
10
+ import { Alert, AlertDescription } from './ui/alert.tsx';
11
+ import { Button } from './ui/button.tsx';
12
+ import { Input } from './ui/input.tsx';
13
+ import { Textarea } from './ui/textarea.tsx';
14
+
15
+ export type NewTask = Omit<DraftInput, 'owner'>;
16
+
17
+ export interface TaskCreateFormProps {
18
+ directory: TaskDirectory;
19
+ onCreate: (task: NewTask) => Promise<unknown>;
20
+ onCancel?: (() => void) | undefined;
21
+ /** Starting values, e.g. the project or assignee the view is filtered to. */
22
+ initial?: Partial<NewTask>;
23
+ }
24
+
25
+ /** A form for a new task: title (required), description, priority, assignee, project and due date. */
26
+ export function TaskCreateForm({ directory, onCreate, onCancel, initial }: TaskCreateFormProps) {
27
+ const copy = useCopy();
28
+ const ids = useId();
29
+ const title = useRef<HTMLInputElement>(null);
30
+ const [values, setValues] = useState({
31
+ title: initial?.title ?? '',
32
+ description: initial?.description ?? '',
33
+ priority: initial?.priority ?? ('NONE' as TaskPriority),
34
+ assignee: initial?.assignee?.urn ?? NONE,
35
+ project: initial?.project?.urn ?? NONE,
36
+ due: '',
37
+ });
38
+ const [sending, setSending] = useState(false);
39
+ const [failed, setFailed] = useState(false);
40
+ useEffect(() => title.current?.focus(), []);
41
+
42
+ const set = <K extends keyof typeof values>(key: K, value: (typeof values)[K]) =>
43
+ setValues((current) => ({ ...current, [key]: value }));
44
+
45
+ return (
46
+ <form
47
+ aria-labelledby={`${ids}-heading`}
48
+ className="grid gap-4"
49
+ onSubmit={async (event) => {
50
+ event.preventDefault();
51
+ if (!values.title.trim()) return;
52
+ setSending(true);
53
+ setFailed(false);
54
+ const due = fromLocalInput(values.due);
55
+ try {
56
+ await onCreate({
57
+ ...initial,
58
+ title: values.title.trim(),
59
+ description: values.description,
60
+ priority: values.priority,
61
+ ...(values.assignee === NONE ? {} : { assignee: { urn: values.assignee } }),
62
+ ...(values.project === NONE ? {} : { project: { urn: values.project } }),
63
+ ...(due === undefined ? {} : { dueTimestamp: due }),
64
+ });
65
+ } catch {
66
+ setFailed(true);
67
+ } finally {
68
+ setSending(false);
69
+ }
70
+ }}
71
+ >
72
+ <header className="flex items-center justify-between gap-2">
73
+ <h2 id={`${ids}-heading`} className="text-lg font-bold">
74
+ {copy('create_heading')}
75
+ </h2>
76
+ {onCancel && (
77
+ <Button type="button" variant="ghost" size="icon-sm" onClick={onCancel} aria-label={copy('create_cancel')}>
78
+ <X aria-hidden="true" />
79
+ </Button>
80
+ )}
81
+ </header>
82
+ {failed && (
83
+ <Alert variant="destructive">
84
+ <AlertDescription>{copy('create_failed')}</AlertDescription>
85
+ </Alert>
86
+ )}
87
+ <Field id={`${ids}-title`} label={copy('field_title')}>
88
+ <Input
89
+ ref={title}
90
+ id={`${ids}-title`}
91
+ required
92
+ maxLength={1024}
93
+ value={values.title}
94
+ onChange={(e) => set('title', e.target.value)}
95
+ />
96
+ </Field>
97
+ <Field id={`${ids}-description`} label={copy('field_description')}>
98
+ <Textarea
99
+ id={`${ids}-description`}
100
+ rows={4}
101
+ value={values.description}
102
+ onChange={(e) => set('description', e.target.value)}
103
+ />
104
+ </Field>
105
+ <div className="grid grid-cols-2 gap-3">
106
+ <Field id={`${ids}-priority`} label={copy('field_priority')}>
107
+ <ChoiceSelect
108
+ id={`${ids}-priority`}
109
+ value={values.priority}
110
+ choices={[...TASK_PRIORITIES]
111
+ .reverse()
112
+ .map((priority) => ({ value: priority, label: copy(priorityCopy(priority)) }))}
113
+ onChange={(priority) => set('priority', priority as TaskPriority)}
114
+ />
115
+ </Field>
116
+ <Field id={`${ids}-due`} label={copy('field_due')}>
117
+ <Input
118
+ id={`${ids}-due`}
119
+ type="datetime-local"
120
+ className="h-7 text-sm"
121
+ value={values.due}
122
+ onChange={(e) => set('due', e.target.value)}
123
+ />
124
+ </Field>
125
+ <Field id={`${ids}-assignee`} label={copy('field_assignee')}>
126
+ <ChoiceSelect
127
+ id={`${ids}-assignee`}
128
+ value={values.assignee}
129
+ choices={[
130
+ { value: NONE, label: copy('option_unassigned') },
131
+ ...directory.people.map((person) => ({
132
+ value: person.urn,
133
+ label: person.name,
134
+ icon: <PersonAvatar person={person} />,
135
+ })),
136
+ ]}
137
+ onChange={(urn) => set('assignee', urn)}
138
+ />
139
+ </Field>
140
+ <Field id={`${ids}-project`} label={copy('field_project')}>
141
+ <ChoiceSelect
142
+ id={`${ids}-project`}
143
+ value={values.project}
144
+ choices={[
145
+ { value: NONE, label: copy('option_no_project') },
146
+ ...directory.projects.map((project) => ({ value: project.urn, label: project.name })),
147
+ ]}
148
+ onChange={(urn) => set('project', urn)}
149
+ />
150
+ </Field>
151
+ </div>
152
+ <div className="flex gap-2">
153
+ <Button type="submit" disabled={!values.title.trim() || sending}>
154
+ <Plus aria-hidden="true" />
155
+ {copy('create_submit')}
156
+ </Button>
157
+ {onCancel && (
158
+ <Button type="button" variant="ghost" onClick={onCancel}>
159
+ {copy('create_cancel')}
160
+ </Button>
161
+ )}
162
+ </div>
163
+ </form>
164
+ );
165
+ }