@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/store.ts
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isBaackApiError,
|
|
3
|
+
type SearchItem,
|
|
4
|
+
type Task,
|
|
5
|
+
type TaskLog,
|
|
6
|
+
type TaskPriority,
|
|
7
|
+
type TaskStatus,
|
|
8
|
+
type Workflow,
|
|
9
|
+
} from '@baack-software/baack-ts-client';
|
|
10
|
+
import { openBlockers as openBlockersOf } from './blockers.ts';
|
|
11
|
+
import type { TaskField } from './conflicts.ts';
|
|
12
|
+
import { applyChanges, type DraftInput } from './draft.ts';
|
|
13
|
+
import { TaskConflictError } from './errors.ts';
|
|
14
|
+
import { ref, urnOf, withRef, withoutRef } from './refs.ts';
|
|
15
|
+
import type { ChangeOptions, TaskClient } from './task-client.ts';
|
|
16
|
+
import {
|
|
17
|
+
filterTasks,
|
|
18
|
+
groupTasks,
|
|
19
|
+
type TaskComparator,
|
|
20
|
+
type TaskFilter,
|
|
21
|
+
type TaskGrouping,
|
|
22
|
+
} from './tasks.ts';
|
|
23
|
+
import type { RefLike, TaskDraft, TaskInput, TaskListField } from './types.ts';
|
|
24
|
+
|
|
25
|
+
export interface TaskStoreOptions {
|
|
26
|
+
/** The company whose tasks the store holds. */
|
|
27
|
+
owner: RefLike;
|
|
28
|
+
/** The identity changes are attributed to in the activity log, unless a change names another. */
|
|
29
|
+
actor?: RefLike;
|
|
30
|
+
/** Load identities and projects for names and pickers. Default true. */
|
|
31
|
+
includeRelated?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Workflows whose stages tasks can move through, loaded with the tasks for stage columns and pickers. The API has
|
|
34
|
+
* no way to list them, so the app names them. One that can't be read is left out.
|
|
35
|
+
*/
|
|
36
|
+
workflows?: readonly RefLike[];
|
|
37
|
+
/**
|
|
38
|
+
* The server can change a task after accepting a write. This long after each write, the store reads the task (and
|
|
39
|
+
* its log, if loaded) again. Default 500 ms; 0 turns it off.
|
|
40
|
+
*/
|
|
41
|
+
settleMs?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error';
|
|
45
|
+
|
|
46
|
+
export interface TaskLogState {
|
|
47
|
+
status: LoadStatus;
|
|
48
|
+
/** Newest first. */
|
|
49
|
+
logs: readonly TaskLog[];
|
|
50
|
+
/** Set while older pages remain. */
|
|
51
|
+
after?: string;
|
|
52
|
+
error?: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A snapshot of the store. Each change replaces it, so it can be compared by reference. */
|
|
56
|
+
export interface TaskStoreState {
|
|
57
|
+
status: LoadStatus;
|
|
58
|
+
error?: unknown;
|
|
59
|
+
/** Tasks as the reader should see them, pending changes applied, in search order with new tasks first. */
|
|
60
|
+
tasks: readonly Task[];
|
|
61
|
+
byUrn: ReadonlyMap<string, Task>;
|
|
62
|
+
identities: ReadonlyMap<string, SearchItem>;
|
|
63
|
+
projects: ReadonlyMap<string, SearchItem>;
|
|
64
|
+
/** The workflows named in the options, by URN, in the order named. */
|
|
65
|
+
workflows: ReadonlyMap<string, Workflow>;
|
|
66
|
+
/** URNs of tasks with a write in flight. */
|
|
67
|
+
saving: ReadonlySet<string>;
|
|
68
|
+
/** Activity logs of the tasks whose logs were loaded. */
|
|
69
|
+
logs: ReadonlyMap<string, TaskLogState>;
|
|
70
|
+
/**
|
|
71
|
+
* Fields someone else changed while this person was editing, found when one of their changes was saved, by task
|
|
72
|
+
* URN. Kept until `dismissChangedElsewhere`.
|
|
73
|
+
*/
|
|
74
|
+
changedElsewhere: ReadonlyMap<string, readonly (TaskField | TaskListField)[]>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Patch = { id: number; changes: TaskInput } | { id: number; deleted: true };
|
|
78
|
+
|
|
79
|
+
const EMPTY: TaskStoreState = {
|
|
80
|
+
status: 'idle',
|
|
81
|
+
tasks: [],
|
|
82
|
+
byUrn: new Map(),
|
|
83
|
+
identities: new Map(),
|
|
84
|
+
projects: new Map(),
|
|
85
|
+
workflows: new Map(),
|
|
86
|
+
saving: new Set(),
|
|
87
|
+
logs: new Map(),
|
|
88
|
+
changedElsewhere: new Map(),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Holds a company's tasks for a UI: loads them, applies changes straight away and rolls them back if the server
|
|
93
|
+
* refuses, and notifies subscribers. `subscribe` and `getState` fit React's `useSyncExternalStore`.
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* const store = new TaskStore(tasks, { owner: companyUrn, actor: me });
|
|
97
|
+
* store.subscribe(() => render(selectTasks(store.getState(), { assignee: [me], open: true }, byUrgency)));
|
|
98
|
+
* await store.load();
|
|
99
|
+
* await store.setStatus(urn, 'COMPLETED');
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export class TaskStore {
|
|
103
|
+
#state: TaskStoreState = EMPTY;
|
|
104
|
+
readonly #listeners = new Set<() => void>();
|
|
105
|
+
/** Tasks as the server last returned them. */
|
|
106
|
+
readonly #confirmed = new Map<string, Task>();
|
|
107
|
+
#order: string[] = [];
|
|
108
|
+
readonly #pending = new Map<string, Patch[]>();
|
|
109
|
+
#nextPatch = 0;
|
|
110
|
+
readonly #timers = new Set<ReturnType<typeof setTimeout>>();
|
|
111
|
+
#loading: AbortController | undefined;
|
|
112
|
+
|
|
113
|
+
constructor(
|
|
114
|
+
readonly client: TaskClient,
|
|
115
|
+
readonly options: TaskStoreOptions,
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
getState = (): TaskStoreState => this.#state;
|
|
119
|
+
|
|
120
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
121
|
+
this.#listeners.add(listener);
|
|
122
|
+
return () => this.#listeners.delete(listener);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** The task as the reader should see it. */
|
|
126
|
+
get(task: RefLike): Task | undefined {
|
|
127
|
+
return this.#state.byUrn.get(urnOf(task));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Stops pending work and drops subscribers. */
|
|
131
|
+
dispose(): void {
|
|
132
|
+
this.#loading?.abort();
|
|
133
|
+
this.#timers.forEach(clearTimeout);
|
|
134
|
+
this.#timers.clear();
|
|
135
|
+
this.#listeners.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ==========================================
|
|
139
|
+
// LOADING
|
|
140
|
+
// ==========================================
|
|
141
|
+
|
|
142
|
+
/** Loads (or reloads) every task. A load already running is cancelled. Pending changes stay applied. */
|
|
143
|
+
async load(): Promise<void> {
|
|
144
|
+
this.#loading?.abort();
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
this.#loading = controller;
|
|
147
|
+
this.#publish({ status: 'loading' });
|
|
148
|
+
try {
|
|
149
|
+
const [listing, workflows] = await Promise.all([
|
|
150
|
+
this.client.list({
|
|
151
|
+
owner: this.options.owner,
|
|
152
|
+
includeRelated: this.options.includeRelated ?? true,
|
|
153
|
+
signal: controller.signal,
|
|
154
|
+
}),
|
|
155
|
+
Promise.all((this.options.workflows ?? []).map((workflow) => this.client.getWorkflow(workflow).catch(() => undefined))),
|
|
156
|
+
]);
|
|
157
|
+
if (controller.signal.aborted) return;
|
|
158
|
+
this.#confirmed.clear();
|
|
159
|
+
for (const task of listing.tasks) this.#confirmed.set(task.urn, task);
|
|
160
|
+
this.#order = listing.tasks.map((task) => task.urn);
|
|
161
|
+
this.#publish({
|
|
162
|
+
status: 'ready',
|
|
163
|
+
error: undefined,
|
|
164
|
+
identities: new Map(listing.identities.map((item) => [item.urn, item])),
|
|
165
|
+
projects: new Map(listing.projects.map((item) => [item.urn, item])),
|
|
166
|
+
workflows: new Map(workflows.filter((w) => w !== undefined).map((workflow) => [workflow.urn, workflow])),
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (controller.signal.aborted) return;
|
|
170
|
+
this.#publish({ status: 'error', error });
|
|
171
|
+
throw error;
|
|
172
|
+
} finally {
|
|
173
|
+
if (this.#loading === controller) this.#loading = undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Reads one task again. Removes it when it no longer exists. */
|
|
178
|
+
async reload(task: RefLike): Promise<Task | undefined> {
|
|
179
|
+
const urn = urnOf(task);
|
|
180
|
+
try {
|
|
181
|
+
const fresh = await this.client.get(urn);
|
|
182
|
+
this.#confirm(fresh);
|
|
183
|
+
this.#publish();
|
|
184
|
+
return fresh;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (isBaackApiError(error) && error.isNotFound) {
|
|
187
|
+
this.#forget(urn);
|
|
188
|
+
this.#publish();
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ==========================================
|
|
196
|
+
// CHANGES
|
|
197
|
+
// ==========================================
|
|
198
|
+
|
|
199
|
+
async create(input: TaskDraft | Omit<DraftInput, 'owner'>, options: ChangeOptions = {}): Promise<Task> {
|
|
200
|
+
const created = await this.client.create({ owner: this.options.owner, ...input }, this.#changeOptions(options));
|
|
201
|
+
this.#confirm(created, true);
|
|
202
|
+
this.#publish();
|
|
203
|
+
this.#settle([created.urn]);
|
|
204
|
+
return created;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Shows `changes` at once and sends them. On success the server's task replaces the shown one; on failure the
|
|
209
|
+
* change is rolled back and the error rethrown.
|
|
210
|
+
*
|
|
211
|
+
* The change is checked against the task as shown when it was made. If someone else changed one of the same fields
|
|
212
|
+
* since, it throws `TaskConflictError`, saves nothing and shows the task as it is now; resolve the conflict with
|
|
213
|
+
* `resolveConflicts` and update again. Other fields they changed are kept and listed in `changedElsewhere`.
|
|
214
|
+
*/
|
|
215
|
+
async update(task: RefLike, changes: TaskInput, options: ChangeOptions = {}): Promise<Task> {
|
|
216
|
+
const urn = urnOf(task);
|
|
217
|
+
const before = this.get(urn);
|
|
218
|
+
const id = this.#addPatch(urn, { changes });
|
|
219
|
+
try {
|
|
220
|
+
const result = (await this.client.update(before ?? urn, changes, {
|
|
221
|
+
...this.#changeOptions(options),
|
|
222
|
+
base: before,
|
|
223
|
+
onChangedElsewhere: (fields) => this.#noteChangedElsewhere(urn, fields),
|
|
224
|
+
})) as Task | undefined;
|
|
225
|
+
const saved = result ?? (await this.client.get(urn));
|
|
226
|
+
this.#confirm(saved);
|
|
227
|
+
this.#removePatch(urn, id);
|
|
228
|
+
this.#settle([urn, ...changedBlockers(before, changes)]);
|
|
229
|
+
return saved;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof TaskConflictError) this.#confirm(error.current);
|
|
232
|
+
this.#removePatch(urn, id);
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Clears the note of fields someone else changed on the task. */
|
|
238
|
+
dismissChangedElsewhere(task: RefLike): void {
|
|
239
|
+
const urn = urnOf(task);
|
|
240
|
+
if (!this.#state.changedElsewhere.has(urn)) return;
|
|
241
|
+
const changedElsewhere = new Map(this.#state.changedElsewhere);
|
|
242
|
+
changedElsewhere.delete(urn);
|
|
243
|
+
this.#publish({ changedElsewhere });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async delete(task: RefLike): Promise<void> {
|
|
247
|
+
const urn = urnOf(task);
|
|
248
|
+
const id = this.#addPatch(urn, { deleted: true });
|
|
249
|
+
try {
|
|
250
|
+
await this.client.delete(urn);
|
|
251
|
+
const blockers = changedBlockers(this.#confirmed.get(urn), { blockedBy: [], blocking: [] });
|
|
252
|
+
this.#forget(urn);
|
|
253
|
+
this.#removePatch(urn, id);
|
|
254
|
+
this.#settle(blockers);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
this.#removePatch(urn, id);
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
setStatus(task: RefLike, status: TaskStatus, options?: ChangeOptions): Promise<Task> {
|
|
262
|
+
return this.update(task, { status }, options);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
setPriority(task: RefLike, priority: TaskPriority, options?: ChangeOptions): Promise<Task> {
|
|
266
|
+
return this.update(task, { priority }, options);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
assign(task: RefLike, assignee: RefLike | null, options?: ChangeOptions): Promise<Task> {
|
|
270
|
+
return this.update(task, { assignee: assignee === null ? null : ref(assignee) }, options);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
setDue(task: RefLike, dueTimestamp: string | null, options?: ChangeOptions): Promise<Task> {
|
|
274
|
+
return this.update(task, { dueTimestamp }, options);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
setProject(task: RefLike, project: RefLike | null, options?: ChangeOptions): Promise<Task> {
|
|
278
|
+
return this.update(task, { project: project === null ? null : ref(project) }, options);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
setWorkflowStage(task: RefLike, stage: RefLike | null, options?: ChangeOptions): Promise<Task> {
|
|
282
|
+
return this.update(task, { workflowStage: stage === null ? null : ref(stage) }, options);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
addBlocker(task: RefLike, blocker: RefLike, options?: ChangeOptions): Promise<Task> {
|
|
286
|
+
return this.addTo(task, 'blockedBy', blocker, options);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
removeBlocker(task: RefLike, blocker: RefLike, options?: ChangeOptions): Promise<Task> {
|
|
290
|
+
return this.removeFrom(task, 'blockedBy', blocker, options);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Adds a reference to one of the task's lists, building on pending changes. */
|
|
294
|
+
addTo(task: RefLike, field: TaskListField, value: RefLike, options?: ChangeOptions): Promise<Task> {
|
|
295
|
+
return this.update(task, { [field]: withRef(this.#require(task)[field], value) }, options);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Removes a reference from one of the task's lists, building on pending changes. */
|
|
299
|
+
removeFrom(task: RefLike, field: TaskListField, value: RefLike, options?: ChangeOptions): Promise<Task> {
|
|
300
|
+
return this.update(task, { [field]: withoutRef(this.#require(task)[field], value) }, options);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ==========================================
|
|
304
|
+
// ACTIVITY LOG
|
|
305
|
+
// ==========================================
|
|
306
|
+
|
|
307
|
+
/** Loads the newest page of the task's log, replacing what was loaded. */
|
|
308
|
+
async loadLogs(task: RefLike): Promise<void> {
|
|
309
|
+
const urn = urnOf(task);
|
|
310
|
+
this.#setLogs(urn, { ...this.#logState(urn), status: 'loading' });
|
|
311
|
+
try {
|
|
312
|
+
const page = await this.client.logs(urn);
|
|
313
|
+
this.#setLogs(urn, { status: 'ready', logs: page.logs, ...afterOf(page.pagination) });
|
|
314
|
+
} catch (error) {
|
|
315
|
+
this.#setLogs(urn, { ...this.#logState(urn), status: 'error', error });
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Loads the next older page of the task's log, if there is one. */
|
|
321
|
+
async loadMoreLogs(task: RefLike): Promise<void> {
|
|
322
|
+
const urn = urnOf(task);
|
|
323
|
+
const current = this.#logState(urn);
|
|
324
|
+
if (current.after === undefined) return;
|
|
325
|
+
this.#setLogs(urn, { ...current, status: 'loading' });
|
|
326
|
+
try {
|
|
327
|
+
const page = await this.client.logs(urn, { after: current.after });
|
|
328
|
+
this.#setLogs(urn, {
|
|
329
|
+
status: 'ready',
|
|
330
|
+
logs: mergeLogs(current.logs, page.logs),
|
|
331
|
+
...afterOf(page.pagination),
|
|
332
|
+
});
|
|
333
|
+
} catch (error) {
|
|
334
|
+
this.#setLogs(urn, { ...current, status: 'error', error });
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Adds a comment and shows it at the top of the task's log. */
|
|
340
|
+
async comment(task: RefLike, text: string, options: { actor?: RefLike; replyTo?: RefLike } = {}): Promise<TaskLog> {
|
|
341
|
+
const urn = urnOf(task);
|
|
342
|
+
const actor = options.actor ?? this.options.actor;
|
|
343
|
+
const log = await this.client.comment(urn, text, {
|
|
344
|
+
...(actor === undefined ? {} : { actor }),
|
|
345
|
+
...(options.replyTo === undefined ? {} : { replyTo: options.replyTo }),
|
|
346
|
+
});
|
|
347
|
+
const current = this.#state.logs.get(urn);
|
|
348
|
+
if (current) this.#setLogs(urn, { ...current, logs: mergeLogs([log], current.logs) });
|
|
349
|
+
this.#settle([urn]);
|
|
350
|
+
return log;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ==========================================
|
|
354
|
+
// INTERNALS
|
|
355
|
+
// ==========================================
|
|
356
|
+
|
|
357
|
+
#changeOptions(options: ChangeOptions): ChangeOptions {
|
|
358
|
+
const actor = options.actor ?? this.options.actor;
|
|
359
|
+
return actor === undefined ? {} : { actor };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#require(task: RefLike): Task {
|
|
363
|
+
const found = this.get(task);
|
|
364
|
+
if (!found) throw new Error(`Task ${urnOf(task)} is not in the store`);
|
|
365
|
+
return found;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#confirm(task: Task, first = false): void {
|
|
369
|
+
this.#confirmed.set(task.urn, task);
|
|
370
|
+
if (!this.#order.includes(task.urn)) this.#order = first ? [task.urn, ...this.#order] : [...this.#order, task.urn];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#forget(urn: string): void {
|
|
374
|
+
this.#confirmed.delete(urn);
|
|
375
|
+
this.#order = this.#order.filter((candidate) => candidate !== urn);
|
|
376
|
+
if (this.#state.logs.has(urn)) {
|
|
377
|
+
const logs = new Map(this.#state.logs);
|
|
378
|
+
logs.delete(urn);
|
|
379
|
+
this.#state = { ...this.#state, logs };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#addPatch(urn: string, patch: Omit<Patch, 'id'>): number {
|
|
384
|
+
const id = this.#nextPatch++;
|
|
385
|
+
this.#pending.set(urn, [...(this.#pending.get(urn) ?? []), { id, ...patch } as Patch]);
|
|
386
|
+
this.#publish();
|
|
387
|
+
return id;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#removePatch(urn: string, id: number): void {
|
|
391
|
+
const remaining = (this.#pending.get(urn) ?? []).filter((patch) => patch.id !== id);
|
|
392
|
+
if (remaining.length > 0) this.#pending.set(urn, remaining);
|
|
393
|
+
else this.#pending.delete(urn);
|
|
394
|
+
this.#publish();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#noteChangedElsewhere(urn: string, fields: readonly (TaskField | TaskListField)[]): void {
|
|
398
|
+
const noted = new Set([...(this.#state.changedElsewhere.get(urn) ?? []), ...fields]);
|
|
399
|
+
this.#state = { ...this.#state, changedElsewhere: new Map(this.#state.changedElsewhere).set(urn, [...noted]) };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#logState(urn: string): TaskLogState {
|
|
403
|
+
return this.#state.logs.get(urn) ?? { status: 'idle', logs: [] };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#setLogs(urn: string, state: TaskLogState): void {
|
|
407
|
+
this.#publish({ logs: new Map(this.#state.logs).set(urn, state) });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Reads the given tasks (and their loaded logs) again once the server has had time to settle. */
|
|
411
|
+
#settle(urns: readonly string[]): void {
|
|
412
|
+
const settleMs = this.options.settleMs ?? 500;
|
|
413
|
+
if (settleMs <= 0) return;
|
|
414
|
+
const timer = setTimeout(() => {
|
|
415
|
+
this.#timers.delete(timer);
|
|
416
|
+
for (const urn of new Set(urns)) {
|
|
417
|
+
if (!this.#confirmed.has(urn)) continue;
|
|
418
|
+
this.reload(urn).catch(() => undefined);
|
|
419
|
+
if (this.#state.logs.get(urn)?.status === 'ready') this.#refreshLogs(urn).catch(() => undefined);
|
|
420
|
+
}
|
|
421
|
+
}, settleMs);
|
|
422
|
+
this.#timers.add(timer);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Reads the newest page of a log and merges it in, keeping older pages already loaded. */
|
|
426
|
+
async #refreshLogs(urn: string): Promise<void> {
|
|
427
|
+
const page = await this.client.logs(urn);
|
|
428
|
+
const current = this.#logState(urn);
|
|
429
|
+
this.#setLogs(urn, { ...current, logs: mergeLogs(page.logs, current.logs) });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#publish(changes: Partial<TaskStoreState> = {}): void {
|
|
433
|
+
const tasks: Task[] = [];
|
|
434
|
+
const byUrn = new Map<string, Task>();
|
|
435
|
+
const saving = new Set<string>();
|
|
436
|
+
for (const urn of this.#order) {
|
|
437
|
+
let task = this.#confirmed.get(urn);
|
|
438
|
+
const patches = this.#pending.get(urn) ?? [];
|
|
439
|
+
if (patches.length > 0) saving.add(urn);
|
|
440
|
+
for (const patch of patches) {
|
|
441
|
+
if (!task) break;
|
|
442
|
+
task = 'deleted' in patch ? undefined : this.#withNames(applyChanges(task, patch.changes));
|
|
443
|
+
}
|
|
444
|
+
if (task) {
|
|
445
|
+
tasks.push(task);
|
|
446
|
+
byUrn.set(urn, task);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const urn of this.#pending.keys()) saving.add(urn);
|
|
450
|
+
this.#state = { ...this.#state, ...changes, tasks, byUrn, saving };
|
|
451
|
+
this.#listeners.forEach((listener) => listener());
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Fills in names for an assignee or project set by URN, from the identities and projects loaded. */
|
|
455
|
+
#withNames(task: Task): Task {
|
|
456
|
+
const result = { ...task };
|
|
457
|
+
const assignee = task.assignee && this.#state.identities.get(task.assignee.urn);
|
|
458
|
+
if (task.assignee && assignee && task.assignee.name == null) {
|
|
459
|
+
result.assignee = { ...task.assignee, name: assignee.name ?? null };
|
|
460
|
+
}
|
|
461
|
+
const project = task.project && this.#state.projects.get(task.project.urn);
|
|
462
|
+
if (task.project && project && task.project.name == null) {
|
|
463
|
+
result.project = { ...task.project, name: project.name ?? '' };
|
|
464
|
+
}
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ==========================================
|
|
470
|
+
// SELECTORS
|
|
471
|
+
// ==========================================
|
|
472
|
+
|
|
473
|
+
/** Tasks matching `filter`, sorted when a comparator is given. */
|
|
474
|
+
export function selectTasks(state: TaskStoreState, filter: TaskFilter = {}, sort?: TaskComparator): Task[] {
|
|
475
|
+
const tasks = filterTasks(state.tasks, filter);
|
|
476
|
+
return sort ? tasks.sort(sort) : tasks;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Tasks matching `filter`, grouped for a board, each group sorted when a comparator is given. */
|
|
480
|
+
export function selectGroups(
|
|
481
|
+
state: TaskStoreState,
|
|
482
|
+
by: TaskGrouping,
|
|
483
|
+
filter: TaskFilter = {},
|
|
484
|
+
sort?: TaskComparator,
|
|
485
|
+
): Map<string, Task[]> {
|
|
486
|
+
const groups = groupTasks(filterTasks(state.tasks, filter), by);
|
|
487
|
+
if (sort) groups.forEach((group) => group.sort(sort));
|
|
488
|
+
return groups;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The display name of an identity, project, task or workflow stage, falling back to the end of its URN. */
|
|
492
|
+
export function selectName(state: TaskStoreState, item: RefLike): string {
|
|
493
|
+
const urn = urnOf(item);
|
|
494
|
+
return (
|
|
495
|
+
state.identities.get(urn)?.name ??
|
|
496
|
+
state.projects.get(urn)?.name ??
|
|
497
|
+
state.byUrn.get(urn)?.title ??
|
|
498
|
+
stageName(state.workflows.values(), urn) ??
|
|
499
|
+
(typeof item === 'object' && 'name' in item && typeof item.name === 'string' ? item.name : undefined) ??
|
|
500
|
+
urn.slice(-6)
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The name of a stage in one of `workflows`. */
|
|
505
|
+
function stageName(workflows: Iterable<Workflow>, urn: string): string | undefined {
|
|
506
|
+
for (const workflow of workflows) {
|
|
507
|
+
const stage = workflow.stages?.find((s) => s.urn === urn);
|
|
508
|
+
if (stage) return stage.name;
|
|
509
|
+
}
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** The URNs of the open tasks blocking `task`, by the store's tasks. */
|
|
514
|
+
export function selectOpenBlockers(state: TaskStoreState, task: Task): string[] {
|
|
515
|
+
return openBlockersOf(task, (urn) => state.byUrn.get(urn));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function changedBlockers(before: Task | undefined, changes: TaskInput): string[] {
|
|
519
|
+
const urns: string[] = [];
|
|
520
|
+
for (const field of ['blockedBy', 'blocking'] as const) {
|
|
521
|
+
const after = changes[field];
|
|
522
|
+
if (after === undefined) continue;
|
|
523
|
+
const old = new Set((before?.[field] ?? []).map(urnOf));
|
|
524
|
+
const next = new Set(after.map(urnOf));
|
|
525
|
+
for (const urn of old) if (!next.has(urn)) urns.push(urn);
|
|
526
|
+
for (const urn of next) if (!old.has(urn)) urns.push(urn);
|
|
527
|
+
}
|
|
528
|
+
return urns;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function mergeLogs(newer: readonly TaskLog[], older: readonly TaskLog[]): TaskLog[] {
|
|
532
|
+
const seen = new Set(newer.map((log) => log.urn));
|
|
533
|
+
return [...newer, ...older.filter((log) => !seen.has(log.urn))];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function afterOf(pagination: { isMorePages?: boolean; after?: string | null } | null | undefined): { after?: string } {
|
|
537
|
+
return pagination?.isMorePages && pagination.after ? { after: pagination.after } : {};
|
|
538
|
+
}
|