@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
package/src/draft.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { Task } from '@baack-software/baack-ts-client';
|
|
2
|
+
import { ref, refs, sameRef, sameRefs, urnOf } from './refs.ts';
|
|
3
|
+
import { parseTimestamp } from './time.ts';
|
|
4
|
+
import { TASK_LIST_FIELDS, type RefLike, type TaskDraft, type TaskInput, type TaskUpdate } from './types.ts';
|
|
5
|
+
|
|
6
|
+
export const TITLE_MAX_LENGTH = 1024;
|
|
7
|
+
export const DESCRIPTION_MAX_LENGTH = 65536;
|
|
8
|
+
|
|
9
|
+
/** What `draft` needs: an owner and a title. Everything else has a default. */
|
|
10
|
+
export type DraftInput = Omit<Partial<TaskDraft>, 'owner' | 'title'> & {
|
|
11
|
+
owner: RefLike;
|
|
12
|
+
title: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A new task ready to create: status `PENDING`, priority `NONE`, an empty description and empty lists unless given.
|
|
17
|
+
* References are reduced to bare `{ urn }`.
|
|
18
|
+
*/
|
|
19
|
+
export function draft(input: DraftInput): TaskDraft {
|
|
20
|
+
const { owner, ...rest } = input;
|
|
21
|
+
const result: TaskDraft = {
|
|
22
|
+
status: 'PENDING',
|
|
23
|
+
priority: 'NONE',
|
|
24
|
+
description: '',
|
|
25
|
+
...rest,
|
|
26
|
+
owner: ref(owner),
|
|
27
|
+
blockedBy: refs(rest.blockedBy),
|
|
28
|
+
blocking: refs(rest.blocking),
|
|
29
|
+
associatedTasks: refs(rest.associatedTasks),
|
|
30
|
+
associatedEntities: refs(rest.associatedEntities),
|
|
31
|
+
associatedIdentities: refs(rest.associatedIdentities),
|
|
32
|
+
associatedCompanies: refs(rest.associatedCompanies),
|
|
33
|
+
};
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ValidationIssue {
|
|
38
|
+
field: keyof TaskInput;
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Checks a create or update body against the API's documented rules, so a form can show problems before sending.
|
|
44
|
+
* Pass the task's URN when updating, to catch a task blocking itself.
|
|
45
|
+
*/
|
|
46
|
+
export function validateTask(input: TaskInput, options: { creating?: boolean; urn?: string } = {}): ValidationIssue[] {
|
|
47
|
+
const issues: ValidationIssue[] = [];
|
|
48
|
+
const { creating = false, urn } = options;
|
|
49
|
+
|
|
50
|
+
if (creating && !input.owner) issues.push({ field: 'owner', message: 'A task needs an owning company.' });
|
|
51
|
+
if (input.title !== undefined || creating) {
|
|
52
|
+
const title = input.title?.trim() ?? '';
|
|
53
|
+
if (title === '') issues.push({ field: 'title', message: 'A task needs a title.' });
|
|
54
|
+
else if (title.length > TITLE_MAX_LENGTH)
|
|
55
|
+
issues.push({ field: 'title', message: `The title is longer than ${TITLE_MAX_LENGTH} characters.` });
|
|
56
|
+
}
|
|
57
|
+
if (input.description !== undefined && input.description.length > DESCRIPTION_MAX_LENGTH) {
|
|
58
|
+
issues.push({
|
|
59
|
+
field: 'description',
|
|
60
|
+
message: `The description is longer than ${DESCRIPTION_MAX_LENGTH} characters.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (input.dueTimestamp != null && !parseTimestamp(input.dueTimestamp)) {
|
|
64
|
+
issues.push({ field: 'dueTimestamp', message: 'The due date is not a valid timestamp.' });
|
|
65
|
+
}
|
|
66
|
+
if (urn !== undefined) {
|
|
67
|
+
if (input.blockedBy?.some((item) => item.urn === urn))
|
|
68
|
+
issues.push({ field: 'blockedBy', message: 'A task cannot block itself.' });
|
|
69
|
+
if (input.blocking?.some((item) => item.urn === urn))
|
|
70
|
+
issues.push({ field: 'blocking', message: 'A task cannot block itself.' });
|
|
71
|
+
}
|
|
72
|
+
const blockedBy = new Set((input.blockedBy ?? []).map(urnOf));
|
|
73
|
+
if (input.blocking?.some((item) => blockedBy.has(item.urn))) {
|
|
74
|
+
issues.push({ field: 'blocking', message: 'A task cannot both block and be blocked by the same task.' });
|
|
75
|
+
}
|
|
76
|
+
return issues;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The update body that turns `before` into `after`: only the fields that differ. Lists count as changed when they hold
|
|
81
|
+
* different references, regardless of order, and are sent whole. Returns `{}` when nothing changed.
|
|
82
|
+
*/
|
|
83
|
+
export function taskChanges(before: Task, after: Task | TaskInput): TaskInput {
|
|
84
|
+
const changes: TaskInput = {};
|
|
85
|
+
|
|
86
|
+
for (const field of ['status', 'priority', 'title', 'description'] as const) {
|
|
87
|
+
const value = after[field];
|
|
88
|
+
if (value !== undefined && value !== before[field]) (changes as Record<string, unknown>)[field] = value;
|
|
89
|
+
}
|
|
90
|
+
if (after.dueTimestamp !== undefined && !sameTime(before.dueTimestamp, after.dueTimestamp)) {
|
|
91
|
+
changes.dueTimestamp = after.dueTimestamp || null;
|
|
92
|
+
}
|
|
93
|
+
for (const field of ['assignee', 'project', 'workflowStage'] as const) {
|
|
94
|
+
const value = after[field];
|
|
95
|
+
if (value !== undefined && !sameRef(before[field], value)) changes[field] = value === null ? null : ref(value);
|
|
96
|
+
}
|
|
97
|
+
for (const field of TASK_LIST_FIELDS) {
|
|
98
|
+
const value = after[field];
|
|
99
|
+
if (value !== undefined && !sameRefs(before[field], value)) changes[field] = refs(value);
|
|
100
|
+
}
|
|
101
|
+
return changes;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The task as it will look once `changes` are applied, for showing a change before the server confirms it. References
|
|
106
|
+
* keep only what the change carries (usually just the URN), so names need looking up separately.
|
|
107
|
+
*/
|
|
108
|
+
export function applyChanges(task: Task, changes: TaskInput): Task {
|
|
109
|
+
const result: Task = { ...task };
|
|
110
|
+
for (const field of ['status', 'priority', 'title', 'description'] as const) {
|
|
111
|
+
if (changes[field] !== undefined) (result as unknown as Record<string, unknown>)[field] = changes[field];
|
|
112
|
+
}
|
|
113
|
+
if (changes.dueTimestamp !== undefined) result.dueTimestamp = changes.dueTimestamp ?? '';
|
|
114
|
+
for (const field of ['assignee', 'project', 'workflowStage', ...TASK_LIST_FIELDS] as const) {
|
|
115
|
+
const value = changes[field];
|
|
116
|
+
if (value !== undefined) (result as unknown as Record<string, unknown>)[field] = value;
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The update body that stores `task` with `changes` applied. The API stores the task as an update sends it: required
|
|
123
|
+
* fields must be present and optional fields left out are cleared, so the body carries the whole task. A `null` change
|
|
124
|
+
* clears a field. References are reduced to bare `{ urn }`.
|
|
125
|
+
*/
|
|
126
|
+
export function updateBody(task: Task, changes: TaskInput): TaskUpdate {
|
|
127
|
+
const owner = changes.owner ?? task.owner;
|
|
128
|
+
if (!owner) throw new TypeError(`Task ${task.urn} has no owner, which every update must send.`);
|
|
129
|
+
const body: TaskUpdate = {
|
|
130
|
+
urn: task.urn,
|
|
131
|
+
owner: ref(owner),
|
|
132
|
+
status: changes.status ?? task.status,
|
|
133
|
+
priority: changes.priority ?? task.priority,
|
|
134
|
+
title: changes.title ?? task.title,
|
|
135
|
+
description: changes.description ?? task.description ?? '',
|
|
136
|
+
blockedBy: refs(changes.blockedBy ?? task.blockedBy),
|
|
137
|
+
blocking: refs(changes.blocking ?? task.blocking),
|
|
138
|
+
associatedTasks: refs(changes.associatedTasks ?? task.associatedTasks),
|
|
139
|
+
associatedEntities: refs(changes.associatedEntities ?? task.associatedEntities),
|
|
140
|
+
associatedIdentities: refs(changes.associatedIdentities ?? task.associatedIdentities),
|
|
141
|
+
associatedCompanies: refs(changes.associatedCompanies ?? task.associatedCompanies),
|
|
142
|
+
};
|
|
143
|
+
for (const field of ['assignee', 'project', 'workflowStage'] as const) {
|
|
144
|
+
const value = changes[field] === undefined ? task[field] : changes[field];
|
|
145
|
+
if (value) body[field] = ref(value);
|
|
146
|
+
else if (changes[field] === null) body[field] = null;
|
|
147
|
+
}
|
|
148
|
+
const due = changes.dueTimestamp === undefined ? task.dueTimestamp : changes.dueTimestamp;
|
|
149
|
+
if (due) body.dueTimestamp = due;
|
|
150
|
+
else if (changes.dueTimestamp !== undefined) body.dueTimestamp = null;
|
|
151
|
+
if (changes.identityForLogs) body.identityForLogs = ref(changes.identityForLogs);
|
|
152
|
+
return body;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether a body has nothing to send. */
|
|
156
|
+
export function isEmptyChange(input: TaskInput): boolean {
|
|
157
|
+
return Object.keys(input).every((key) => key === 'identityForLogs');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sameTime(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
161
|
+
return parseTimestamp(a)?.getTime() === parseTimestamp(b)?.getTime();
|
|
162
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Task } from '@baack-software/baack-ts-client';
|
|
2
|
+
import type { TaskConflict } from './conflicts.ts';
|
|
3
|
+
import type { ValidationIssue } from './draft.ts';
|
|
4
|
+
|
|
5
|
+
/** Thrown before sending a create or update the API would reject. */
|
|
6
|
+
export class TaskValidationError extends Error {
|
|
7
|
+
override readonly name = 'TaskValidationError';
|
|
8
|
+
|
|
9
|
+
constructor(readonly issues: ValidationIssue[]) {
|
|
10
|
+
super(issues.map((issue) => issue.message).join(' '));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Thrown when a search hasn't reached its full result in the time allowed. */
|
|
15
|
+
export class TaskSearchTimeoutError extends Error {
|
|
16
|
+
override readonly name = 'TaskSearchTimeoutError';
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
readonly searchUrn: string | undefined,
|
|
20
|
+
readonly timeoutMs: number,
|
|
21
|
+
) {
|
|
22
|
+
super(`Search ${searchUrn ?? ''} did not finish within ${timeoutMs} ms`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Thrown before writing when someone else changed a field this update also changes, since the base the change was
|
|
28
|
+
* made against. Nothing was saved. Resolve with `resolveConflicts` and update again with `current` as the base.
|
|
29
|
+
*/
|
|
30
|
+
export class TaskConflictError extends Error {
|
|
31
|
+
override readonly name = 'TaskConflictError';
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
readonly conflicts: TaskConflict[],
|
|
35
|
+
/** The task as it is now. */
|
|
36
|
+
readonly current: Task,
|
|
37
|
+
) {
|
|
38
|
+
super(`Someone else changed ${conflicts.map((conflict) => conflict.field).join(', ')} on task ${current.urn}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types.ts';
|
|
2
|
+
export * from './refs.ts';
|
|
3
|
+
export * from './time.ts';
|
|
4
|
+
export * from './tasks.ts';
|
|
5
|
+
export * from './draft.ts';
|
|
6
|
+
export * from './conflicts.ts';
|
|
7
|
+
export * from './blockers.ts';
|
|
8
|
+
export * from './activity.ts';
|
|
9
|
+
export * from './errors.ts';
|
|
10
|
+
export * from './task-client.ts';
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useId, useState } from 'react';
|
|
2
|
+
import type { TaskLog } from '@baack-software/baack-ts-client';
|
|
3
|
+
import { MessageSquare, Reply } from 'lucide-react';
|
|
4
|
+
import { activityLabel, isComment } from '../activity.ts';
|
|
5
|
+
import { activityCopy, useCopy } from './copy.tsx';
|
|
6
|
+
import { nameOf, PersonAvatar, Timestamp, type TaskDirectory } from './parts.tsx';
|
|
7
|
+
import { Button } from './ui/button.tsx';
|
|
8
|
+
import { Label } from './ui/label.tsx';
|
|
9
|
+
import { Textarea } from './ui/textarea.tsx';
|
|
10
|
+
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx';
|
|
11
|
+
|
|
12
|
+
type Show = 'all' | 'changes' | 'comments';
|
|
13
|
+
|
|
14
|
+
export interface TaskActivityProps {
|
|
15
|
+
/** Newest first. */
|
|
16
|
+
logs: readonly TaskLog[];
|
|
17
|
+
directory: TaskDirectory;
|
|
18
|
+
loading?: boolean;
|
|
19
|
+
/** Set when older entries can be loaded. */
|
|
20
|
+
onLoadMore?: (() => void) | undefined;
|
|
21
|
+
/** Adds a comment, optionally as a reply. Omit for a read-only log. */
|
|
22
|
+
onComment?: ((text: string, replyTo?: TaskLog) => Promise<unknown>) | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A task's activity log, newest first, with a comment box and replies. */
|
|
26
|
+
export function TaskActivity({ logs, directory, loading, onLoadMore, onComment }: TaskActivityProps) {
|
|
27
|
+
const copy = useCopy();
|
|
28
|
+
const [show, setShow] = useState<Show>('all');
|
|
29
|
+
const [replyTo, setReplyTo] = useState<TaskLog | undefined>();
|
|
30
|
+
const byUrn = new Map(logs.map((log) => [log.urn, log]));
|
|
31
|
+
const shown = logs.filter((log) => show === 'all' || isComment(log) === (show === 'comments'));
|
|
32
|
+
const author = (log: TaskLog) => (log.identity ? nameOf(directory, log.identity) : undefined);
|
|
33
|
+
|
|
34
|
+
const describe = (log: TaskLog): string => {
|
|
35
|
+
const name = activityCopy(log.activity);
|
|
36
|
+
const label = name ? copy(name) : activityLabel(log.activity);
|
|
37
|
+
return log.subject && log.activity !== 'COMMENT_ADDED' ? `${label}: ${nameOf(directory, log.subject)}` : label;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<section aria-label={copy('activity_heading')}>
|
|
42
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
43
|
+
<h3 className="text-xs font-semibold tracking-wide uppercase">{copy('activity_heading')}</h3>
|
|
44
|
+
<ToggleGroup
|
|
45
|
+
type="single"
|
|
46
|
+
variant="outline"
|
|
47
|
+
size="sm"
|
|
48
|
+
spacing={0}
|
|
49
|
+
value={show}
|
|
50
|
+
onValueChange={(value) => value && setShow(value as Show)}
|
|
51
|
+
aria-label={copy('activity_filter')}
|
|
52
|
+
>
|
|
53
|
+
<ToggleGroupItem value="all">{copy('activity_filter_all')}</ToggleGroupItem>
|
|
54
|
+
<ToggleGroupItem value="changes">{copy('activity_filter_changes')}</ToggleGroupItem>
|
|
55
|
+
<ToggleGroupItem value="comments">{copy('activity_filter_comments')}</ToggleGroupItem>
|
|
56
|
+
</ToggleGroup>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{onComment && (
|
|
60
|
+
<CommentForm
|
|
61
|
+
key={replyTo?.urn ?? 'new'}
|
|
62
|
+
replyTo={replyTo}
|
|
63
|
+
replyName={replyTo ? (author(replyTo) ?? '') : undefined}
|
|
64
|
+
onCancelReply={() => setReplyTo(undefined)}
|
|
65
|
+
onSubmit={async (text) => {
|
|
66
|
+
await onComment(text, replyTo);
|
|
67
|
+
setReplyTo(undefined);
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{shown.length === 0 && !loading && <p className="text-muted-foreground mt-3 text-sm">{copy('activity_empty')}</p>}
|
|
73
|
+
<ul className="mt-3 space-y-3" aria-busy={loading || undefined}>
|
|
74
|
+
{shown.map((log) => {
|
|
75
|
+
const name = author(log);
|
|
76
|
+
const parent = log.replyTo ? byUrn.get(log.replyTo.urn) : undefined;
|
|
77
|
+
return (
|
|
78
|
+
<li key={log.urn} className="flex gap-2.5" data-activity={log.activity}>
|
|
79
|
+
<PersonAvatar person={log.identity && name ? { urn: log.identity.urn, name } : null} />
|
|
80
|
+
<div className="min-w-0 flex-1">
|
|
81
|
+
<p className="text-[13px] leading-snug">
|
|
82
|
+
<span className="font-medium">{describe(log)}</span>
|
|
83
|
+
{name && <span className="text-muted-foreground"> {copy('activity_by', { name })}</span>}
|
|
84
|
+
{log.createdTimestamp && (
|
|
85
|
+
<span className="text-muted-foreground">
|
|
86
|
+
{' · '}
|
|
87
|
+
<Timestamp value={log.createdTimestamp} />
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
</p>
|
|
91
|
+
{parent && (
|
|
92
|
+
<p className="text-muted-foreground mt-0.5 flex items-center gap-1 text-[11px]">
|
|
93
|
+
<Reply aria-hidden="true" className="size-3" />
|
|
94
|
+
{copy('comment_reply_label', { name: author(parent) ?? '' })}
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
{log.comment && (
|
|
98
|
+
<p className="bg-muted mt-1 rounded-md px-2.5 py-1.5 text-[13px] whitespace-pre-wrap">{log.comment}</p>
|
|
99
|
+
)}
|
|
100
|
+
{onComment && isComment(log) && (
|
|
101
|
+
<Button variant="link" size="xs" className="h-auto px-0" onClick={() => setReplyTo(log)}>
|
|
102
|
+
{copy('comment_reply')}
|
|
103
|
+
</Button>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
</li>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</ul>
|
|
110
|
+
{loading && <p className="text-muted-foreground mt-3 text-sm">{copy('activity_loading')}</p>}
|
|
111
|
+
{onLoadMore && !loading && (
|
|
112
|
+
<Button variant="outline" size="sm" className="mt-3" onClick={onLoadMore}>
|
|
113
|
+
{copy('activity_more')}
|
|
114
|
+
</Button>
|
|
115
|
+
)}
|
|
116
|
+
</section>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function CommentForm({
|
|
121
|
+
replyTo,
|
|
122
|
+
replyName,
|
|
123
|
+
onCancelReply,
|
|
124
|
+
onSubmit,
|
|
125
|
+
}: {
|
|
126
|
+
replyTo: TaskLog | undefined;
|
|
127
|
+
replyName: string | undefined;
|
|
128
|
+
onCancelReply: () => void;
|
|
129
|
+
onSubmit: (text: string) => Promise<void>;
|
|
130
|
+
}) {
|
|
131
|
+
const copy = useCopy();
|
|
132
|
+
const id = useId();
|
|
133
|
+
const [text, setText] = useState('');
|
|
134
|
+
const [sending, setSending] = useState(false);
|
|
135
|
+
const [failed, setFailed] = useState(false);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<form
|
|
139
|
+
className="mt-3 grid gap-2"
|
|
140
|
+
onSubmit={async (event) => {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
if (!text.trim()) return;
|
|
143
|
+
setSending(true);
|
|
144
|
+
setFailed(false);
|
|
145
|
+
try {
|
|
146
|
+
await onSubmit(text.trim());
|
|
147
|
+
setText('');
|
|
148
|
+
} catch {
|
|
149
|
+
setFailed(true);
|
|
150
|
+
} finally {
|
|
151
|
+
setSending(false);
|
|
152
|
+
}
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<Label htmlFor={id} className={replyTo ? 'text-xs' : 'sr-only'}>
|
|
156
|
+
{replyTo ? copy('comment_reply_label', { name: replyName ?? '' }) : copy('comment_label')}
|
|
157
|
+
</Label>
|
|
158
|
+
<Textarea
|
|
159
|
+
id={id}
|
|
160
|
+
rows={2}
|
|
161
|
+
autoFocus={replyTo !== undefined}
|
|
162
|
+
placeholder={copy('comment_placeholder')}
|
|
163
|
+
value={text}
|
|
164
|
+
onChange={(event) => setText(event.target.value)}
|
|
165
|
+
onKeyDown={(event) => {
|
|
166
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) event.currentTarget.form?.requestSubmit();
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
<div className="flex items-center gap-2">
|
|
170
|
+
<Button type="submit" size="sm" variant="outline" disabled={!text.trim() || sending}>
|
|
171
|
+
<MessageSquare aria-hidden="true" />
|
|
172
|
+
{replyTo ? copy('comment_reply') : copy('comment_submit')}
|
|
173
|
+
</Button>
|
|
174
|
+
{replyTo && (
|
|
175
|
+
<Button type="button" size="sm" variant="ghost" onClick={onCancelReply}>
|
|
176
|
+
{copy('comment_cancel')}
|
|
177
|
+
</Button>
|
|
178
|
+
)}
|
|
179
|
+
{failed && (
|
|
180
|
+
<p role="alert" className="text-destructive text-xs">
|
|
181
|
+
{copy('editor_save_failed')}
|
|
182
|
+
</p>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</form>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useId } from 'react';
|
|
2
|
+
import type { Task, TaskPriority, TaskStatus, Workflow } from '@baack-software/baack-ts-client';
|
|
3
|
+
import { cn } from 'cn';
|
|
4
|
+
import { lookupFrom, openBlockers } from '../blockers.ts';
|
|
5
|
+
import {
|
|
6
|
+
groupByStage,
|
|
7
|
+
groupByWorkflow,
|
|
8
|
+
groupTasks,
|
|
9
|
+
isOverdue,
|
|
10
|
+
NONE_KEY,
|
|
11
|
+
type TaskComparator,
|
|
12
|
+
type TaskGrouping,
|
|
13
|
+
} from '../tasks.ts';
|
|
14
|
+
import { parseTimestamp } from '../time.ts';
|
|
15
|
+
import { priorityCopy, statusCopy, useCopy } from './copy.tsx';
|
|
16
|
+
import { BlockedBadge, DueDate, nameOf, PersonAvatar, PriorityBadge, StatusBadge, type TaskDirectory } from './parts.tsx';
|
|
17
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table.tsx';
|
|
18
|
+
|
|
19
|
+
export interface TaskListProps {
|
|
20
|
+
tasks: readonly Task[];
|
|
21
|
+
directory: TaskDirectory;
|
|
22
|
+
/** The task open in the editor, highlighted. */
|
|
23
|
+
selected?: string | undefined;
|
|
24
|
+
onOpen: (task: Task) => void;
|
|
25
|
+
/** Gives each task's button an id, so focus can return to it when the editor closes. */
|
|
26
|
+
itemId?: (urn: string) => string;
|
|
27
|
+
now?: Date;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function useBlocked(directory: TaskDirectory) {
|
|
31
|
+
const lookup = lookupFrom(directory.tasks);
|
|
32
|
+
return (task: Task) => openBlockers(task, lookup);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A task as a board card: priority, blocked, title, due date and assignee. Opens the task when chosen. */
|
|
36
|
+
export function TaskCard({
|
|
37
|
+
task,
|
|
38
|
+
directory,
|
|
39
|
+
selected,
|
|
40
|
+
blockedBy,
|
|
41
|
+
onOpen,
|
|
42
|
+
id,
|
|
43
|
+
now,
|
|
44
|
+
}: {
|
|
45
|
+
task: Task;
|
|
46
|
+
directory: TaskDirectory;
|
|
47
|
+
selected?: boolean;
|
|
48
|
+
blockedBy: readonly string[];
|
|
49
|
+
onOpen: (task: Task) => void;
|
|
50
|
+
id?: string;
|
|
51
|
+
now?: Date;
|
|
52
|
+
}) {
|
|
53
|
+
const copy = useCopy();
|
|
54
|
+
const ids = useId();
|
|
55
|
+
const assignee = task.assignee ? { urn: task.assignee.urn, name: nameOf(directory, task.assignee) } : null;
|
|
56
|
+
const blockedTitle = blockedBy.map((urn) => copy('blocked_by', { name: nameOf(directory, { urn }) })).join(', ');
|
|
57
|
+
// The title names the card; the rest describes it, so screen readers hear the title first and the details after.
|
|
58
|
+
const details = [
|
|
59
|
+
copy(priorityCopy(task.priority)),
|
|
60
|
+
dueLabel(task, copy, now),
|
|
61
|
+
assignee ? `${copy('field_assignee')}: ${assignee.name}` : copy('option_unassigned'),
|
|
62
|
+
blockedTitle,
|
|
63
|
+
]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(', ');
|
|
66
|
+
return (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
id={id}
|
|
70
|
+
onClick={() => onOpen(task)}
|
|
71
|
+
aria-current={selected ? 'true' : undefined}
|
|
72
|
+
aria-labelledby={`${ids}-title`}
|
|
73
|
+
aria-describedby={`${ids}-details`}
|
|
74
|
+
className={cn(
|
|
75
|
+
'border-border bg-card hover:border-primary focus-visible:outline-primary w-full rounded-lg border p-2.5',
|
|
76
|
+
'text-left transition-colors focus-visible:outline-2',
|
|
77
|
+
selected && 'border-primary ring-primary/30 ring-2',
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<span aria-hidden="true" className="flex flex-wrap items-center gap-1.5">
|
|
81
|
+
<PriorityBadge priority={task.priority} />
|
|
82
|
+
{blockedBy.length > 0 && <BlockedBadge title={blockedTitle} />}
|
|
83
|
+
</span>
|
|
84
|
+
<span id={`${ids}-title`} className="mt-1.5 block text-[13px] leading-snug font-medium">
|
|
85
|
+
{task.title}
|
|
86
|
+
</span>
|
|
87
|
+
<span aria-hidden="true" className="mt-2 flex items-center justify-between gap-1">
|
|
88
|
+
<DueDate task={task} {...(now ? { now } : {})} />
|
|
89
|
+
<PersonAvatar person={assignee} />
|
|
90
|
+
</span>
|
|
91
|
+
<span id={`${ids}-details`} className="sr-only">
|
|
92
|
+
{details}
|
|
93
|
+
</span>
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The due date as words, for a screen reader: "Due 12 Oct, overdue", or "No due date". */
|
|
99
|
+
function dueLabel(task: Task, copy: ReturnType<typeof useCopy>, now?: Date): string {
|
|
100
|
+
const due = parseTimestamp(task.dueTimestamp);
|
|
101
|
+
if (!due) return copy('due_none');
|
|
102
|
+
const text = `${copy('field_due')} ${due.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}`;
|
|
103
|
+
return isOverdue(task, now) ? `${text}, ${copy('due_overdue')}` : text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface TaskBoardProps extends TaskListProps {
|
|
107
|
+
/** What the columns are. Default status. */
|
|
108
|
+
groupBy?: TaskGrouping;
|
|
109
|
+
/** Sorts each column. */
|
|
110
|
+
sort?: TaskComparator;
|
|
111
|
+
/** Columns hidden while empty. Default the undefined status. */
|
|
112
|
+
hideEmpty?: readonly string[];
|
|
113
|
+
/** With `groupBy: 'workflowStage'`, the workflow whose stages are the columns, in order. */
|
|
114
|
+
workflow?: Workflow | undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Tasks in columns by status, priority, assignee, project, workflow or workflow stage, each with its count. */
|
|
118
|
+
export function TaskBoard({
|
|
119
|
+
tasks,
|
|
120
|
+
directory,
|
|
121
|
+
selected,
|
|
122
|
+
onOpen,
|
|
123
|
+
itemId,
|
|
124
|
+
now,
|
|
125
|
+
groupBy = 'status',
|
|
126
|
+
sort,
|
|
127
|
+
hideEmpty = ['UNDEFINED'],
|
|
128
|
+
workflow,
|
|
129
|
+
}: TaskBoardProps) {
|
|
130
|
+
const copy = useCopy();
|
|
131
|
+
const ids = useId();
|
|
132
|
+
const blocked = useBlocked(directory);
|
|
133
|
+
const grouped =
|
|
134
|
+
groupBy === 'workflowStage' && workflow
|
|
135
|
+
? groupByStage(tasks, workflow)
|
|
136
|
+
: groupBy === 'workflow'
|
|
137
|
+
? groupByWorkflow(tasks, directory.workflows ?? [])
|
|
138
|
+
: groupTasks(tasks, groupBy);
|
|
139
|
+
const groups = [...grouped].filter(([key, group]) => group.length > 0 || !hideEmpty.includes(key));
|
|
140
|
+
|
|
141
|
+
const columnName = (key: string): string => {
|
|
142
|
+
switch (groupBy) {
|
|
143
|
+
case 'status':
|
|
144
|
+
return copy(statusCopy(key as TaskStatus));
|
|
145
|
+
case 'priority':
|
|
146
|
+
return copy(priorityCopy(key as TaskPriority));
|
|
147
|
+
case 'assignee':
|
|
148
|
+
return key === NONE_KEY ? copy('option_unassigned') : nameOf(directory, { urn: key });
|
|
149
|
+
case 'project':
|
|
150
|
+
return key === NONE_KEY ? copy('option_no_project') : nameOf(directory, { urn: key });
|
|
151
|
+
case 'workflow':
|
|
152
|
+
return key === NONE_KEY
|
|
153
|
+
? copy('option_no_workflow')
|
|
154
|
+
: (directory.workflows?.find((w) => w.urn === key)?.name ?? nameOf(directory, { urn: key }));
|
|
155
|
+
case 'workflowStage':
|
|
156
|
+
return key === NONE_KEY ? copy('option_no_stage') : nameOf(directory, { urn: key });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="relative grid auto-cols-[minmax(15rem,1fr)] grid-flow-col gap-3 overflow-x-auto pb-2">
|
|
162
|
+
{groups.map(([key, group], index) => {
|
|
163
|
+
const sorted = sort ? [...group].sort(sort) : group;
|
|
164
|
+
const name = columnName(key);
|
|
165
|
+
const count = group.length === 1 ? copy('tasks_count_one') : copy('tasks_count', { count: group.length });
|
|
166
|
+
return (
|
|
167
|
+
<section key={key} aria-labelledby={`${ids}-column-${index}`} className="bg-muted/50 rounded-xl p-2">
|
|
168
|
+
<h3
|
|
169
|
+
id={`${ids}-column-${index}`}
|
|
170
|
+
aria-label={`${name}, ${count}`}
|
|
171
|
+
className="text-muted-foreground mb-2 flex items-center justify-between px-1 text-xs font-semibold tracking-wide uppercase"
|
|
172
|
+
>
|
|
173
|
+
{name}
|
|
174
|
+
<span className="bg-background text-foreground rounded-full px-1.5 text-[11px] tabular-nums">
|
|
175
|
+
{group.length}
|
|
176
|
+
</span>
|
|
177
|
+
</h3>
|
|
178
|
+
<ul className="space-y-2">
|
|
179
|
+
{sorted.map((task) => (
|
|
180
|
+
<li key={task.urn}>
|
|
181
|
+
<TaskCard
|
|
182
|
+
task={task}
|
|
183
|
+
directory={directory}
|
|
184
|
+
selected={task.urn === selected}
|
|
185
|
+
blockedBy={blocked(task)}
|
|
186
|
+
onOpen={onOpen}
|
|
187
|
+
{...(itemId ? { id: itemId(task.urn) } : {})}
|
|
188
|
+
{...(now ? { now } : {})}
|
|
189
|
+
/>
|
|
190
|
+
</li>
|
|
191
|
+
))}
|
|
192
|
+
</ul>
|
|
193
|
+
</section>
|
|
194
|
+
);
|
|
195
|
+
})}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Tasks as table rows: title, status, priority, assignee, project and due date. The title opens the task. */
|
|
201
|
+
export function TaskTable({ tasks, directory, selected, onOpen, itemId, now }: TaskListProps) {
|
|
202
|
+
const copy = useCopy();
|
|
203
|
+
const blocked = useBlocked(directory);
|
|
204
|
+
return (
|
|
205
|
+
<Table>
|
|
206
|
+
<TableHeader>
|
|
207
|
+
<TableRow>
|
|
208
|
+
<TableHead>{copy('field_title')}</TableHead>
|
|
209
|
+
<TableHead>{copy('field_status')}</TableHead>
|
|
210
|
+
<TableHead>{copy('field_priority')}</TableHead>
|
|
211
|
+
<TableHead>{copy('field_assignee')}</TableHead>
|
|
212
|
+
<TableHead>{copy('field_project')}</TableHead>
|
|
213
|
+
<TableHead>{copy('field_due')}</TableHead>
|
|
214
|
+
</TableRow>
|
|
215
|
+
</TableHeader>
|
|
216
|
+
<TableBody>
|
|
217
|
+
{tasks.map((task) => {
|
|
218
|
+
const assignee = task.assignee ? { urn: task.assignee.urn, name: nameOf(directory, task.assignee) } : null;
|
|
219
|
+
const blockers = blocked(task);
|
|
220
|
+
return (
|
|
221
|
+
<TableRow key={task.urn} data-state={task.urn === selected ? 'selected' : undefined}>
|
|
222
|
+
<TableCell className="max-w-80 whitespace-normal">
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
{...(itemId ? { id: itemId(task.urn) } : {})}
|
|
226
|
+
onClick={() => onOpen(task)}
|
|
227
|
+
aria-current={task.urn === selected ? 'true' : undefined}
|
|
228
|
+
className="hover:text-primary text-left font-medium hover:underline"
|
|
229
|
+
>
|
|
230
|
+
{task.title}
|
|
231
|
+
</button>
|
|
232
|
+
{blockers.length > 0 && (
|
|
233
|
+
<span className="ml-2">
|
|
234
|
+
<BlockedBadge />
|
|
235
|
+
</span>
|
|
236
|
+
)}
|
|
237
|
+
</TableCell>
|
|
238
|
+
<TableCell>
|
|
239
|
+
<StatusBadge status={task.status} />
|
|
240
|
+
</TableCell>
|
|
241
|
+
<TableCell>
|
|
242
|
+
<PriorityBadge priority={task.priority} />
|
|
243
|
+
</TableCell>
|
|
244
|
+
<TableCell>
|
|
245
|
+
<span className="flex items-center gap-2">
|
|
246
|
+
<PersonAvatar person={assignee} />
|
|
247
|
+
<span className={cn(!assignee && 'text-muted-foreground')}>
|
|
248
|
+
{assignee?.name ?? copy('option_unassigned')}
|
|
249
|
+
</span>
|
|
250
|
+
</span>
|
|
251
|
+
</TableCell>
|
|
252
|
+
<TableCell className={cn(!task.project && 'text-muted-foreground')}>
|
|
253
|
+
{task.project ? nameOf(directory, task.project) : copy('option_no_project')}
|
|
254
|
+
</TableCell>
|
|
255
|
+
<TableCell>
|
|
256
|
+
<DueDate task={task} {...(now ? { now } : {})} />
|
|
257
|
+
</TableCell>
|
|
258
|
+
</TableRow>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</TableBody>
|
|
262
|
+
</Table>
|
|
263
|
+
);
|
|
264
|
+
}
|