@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,291 @@
|
|
|
1
|
+
import { useEffect, useId, useMemo, useState } from 'react';
|
|
2
|
+
import type { Task } from '@baack-software/baack-ts-client';
|
|
3
|
+
import { Columns3, Plus, Rows3 } from 'lucide-react';
|
|
4
|
+
import { cn } from 'cn';
|
|
5
|
+
import { selectTasks, type TaskStore } from '../store.ts';
|
|
6
|
+
import { byPriorityThenDue, byUrgency, type TaskFilter, type TaskGrouping } from '../tasks.ts';
|
|
7
|
+
import { useCopy } from './copy.tsx';
|
|
8
|
+
import { ChoiceSelect } from './fields.tsx';
|
|
9
|
+
import { useMediaQuery, useTaskDirectory, useTaskStore } from './hooks.ts';
|
|
10
|
+
import { TaskActivity } from './TaskActivity.tsx';
|
|
11
|
+
import { TaskBoard, TaskTable } from './TaskBoard.tsx';
|
|
12
|
+
import { TaskCreateForm } from './TaskCreateForm.tsx';
|
|
13
|
+
import { TaskEditor } from './TaskEditor.tsx';
|
|
14
|
+
import { TaskFilters } from './TaskFilters.tsx';
|
|
15
|
+
import type { TaskDirectory } from './parts.tsx';
|
|
16
|
+
import { Alert, AlertDescription } from './ui/alert.tsx';
|
|
17
|
+
import { Button } from './ui/button.tsx';
|
|
18
|
+
import { Sheet, SheetContent, SheetTitle } from './ui/sheet.tsx';
|
|
19
|
+
import { Skeleton } from './ui/skeleton.tsx';
|
|
20
|
+
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx';
|
|
21
|
+
|
|
22
|
+
export type TaskLayout = 'board' | 'table';
|
|
23
|
+
|
|
24
|
+
export interface TaskViewProps {
|
|
25
|
+
store: TaskStore;
|
|
26
|
+
/** The signed-in person's identity URN, for "Assigned to me". */
|
|
27
|
+
me?: string | undefined;
|
|
28
|
+
/** Starting layout. Default board. */
|
|
29
|
+
layout?: TaskLayout;
|
|
30
|
+
/**
|
|
31
|
+
* Starting board columns. Default status. `workflow` has a column per workflow; `workflowStage` shows the first
|
|
32
|
+
* workflow's stages. Each column is sorted open first, then by priority, due date and title.
|
|
33
|
+
*/
|
|
34
|
+
groupBy?: TaskGrouping;
|
|
35
|
+
/** Starting filter. */
|
|
36
|
+
filter?: TaskFilter;
|
|
37
|
+
/** Opens this task on load, e.g. from the URL. */
|
|
38
|
+
selected?: string | undefined;
|
|
39
|
+
/** Told when the open task changes, e.g. to put it in the URL. */
|
|
40
|
+
onSelect?: ((urn: string | undefined) => void) | undefined;
|
|
41
|
+
/** Companies and content to offer as related items, which the store doesn't load. */
|
|
42
|
+
related?: Pick<TaskDirectory, 'companies' | 'entities'> | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const GROUPINGS: readonly Exclude<TaskGrouping, 'workflow' | 'workflowStage'>[] = [
|
|
46
|
+
'status',
|
|
47
|
+
'priority',
|
|
48
|
+
'assignee',
|
|
49
|
+
'project',
|
|
50
|
+
];
|
|
51
|
+
/** Prefixes a workflow URN in the grouping choice. */
|
|
52
|
+
const STAGE = 'stage:';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The full task view: a filterable board or table of a company's tasks, with the open task's editor and activity log
|
|
56
|
+
* beside it, and a form for new tasks. Loads the store when it hasn't been loaded.
|
|
57
|
+
*/
|
|
58
|
+
export function TaskView({
|
|
59
|
+
store,
|
|
60
|
+
me,
|
|
61
|
+
layout: initialLayout = 'board',
|
|
62
|
+
groupBy: initialGroupBy = 'status',
|
|
63
|
+
filter: initialFilter = {},
|
|
64
|
+
selected: initialSelected,
|
|
65
|
+
onSelect,
|
|
66
|
+
related,
|
|
67
|
+
}: TaskViewProps) {
|
|
68
|
+
const copy = useCopy();
|
|
69
|
+
const ids = useId();
|
|
70
|
+
const state = useTaskStore(store);
|
|
71
|
+
const loaded = useTaskDirectory(state);
|
|
72
|
+
const directory = useMemo(
|
|
73
|
+
() => ({ ...loaded, companies: related?.companies, entities: related?.entities }),
|
|
74
|
+
[loaded, related?.companies, related?.entities],
|
|
75
|
+
);
|
|
76
|
+
const [layout, setLayout] = useState<TaskLayout>(initialLayout);
|
|
77
|
+
const [groupBy, setGroupBy] = useState<TaskGrouping>(initialGroupBy);
|
|
78
|
+
const [workflowUrn, setWorkflowUrn] = useState<string | undefined>();
|
|
79
|
+
const workflow = directory.workflows?.find((w) => w.urn === workflowUrn) ?? directory.workflows?.[0];
|
|
80
|
+
const [filter, setFilter] = useState<TaskFilter>(initialFilter);
|
|
81
|
+
const [selected, setSelected] = useState<string | undefined>(initialSelected);
|
|
82
|
+
const [creating, setCreating] = useState(false);
|
|
83
|
+
const [announcement, setAnnouncement] = useState('');
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (store.getState().status === 'idle') store.load().catch(() => undefined);
|
|
87
|
+
}, [store]);
|
|
88
|
+
|
|
89
|
+
const task = selected ? state.byUrn.get(selected) : undefined;
|
|
90
|
+
const logs = selected ? state.logs.get(selected) : undefined;
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (selected && store.getState().logs.get(selected) === undefined && store.get(selected)) {
|
|
94
|
+
store.loadLogs(selected).catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
}, [store, selected, task !== undefined]);
|
|
97
|
+
|
|
98
|
+
const itemId = (urn: string) => `${ids}-task-${urn}`;
|
|
99
|
+
const select = (urn: string | undefined) => {
|
|
100
|
+
setSelected(urn);
|
|
101
|
+
setCreating(false);
|
|
102
|
+
onSelect?.(urn);
|
|
103
|
+
};
|
|
104
|
+
const close = () => {
|
|
105
|
+
const from = selected;
|
|
106
|
+
select(undefined);
|
|
107
|
+
if (from) requestAnimationFrame(() => document.getElementById(itemId(from))?.focus());
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const wide = useMediaQuery('(min-width: 64rem)');
|
|
111
|
+
const visible = useMemo(() => selectTasks(state, filter, byUrgency), [state, filter]);
|
|
112
|
+
const list = {
|
|
113
|
+
tasks: visible,
|
|
114
|
+
directory,
|
|
115
|
+
selected,
|
|
116
|
+
itemId,
|
|
117
|
+
onOpen: (t: Task) => select(t.urn),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const panel = (
|
|
121
|
+
<>
|
|
122
|
+
{creating ? (
|
|
123
|
+
<TaskCreateForm
|
|
124
|
+
directory={directory}
|
|
125
|
+
onCancel={() => setCreating(false)}
|
|
126
|
+
onCreate={async (input) => {
|
|
127
|
+
const created = await store.create(input);
|
|
128
|
+
setAnnouncement(created.title);
|
|
129
|
+
select(created.urn);
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
) : (
|
|
133
|
+
task && (
|
|
134
|
+
<TaskEditor
|
|
135
|
+
task={task}
|
|
136
|
+
directory={directory}
|
|
137
|
+
saving={state.saving.has(task.urn)}
|
|
138
|
+
onChange={(changes) => store.update(task.urn, changes)}
|
|
139
|
+
onDelete={async () => {
|
|
140
|
+
await store.delete(task.urn);
|
|
141
|
+
select(undefined);
|
|
142
|
+
}}
|
|
143
|
+
onClose={close}
|
|
144
|
+
changedElsewhere={state.changedElsewhere.get(task.urn)}
|
|
145
|
+
onDismissChangedElsewhere={() => store.dismissChangedElsewhere(task.urn)}
|
|
146
|
+
activity={
|
|
147
|
+
<TaskActivity
|
|
148
|
+
logs={logs?.logs ?? []}
|
|
149
|
+
directory={directory}
|
|
150
|
+
loading={logs?.status === 'loading'}
|
|
151
|
+
onLoadMore={logs?.after ? () => store.loadMoreLogs(task.urn).catch(() => undefined) : undefined}
|
|
152
|
+
onComment={(text, replyTo) =>
|
|
153
|
+
store.comment(task.urn, text, replyTo ? { replyTo: replyTo.urn } : {})
|
|
154
|
+
}
|
|
155
|
+
/>
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
)}
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="grid gap-4">
|
|
165
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
166
|
+
<h2 className="mr-auto text-xl font-bold">
|
|
167
|
+
{copy('tasks_heading')}
|
|
168
|
+
{state.status === 'ready' && (
|
|
169
|
+
<span className="text-muted-foreground ml-2 text-sm font-normal tabular-nums">
|
|
170
|
+
{visible.length === 1 ? copy('tasks_count_one') : copy('tasks_count', { count: visible.length })}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</h2>
|
|
174
|
+
<ToggleGroup
|
|
175
|
+
type="single"
|
|
176
|
+
variant="outline"
|
|
177
|
+
size="sm"
|
|
178
|
+
spacing={0}
|
|
179
|
+
value={layout}
|
|
180
|
+
onValueChange={(value) => value && setLayout(value as TaskLayout)}
|
|
181
|
+
aria-label={copy('tasks_view')}
|
|
182
|
+
>
|
|
183
|
+
<ToggleGroupItem value="board">
|
|
184
|
+
<Columns3 aria-hidden="true" />
|
|
185
|
+
{copy('tasks_view_board')}
|
|
186
|
+
</ToggleGroupItem>
|
|
187
|
+
<ToggleGroupItem value="table">
|
|
188
|
+
<Rows3 aria-hidden="true" />
|
|
189
|
+
{copy('tasks_view_table')}
|
|
190
|
+
</ToggleGroupItem>
|
|
191
|
+
</ToggleGroup>
|
|
192
|
+
{layout === 'board' && (
|
|
193
|
+
<div className="w-36">
|
|
194
|
+
<ChoiceSelect
|
|
195
|
+
value={groupBy === 'workflowStage' && workflow ? `${STAGE}${workflow.urn}` : groupBy}
|
|
196
|
+
label={copy('tasks_group_by')}
|
|
197
|
+
choices={[
|
|
198
|
+
...GROUPINGS.map((grouping) => ({ value: grouping, label: copy(`tasks_group_${grouping}`) })),
|
|
199
|
+
// Workflows as columns, then one choice per workflow for its stages as columns.
|
|
200
|
+
...(directory.workflows?.length ? [{ value: 'workflow', label: copy('tasks_group_workflow') }] : []),
|
|
201
|
+
...(directory.workflows ?? []).map((w) => ({
|
|
202
|
+
value: `${STAGE}${w.urn}`,
|
|
203
|
+
label: copy('tasks_group_stages', { workflow: w.name }),
|
|
204
|
+
})),
|
|
205
|
+
]}
|
|
206
|
+
onChange={(value) => {
|
|
207
|
+
if (value.startsWith(STAGE)) {
|
|
208
|
+
setGroupBy('workflowStage');
|
|
209
|
+
setWorkflowUrn(value.slice(STAGE.length));
|
|
210
|
+
} else setGroupBy(value as TaskGrouping);
|
|
211
|
+
}}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
<Button
|
|
216
|
+
size="sm"
|
|
217
|
+
onClick={() => {
|
|
218
|
+
setSelected(undefined);
|
|
219
|
+
setCreating(true);
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
<Plus aria-hidden="true" />
|
|
223
|
+
{copy('tasks_new')}
|
|
224
|
+
</Button>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<TaskFilters value={filter} onChange={setFilter} directory={directory} me={me} />
|
|
228
|
+
|
|
229
|
+
<div
|
|
230
|
+
className={cn(
|
|
231
|
+
'grid grid-cols-1 items-start gap-4',
|
|
232
|
+
(creating || task) && wide && 'grid-cols-[minmax(0,1fr)_minmax(22rem,28rem)]',
|
|
233
|
+
)}
|
|
234
|
+
>
|
|
235
|
+
<div className="min-w-0">
|
|
236
|
+
{state.status === 'error' && (
|
|
237
|
+
<Alert variant="destructive">
|
|
238
|
+
<AlertDescription className="flex flex-wrap items-center gap-2">
|
|
239
|
+
{copy('tasks_load_failed')}
|
|
240
|
+
<Button variant="outline" size="sm" onClick={() => store.load().catch(() => undefined)}>
|
|
241
|
+
{copy('tasks_retry')}
|
|
242
|
+
</Button>
|
|
243
|
+
</AlertDescription>
|
|
244
|
+
</Alert>
|
|
245
|
+
)}
|
|
246
|
+
{(state.status === 'loading' || state.status === 'idle') && state.tasks.length === 0 ? (
|
|
247
|
+
<div aria-busy="true" className="grid gap-2">
|
|
248
|
+
<span className="sr-only">{copy('tasks_loading')}</span>
|
|
249
|
+
{[0, 1, 2].map((row) => (
|
|
250
|
+
<Skeleton key={row} className="h-16 w-full" />
|
|
251
|
+
))}
|
|
252
|
+
</div>
|
|
253
|
+
) : state.status === 'ready' && visible.length === 0 ? (
|
|
254
|
+
<p className="text-muted-foreground rounded-xl border border-dashed p-8 text-center text-sm">
|
|
255
|
+
{state.tasks.length === 0 ? copy('tasks_none') : copy('tasks_empty')}
|
|
256
|
+
</p>
|
|
257
|
+
) : layout === 'board' ? (
|
|
258
|
+
<TaskBoard {...list} groupBy={groupBy} workflow={workflow} sort={byPriorityThenDue} />
|
|
259
|
+
) : (
|
|
260
|
+
<TaskTable {...list} />
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{(creating || task) && wide && (
|
|
265
|
+
<aside className="bg-card sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto rounded-xl border p-4">
|
|
266
|
+
{panel}
|
|
267
|
+
</aside>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
{!wide && (
|
|
271
|
+
<Sheet open={creating || task !== undefined} onOpenChange={(open) => !open && (creating ? setCreating(false) : close())}>
|
|
272
|
+
<SheetContent
|
|
273
|
+
side="right"
|
|
274
|
+
showCloseButton={false}
|
|
275
|
+
aria-describedby={undefined}
|
|
276
|
+
className="w-full overflow-y-auto p-4 sm:max-w-md"
|
|
277
|
+
// The editor and form focus their own heading or first field, and closing focuses the task's card.
|
|
278
|
+
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
279
|
+
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
280
|
+
>
|
|
281
|
+
<SheetTitle className="sr-only">{creating ? copy('tasks_new') : copy('editor_label')}</SheetTitle>
|
|
282
|
+
{panel}
|
|
283
|
+
</SheetContent>
|
|
284
|
+
</Sheet>
|
|
285
|
+
)}
|
|
286
|
+
<p aria-live="polite" className="sr-only">
|
|
287
|
+
{announcement}
|
|
288
|
+
</p>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { Task } from '@baack-software/baack-ts-client';
|
|
3
|
+
import { CircleCheck, Plus } from 'lucide-react';
|
|
4
|
+
import { selectTasks, type TaskStore } from '../store.ts';
|
|
5
|
+
import { byUrgency, isOpen, type TaskFilter } from '../tasks.ts';
|
|
6
|
+
import type { Ref } from '../types.ts';
|
|
7
|
+
import { useCopy } from './copy.tsx';
|
|
8
|
+
import { useTaskDirectory, useTaskStore } from './hooks.ts';
|
|
9
|
+
import { DueDate, nameOf, PersonAvatar, StatusBadge } 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 { Skeleton } from './ui/skeleton.tsx';
|
|
14
|
+
|
|
15
|
+
/** What a page is about, and so which tasks it shows. */
|
|
16
|
+
export type TaskSubjectKind = 'identity' | 'company' | 'entity' | 'task' | 'project';
|
|
17
|
+
|
|
18
|
+
export interface TaskSubject {
|
|
19
|
+
kind: TaskSubjectKind;
|
|
20
|
+
urn: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TasksAboutProps {
|
|
24
|
+
store: TaskStore;
|
|
25
|
+
/** The person, company, content, task or project the page is about. */
|
|
26
|
+
about: TaskSubject;
|
|
27
|
+
/** Opens a task, e.g. in the full task view. Without it, titles aren't links. */
|
|
28
|
+
onOpen?: ((task: Task) => void) | undefined;
|
|
29
|
+
/** The heading's level, to fit the page's outline. Default 2. */
|
|
30
|
+
headingLevel?: 2 | 3 | 4;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The new task's fields that tie it to the subject. */
|
|
34
|
+
function subjectFields(about: TaskSubject): { project: Ref } | Record<string, Ref[]> {
|
|
35
|
+
const ref = { urn: about.urn };
|
|
36
|
+
switch (about.kind) {
|
|
37
|
+
case 'project':
|
|
38
|
+
return { project: ref };
|
|
39
|
+
case 'identity':
|
|
40
|
+
return { associatedIdentities: [ref] };
|
|
41
|
+
case 'company':
|
|
42
|
+
return { associatedCompanies: [ref] };
|
|
43
|
+
case 'entity':
|
|
44
|
+
return { associatedEntities: [ref] };
|
|
45
|
+
case 'task':
|
|
46
|
+
return { associatedTasks: [ref] };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The tasks about one thing, for a console page about it: open tasks, most urgent first, then completed ones on
|
|
52
|
+
* request, and a quick-add whose task is related to it. The API can't search by association yet, so this filters the
|
|
53
|
+
* store's tasks; share one store across a page's panels. Loads the store when it hasn't been loaded.
|
|
54
|
+
*/
|
|
55
|
+
export function TasksAbout({ store, about, onOpen, headingLevel = 2 }: TasksAboutProps) {
|
|
56
|
+
const copy = useCopy();
|
|
57
|
+
const ids = useId();
|
|
58
|
+
const state = useTaskStore(store);
|
|
59
|
+
const directory = useTaskDirectory(state);
|
|
60
|
+
const input = useRef<HTMLInputElement>(null);
|
|
61
|
+
const [title, setTitle] = useState('');
|
|
62
|
+
const [adding, setAdding] = useState(false);
|
|
63
|
+
const [failed, setFailed] = useState(false);
|
|
64
|
+
const [showDone, setShowDone] = useState(false);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (store.getState().status === 'idle') store.load().catch(() => undefined);
|
|
68
|
+
}, [store]);
|
|
69
|
+
|
|
70
|
+
const filter: TaskFilter = about.kind === 'project' ? { project: [about.urn] } : { relatedTo: about.urn };
|
|
71
|
+
const tasks = useMemo(() => selectTasks(state, filter, byUrgency), [state, about.kind, about.urn]);
|
|
72
|
+
const open = tasks.filter(isOpen);
|
|
73
|
+
const done = tasks.filter((task) => !isOpen(task));
|
|
74
|
+
const Heading = `h${headingLevel}` as const;
|
|
75
|
+
|
|
76
|
+
const add = async () => {
|
|
77
|
+
if (!title.trim()) return;
|
|
78
|
+
setAdding(true);
|
|
79
|
+
setFailed(false);
|
|
80
|
+
try {
|
|
81
|
+
await store.create({ title: title.trim(), ...subjectFields(about) });
|
|
82
|
+
setTitle('');
|
|
83
|
+
} catch {
|
|
84
|
+
setFailed(true);
|
|
85
|
+
} finally {
|
|
86
|
+
setAdding(false);
|
|
87
|
+
input.current?.focus();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<section aria-labelledby={`${ids}-heading`} className="bg-card grid gap-3 rounded-xl border p-4">
|
|
93
|
+
<Heading id={`${ids}-heading`} className="flex items-baseline gap-2 text-base font-semibold">
|
|
94
|
+
{copy('about_heading')}{' '}
|
|
95
|
+
{state.status === 'ready' && (
|
|
96
|
+
<span className="text-muted-foreground text-sm font-normal tabular-nums">
|
|
97
|
+
{copy('about_open_count', { count: open.length })}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
</Heading>
|
|
101
|
+
|
|
102
|
+
<form
|
|
103
|
+
className="flex gap-2"
|
|
104
|
+
onSubmit={(event) => {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
void add();
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<Input
|
|
110
|
+
ref={input}
|
|
111
|
+
value={title}
|
|
112
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
113
|
+
placeholder={copy('about_add_placeholder')}
|
|
114
|
+
aria-label={copy('about_add_label')}
|
|
115
|
+
disabled={adding}
|
|
116
|
+
className="h-8"
|
|
117
|
+
/>
|
|
118
|
+
<Button type="submit" size="sm" disabled={adding || !title.trim()}>
|
|
119
|
+
<Plus aria-hidden="true" />
|
|
120
|
+
{copy('about_add')}
|
|
121
|
+
</Button>
|
|
122
|
+
</form>
|
|
123
|
+
{failed && (
|
|
124
|
+
<Alert variant="destructive">
|
|
125
|
+
<AlertDescription>{copy('about_add_failed')}</AlertDescription>
|
|
126
|
+
</Alert>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{state.status === 'error' && (
|
|
130
|
+
<Alert variant="destructive">
|
|
131
|
+
<AlertDescription className="flex flex-wrap items-center gap-2">
|
|
132
|
+
{copy('tasks_load_failed')}
|
|
133
|
+
<Button variant="outline" size="sm" onClick={() => store.load().catch(() => undefined)}>
|
|
134
|
+
{copy('tasks_retry')}
|
|
135
|
+
</Button>
|
|
136
|
+
</AlertDescription>
|
|
137
|
+
</Alert>
|
|
138
|
+
)}
|
|
139
|
+
{(state.status === 'loading' || state.status === 'idle') && state.tasks.length === 0 ? (
|
|
140
|
+
<div aria-busy="true" className="grid gap-2">
|
|
141
|
+
<span className="sr-only">{copy('tasks_loading')}</span>
|
|
142
|
+
{[0, 1].map((row) => (
|
|
143
|
+
<Skeleton key={row} className="h-10 w-full" />
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
) : (
|
|
147
|
+
state.status !== 'error' && (
|
|
148
|
+
<>
|
|
149
|
+
{open.length === 0 ? (
|
|
150
|
+
<p className="text-muted-foreground text-sm">{copy('about_none')}</p>
|
|
151
|
+
) : (
|
|
152
|
+
<TaskRows tasks={open} store={store} onOpen={onOpen} directory={directory} />
|
|
153
|
+
)}
|
|
154
|
+
{done.length > 0 && (
|
|
155
|
+
<>
|
|
156
|
+
<Button
|
|
157
|
+
variant="ghost"
|
|
158
|
+
size="sm"
|
|
159
|
+
className="justify-self-start"
|
|
160
|
+
aria-expanded={showDone}
|
|
161
|
+
aria-controls={`${ids}-done`}
|
|
162
|
+
onClick={() => setShowDone(!showDone)}
|
|
163
|
+
>
|
|
164
|
+
{showDone ? copy('about_hide_done') : copy('about_show_done', { count: done.length })}
|
|
165
|
+
</Button>
|
|
166
|
+
<div id={`${ids}-done`} hidden={!showDone}>
|
|
167
|
+
{showDone && <TaskRows tasks={done} store={store} onOpen={onOpen} directory={directory} />}
|
|
168
|
+
</div>
|
|
169
|
+
</>
|
|
170
|
+
)}
|
|
171
|
+
</>
|
|
172
|
+
)
|
|
173
|
+
)}
|
|
174
|
+
</section>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function TaskRows({
|
|
179
|
+
tasks,
|
|
180
|
+
store,
|
|
181
|
+
onOpen,
|
|
182
|
+
directory,
|
|
183
|
+
}: {
|
|
184
|
+
tasks: readonly Task[];
|
|
185
|
+
store: TaskStore;
|
|
186
|
+
onOpen: ((task: Task) => void) | undefined;
|
|
187
|
+
directory: ReturnType<typeof useTaskDirectory>;
|
|
188
|
+
}) {
|
|
189
|
+
const copy = useCopy();
|
|
190
|
+
return (
|
|
191
|
+
<ul className="divide-border grid divide-y">
|
|
192
|
+
{tasks.map((task) => {
|
|
193
|
+
const assignee = task.assignee ? { urn: task.assignee.urn, name: nameOf(directory, task.assignee) } : null;
|
|
194
|
+
return (
|
|
195
|
+
<li key={task.urn} className="flex min-w-0 items-center gap-2 py-2 text-sm">
|
|
196
|
+
{isOpen(task) ? (
|
|
197
|
+
<Button
|
|
198
|
+
variant="ghost"
|
|
199
|
+
size="icon-xs"
|
|
200
|
+
aria-label={copy('about_complete', { name: task.title })}
|
|
201
|
+
onClick={() => store.setStatus(task.urn, 'COMPLETED').catch(() => undefined)}
|
|
202
|
+
>
|
|
203
|
+
<CircleCheck aria-hidden="true" />
|
|
204
|
+
</Button>
|
|
205
|
+
) : (
|
|
206
|
+
<StatusBadge status={task.status} />
|
|
207
|
+
)}
|
|
208
|
+
{onOpen ? (
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
onClick={() => onOpen(task)}
|
|
212
|
+
className="hover:text-primary min-w-0 flex-1 truncate text-left font-medium hover:underline"
|
|
213
|
+
>
|
|
214
|
+
{task.title}
|
|
215
|
+
</button>
|
|
216
|
+
) : (
|
|
217
|
+
<span className="min-w-0 flex-1 truncate font-medium">{task.title}</span>
|
|
218
|
+
)}
|
|
219
|
+
<DueDate task={task} />
|
|
220
|
+
<PersonAvatar person={assignee} />
|
|
221
|
+
</li>
|
|
222
|
+
);
|
|
223
|
+
})}
|
|
224
|
+
</ul>
|
|
225
|
+
);
|
|
226
|
+
}
|