@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.
- package/LICENSE +201 -0
- package/README.md +158 -2
- package/dist/activity.d.ts +20 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +75 -0
- package/dist/activity.js.map +1 -0
- package/dist/blockers.d.ts +28 -0
- package/dist/blockers.d.ts.map +1 -0
- package/dist/blockers.js +134 -0
- package/dist/blockers.js.map +1 -0
- package/dist/conflicts.d.ts +50 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +73 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/draft.d.ts +45 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +155 -0
- package/dist/draft.js.map +1 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/react/TaskActivity.d.ts +15 -0
- package/dist/react/TaskActivity.d.ts.map +1 -0
- package/dist/react/TaskActivity.js +60 -0
- package/dist/react/TaskActivity.js.map +1 -0
- package/dist/react/TaskBoard.d.ts +38 -0
- package/dist/react/TaskBoard.d.ts.map +1 -0
- package/dist/react/TaskBoard.js +85 -0
- package/dist/react/TaskBoard.js.map +1 -0
- package/dist/react/TaskConflicts.d.ts +26 -0
- package/dist/react/TaskConflicts.d.ts.map +1 -0
- package/dist/react/TaskConflicts.js +87 -0
- package/dist/react/TaskConflicts.js.map +1 -0
- package/dist/react/TaskCreateForm.d.ts +13 -0
- package/dist/react/TaskCreateForm.d.ts.map +1 -0
- package/dist/react/TaskCreateForm.js +68 -0
- package/dist/react/TaskCreateForm.js.map +1 -0
- package/dist/react/TaskEditor.d.ts +33 -0
- package/dist/react/TaskEditor.d.ts.map +1 -0
- package/dist/react/TaskEditor.js +172 -0
- package/dist/react/TaskEditor.js.map +1 -0
- package/dist/react/TaskFilters.d.ts +15 -0
- package/dist/react/TaskFilters.d.ts.map +1 -0
- package/dist/react/TaskFilters.js +48 -0
- package/dist/react/TaskFilters.js.map +1 -0
- package/dist/react/TaskView.d.ts +30 -0
- package/dist/react/TaskView.d.ts.map +1 -0
- package/dist/react/TaskView.js +108 -0
- package/dist/react/TaskView.js.map +1 -0
- package/dist/react/TasksAbout.d.ts +24 -0
- package/dist/react/TasksAbout.d.ts.map +1 -0
- package/dist/react/TasksAbout.js +82 -0
- package/dist/react/TasksAbout.js.map +1 -0
- package/dist/react/copy.d.ts +154 -0
- package/dist/react/copy.d.ts.map +1 -0
- package/dist/react/copy.js +173 -0
- package/dist/react/copy.js.map +1 -0
- package/dist/react/fields.d.ts +36 -0
- package/dist/react/fields.d.ts.map +1 -0
- package/dist/react/fields.js +21 -0
- package/dist/react/fields.js.map +1 -0
- package/dist/react/hooks.d.ts +9 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +31 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +13 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/parts.d.ts +55 -0
- package/dist/react/parts.d.ts.map +1 -0
- package/dist/react/parts.js +92 -0
- package/dist/react/parts.js.map +1 -0
- package/dist/react/ui/alert.d.ts +11 -0
- package/dist/react/ui/alert.d.ts.map +1 -0
- package/dist/react/ui/alert.js +29 -0
- package/dist/react/ui/alert.js.map +1 -0
- package/dist/react/ui/avatar.d.ts +12 -0
- package/dist/react/ui/avatar.d.ts.map +1 -0
- package/dist/react/ui/avatar.js +24 -0
- package/dist/react/ui/avatar.js.map +1 -0
- package/dist/react/ui/badge.d.ts +10 -0
- package/dist/react/ui/badge.d.ts.map +1 -0
- package/dist/react/ui/badge.js +26 -0
- package/dist/react/ui/badge.js.map +1 -0
- package/dist/react/ui/button.d.ts +11 -0
- package/dist/react/ui/button.d.ts.map +1 -0
- package/dist/react/ui/button.js +37 -0
- package/dist/react/ui/button.js.map +1 -0
- package/dist/react/ui/card.d.ts +12 -0
- package/dist/react/ui/card.d.ts.map +1 -0
- package/dist/react/ui/card.js +26 -0
- package/dist/react/ui/card.js.map +1 -0
- package/dist/react/ui/input.d.ts +4 -0
- package/dist/react/ui/input.d.ts.map +1 -0
- package/dist/react/ui/input.js +8 -0
- package/dist/react/ui/input.js.map +1 -0
- package/dist/react/ui/label.d.ts +5 -0
- package/dist/react/ui/label.d.ts.map +1 -0
- package/dist/react/ui/label.js +10 -0
- package/dist/react/ui/label.js.map +1 -0
- package/dist/react/ui/radio-group.d.ts +6 -0
- package/dist/react/ui/radio-group.d.ts.map +1 -0
- package/dist/react/ui/radio-group.js +12 -0
- package/dist/react/ui/radio-group.js.map +1 -0
- package/dist/react/ui/select.d.ts +16 -0
- package/dist/react/ui/select.d.ts.map +1 -0
- package/dist/react/ui/select.js +37 -0
- package/dist/react/ui/select.js.map +1 -0
- package/dist/react/ui/separator.d.ts +5 -0
- package/dist/react/ui/separator.d.ts.map +1 -0
- package/dist/react/ui/separator.js +10 -0
- package/dist/react/ui/separator.js.map +1 -0
- package/dist/react/ui/sheet.d.ts +15 -0
- package/dist/react/ui/sheet.d.ts.map +1 -0
- package/dist/react/ui/sheet.js +38 -0
- package/dist/react/ui/sheet.js.map +1 -0
- package/dist/react/ui/skeleton.d.ts +3 -0
- package/dist/react/ui/skeleton.d.ts.map +1 -0
- package/dist/react/ui/skeleton.js +7 -0
- package/dist/react/ui/skeleton.js.map +1 -0
- package/dist/react/ui/table.d.ts +11 -0
- package/dist/react/ui/table.d.ts.map +1 -0
- package/dist/react/ui/table.js +29 -0
- package/dist/react/ui/table.js.map +1 -0
- package/dist/react/ui/tabs.d.ts +12 -0
- package/dist/react/ui/tabs.d.ts.map +1 -0
- package/dist/react/ui/tabs.js +31 -0
- package/dist/react/ui/tabs.js.map +1 -0
- package/dist/react/ui/textarea.d.ts +4 -0
- package/dist/react/ui/textarea.d.ts.map +1 -0
- package/dist/react/ui/textarea.js +8 -0
- package/dist/react/ui/textarea.js.map +1 -0
- package/dist/react/ui/toggle-group.d.ts +11 -0
- package/dist/react/ui/toggle-group.d.ts.map +1 -0
- package/dist/react/ui/toggle-group.js +25 -0
- package/dist/react/ui/toggle-group.js.map +1 -0
- package/dist/react/ui/toggle.d.ts +10 -0
- package/dist/react/ui/toggle.d.ts.map +1 -0
- package/dist/react/ui/toggle.js +27 -0
- package/dist/react/ui/toggle.js.map +1 -0
- package/dist/refs.d.ts +16 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +43 -0
- package/dist/refs.js.map +1 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/recipes.d.ts +39 -0
- package/dist/server/recipes.d.ts.map +1 -0
- package/dist/server/recipes.js +81 -0
- package/dist/server/recipes.js.map +1 -0
- package/dist/server/webhooks.d.ts +135 -0
- package/dist/server/webhooks.d.ts.map +1 -0
- package/dist/server/webhooks.js +243 -0
- package/dist/server/webhooks.js.map +1 -0
- package/dist/store.d.ts +124 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +443 -0
- package/dist/store.js.map +1 -0
- package/dist/task-client.d.ts +137 -0
- package/dist/task-client.d.ts.map +1 -0
- package/dist/task-client.js +271 -0
- package/dist/task-client.js.map +1 -0
- package/dist/tasks.d.ts +84 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +208 -0
- package/dist/tasks.js.map +1 -0
- package/dist/time.d.ts +15 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +34 -0
- package/dist/time.js.map +1 -0
- package/dist/types.d.ts +74 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +101 -4
- package/src/activity.ts +86 -0
- package/src/blockers.ts +134 -0
- package/src/conflicts.ts +115 -0
- package/src/draft.ts +162 -0
- package/src/errors.ts +40 -0
- package/src/index.ts +10 -0
- package/src/react/TaskActivity.tsx +187 -0
- package/src/react/TaskBoard.tsx +264 -0
- package/src/react/TaskConflicts.tsx +217 -0
- package/src/react/TaskCreateForm.tsx +165 -0
- package/src/react/TaskEditor.tsx +485 -0
- package/src/react/TaskFilters.tsx +122 -0
- package/src/react/TaskView.tsx +291 -0
- package/src/react/TasksAbout.tsx +226 -0
- package/src/react/copy.tsx +253 -0
- package/src/react/fields.tsx +104 -0
- package/src/react/hooks.ts +41 -0
- package/src/react/index.ts +12 -0
- package/src/react/parts.tsx +176 -0
- package/src/react/ui/README.md +6 -0
- package/src/react/ui/alert.tsx +75 -0
- package/src/react/ui/avatar.tsx +109 -0
- package/src/react/ui/badge.tsx +48 -0
- package/src/react/ui/button.tsx +66 -0
- package/src/react/ui/card.tsx +102 -0
- package/src/react/ui/input.tsx +18 -0
- package/src/react/ui/label.tsx +23 -0
- package/src/react/ui/radio-group.tsx +41 -0
- package/src/react/ui/select.tsx +189 -0
- package/src/react/ui/separator.tsx +27 -0
- package/src/react/ui/sheet.tsx +145 -0
- package/src/react/ui/skeleton.tsx +13 -0
- package/src/react/ui/table.tsx +113 -0
- package/src/react/ui/tabs.tsx +89 -0
- package/src/react/ui/textarea.tsx +17 -0
- package/src/react/ui/toggle-group.tsx +89 -0
- package/src/react/ui/toggle.tsx +44 -0
- package/src/refs.ts +49 -0
- package/src/server/index.ts +3 -0
- package/src/server/recipes.ts +107 -0
- package/src/server/webhooks.ts +369 -0
- package/src/store.ts +538 -0
- package/src/task-client.ts +373 -0
- package/src/tasks.ts +263 -0
- package/src/time.ts +35 -0
- 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
|
+
}
|