@skillcdn/console 0.3.1 → 0.3.2
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/README.md +2 -2
- package/dist/api.d.ts +3 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +3 -3
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +105 -18
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +9 -7
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +13 -11
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/client.d.ts +13 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -2
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +28 -6
- package/dist/compat.d.ts.map +1 -1
- package/dist/compat.js +55 -14
- package/dist/compat.js.map +1 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +6 -4
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +13 -7
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +36 -24
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/picker.d.ts +39 -0
- package/dist/components/picker.d.ts.map +1 -0
- package/dist/components/picker.js +155 -0
- package/dist/components/picker.js.map +1 -0
- package/dist/components/task-form.d.ts +18 -4
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +56 -13
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/task-list.js +2 -2
- package/dist/components/task-list.js.map +1 -1
- package/dist/components/task-view.d.ts +7 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -5
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +7 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +11 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts.map +1 -1
- package/dist/components/work.js +5 -3
- package/dist/components/work.js.map +1 -1
- package/dist/console.css +163 -11
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +24 -4
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +11 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +22 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +11 -3
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +16 -8
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +17 -9
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +2 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +2 -0
- package/dist/limits.js.map +1 -1
- package/dist/schemas.d.ts +12 -11
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +11 -8
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +2 -0
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +2 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +9 -9
- package/docs/specs/rest.md +14 -14
- package/package.json +1 -1
- package/skills/building-a-console/SKILL.md +2 -2
- package/skills/deploying-a-console/SKILL.md +2 -2
- package/skills/deploying-a-console/references/resources.md +1 -1
- package/skills/working-the-board/SKILL.md +3 -3
- package/skills/working-the-board/references/terms.md +2 -2
- package/src/api.ts +5 -0
- package/src/cli/cli.ts +131 -18
- package/src/cli/format.ts +11 -7
- package/src/cli/help.ts +15 -11
- package/src/cli/main.ts +1 -0
- package/src/client.ts +23 -10
- package/src/compat.ts +63 -14
- package/src/components/board.tsx +10 -4
- package/src/components/calendar.tsx +68 -32
- package/src/components/picker.tsx +297 -0
- package/src/components/task-form.tsx +149 -63
- package/src/components/task-list.tsx +2 -2
- package/src/components/task-view.tsx +23 -9
- package/src/components/ui.tsx +25 -0
- package/src/components/work.tsx +6 -4
- package/src/console.tsx +29 -3
- package/src/data.ts +44 -1
- package/src/i18n/en.ts +16 -8
- package/src/i18n/ko.ts +17 -9
- package/src/index.ts +16 -1
- package/src/limits.ts +2 -0
- package/src/schemas.ts +11 -7
- package/src/styles/console.css +163 -11
- package/src/vocabulary.ts +3 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type KeyboardEvent,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { useWords } from "../i18n/index.js";
|
|
11
|
+
import { cx } from "./ui.js";
|
|
12
|
+
|
|
13
|
+
// Choosing one thing out of a listing too long to offer whole (ADR-0037): the task a new one is
|
|
14
|
+
// part of, the goal it works towards. The picker asks for what the words typed find, a page at a
|
|
15
|
+
// time, and never holds the listing: the first page as soon as the field is entered, the words'
|
|
16
|
+
// page once the typing rests, the next page on a press. What is chosen stands in the field's
|
|
17
|
+
// place, and a press clears it. Takes a search and nothing else from the network.
|
|
18
|
+
|
|
19
|
+
/** How many items one page of a picker holds: enough to find the one meant, few enough to read at a glance. */
|
|
20
|
+
export const PICKER_PAGE_LIMIT = 10;
|
|
21
|
+
/** How long the typing rests before the words are searched. */
|
|
22
|
+
const SEARCH_DELAY_MS = 200;
|
|
23
|
+
|
|
24
|
+
export interface PickerPage<T> {
|
|
25
|
+
readonly items: readonly T[];
|
|
26
|
+
readonly more: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the words find, from `offset` on; the first page of everything while the words are empty. */
|
|
30
|
+
export type PickerSearch<T> = (
|
|
31
|
+
query: string,
|
|
32
|
+
page: { readonly limit: number; readonly offset: number },
|
|
33
|
+
signal: AbortSignal,
|
|
34
|
+
) => Promise<PickerPage<T>>;
|
|
35
|
+
|
|
36
|
+
/** How far the list stands from the field, in pixels, as the stylesheet has it. */
|
|
37
|
+
const LIST_GAP = 4;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the list opens above the field: only where the window has no room for it below the
|
|
41
|
+
* field and more room above than below; else below, as a list does.
|
|
42
|
+
*/
|
|
43
|
+
export function opensAbove(
|
|
44
|
+
field: { readonly top: number; readonly bottom: number },
|
|
45
|
+
listHeight: number,
|
|
46
|
+
windowHeight: number,
|
|
47
|
+
): boolean {
|
|
48
|
+
const below = windowHeight - field.bottom - LIST_GAP;
|
|
49
|
+
const over = field.top - LIST_GAP;
|
|
50
|
+
return listHeight > below && over > below;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PickerProps<T> {
|
|
54
|
+
/** What is chosen, if anything. */
|
|
55
|
+
readonly value: T | undefined;
|
|
56
|
+
/** What stands for a choice made but not read back yet: shown in the chip's place, and cleared as one. */
|
|
57
|
+
readonly held?: ReactNode | undefined;
|
|
58
|
+
readonly search: PickerSearch<T>;
|
|
59
|
+
readonly keyOf: (item: T) => string;
|
|
60
|
+
/** How an item reads, in the list and once chosen. */
|
|
61
|
+
readonly render: (item: T) => ReactNode;
|
|
62
|
+
readonly onChange: (item: T | undefined) => void;
|
|
63
|
+
/** The field's name, for the input and the list. */
|
|
64
|
+
readonly label: string;
|
|
65
|
+
readonly placeholder: string;
|
|
66
|
+
readonly disabled?: boolean | undefined;
|
|
67
|
+
/** What the search may answer with but cannot be chosen: a task, as its own parent. */
|
|
68
|
+
readonly exclude?: ((item: T) => boolean) | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Picker<T>(props: PickerProps<T>) {
|
|
72
|
+
const words = useWords().common;
|
|
73
|
+
const listId = useId();
|
|
74
|
+
const [query, setQuery] = useState("");
|
|
75
|
+
const [open, setOpen] = useState(false);
|
|
76
|
+
const [items, setItems] = useState<readonly T[]>([]);
|
|
77
|
+
const [more, setMore] = useState(false);
|
|
78
|
+
const [loading, setLoading] = useState(false);
|
|
79
|
+
const [active, setActive] = useState(-1);
|
|
80
|
+
const [above, setAbove] = useState(false);
|
|
81
|
+
const field = useRef<HTMLInputElement>(null);
|
|
82
|
+
const list = useRef<HTMLDivElement>(null);
|
|
83
|
+
// The search and the exclusion as the page last gave them, read at the moment of a request,
|
|
84
|
+
// so that a page rendering again starts no search over.
|
|
85
|
+
const search = useRef(props.search);
|
|
86
|
+
search.current = props.search;
|
|
87
|
+
const exclude = useRef(props.exclude);
|
|
88
|
+
exclude.current = props.exclude;
|
|
89
|
+
const latest = useRef(query);
|
|
90
|
+
latest.current = query;
|
|
91
|
+
|
|
92
|
+
// The list opens below the field, and above it only where the window has no room below and
|
|
93
|
+
// more above: measured before anything is painted, once it is open and again whenever what it
|
|
94
|
+
// holds changes its height, so that it never shows below for a frame on its way up.
|
|
95
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the items, the loading and the more are what changes the list's height
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
if (!open || field.current === null || list.current === null) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setAbove(
|
|
101
|
+
opensAbove(
|
|
102
|
+
field.current.getBoundingClientRect(),
|
|
103
|
+
list.current.offsetHeight,
|
|
104
|
+
window.innerHeight,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
}, [open, items, loading, more]);
|
|
108
|
+
|
|
109
|
+
// The words' page once the typing rests; the first page of everything at once on entering.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!open) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const timer = setTimeout(
|
|
116
|
+
() => {
|
|
117
|
+
setLoading(true);
|
|
118
|
+
search
|
|
119
|
+
.current(query, { limit: PICKER_PAGE_LIMIT, offset: 0 }, controller.signal)
|
|
120
|
+
.then((page) => {
|
|
121
|
+
if (!controller.signal.aborted) {
|
|
122
|
+
setItems(page.items.filter((item) => exclude.current?.(item) !== true));
|
|
123
|
+
setMore(page.more);
|
|
124
|
+
setActive(-1);
|
|
125
|
+
setLoading(false);
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {
|
|
129
|
+
if (!controller.signal.aborted) {
|
|
130
|
+
setItems([]);
|
|
131
|
+
setMore(false);
|
|
132
|
+
setLoading(false);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
query === "" ? 0 : SEARCH_DELAY_MS,
|
|
137
|
+
);
|
|
138
|
+
return () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
controller.abort();
|
|
141
|
+
};
|
|
142
|
+
}, [open, query]);
|
|
143
|
+
|
|
144
|
+
/** The next page of the same words, put under the ones shown; dropped when the words moved on. */
|
|
145
|
+
const loadMore = () => {
|
|
146
|
+
const wanted = query;
|
|
147
|
+
setLoading(true);
|
|
148
|
+
search
|
|
149
|
+
.current(
|
|
150
|
+
wanted,
|
|
151
|
+
{ limit: PICKER_PAGE_LIMIT, offset: items.length },
|
|
152
|
+
new AbortController().signal,
|
|
153
|
+
)
|
|
154
|
+
.then((page) => {
|
|
155
|
+
if (wanted !== latest.current) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
setItems((current) => [
|
|
159
|
+
...current,
|
|
160
|
+
...page.items.filter((item) => exclude.current?.(item) !== true),
|
|
161
|
+
]);
|
|
162
|
+
setMore(page.more);
|
|
163
|
+
setLoading(false);
|
|
164
|
+
})
|
|
165
|
+
.catch(() => {
|
|
166
|
+
if (wanted === latest.current) {
|
|
167
|
+
setMore(false);
|
|
168
|
+
setLoading(false);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const choose = (item: T) => {
|
|
174
|
+
props.onChange(item);
|
|
175
|
+
setQuery("");
|
|
176
|
+
setOpen(false);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
180
|
+
if (event.key === "ArrowDown") {
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
if (!open) {
|
|
183
|
+
setOpen(true);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
setActive((current) => (items.length === 0 ? -1 : (current + 1) % items.length));
|
|
187
|
+
} else if (event.key === "ArrowUp") {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
setActive((current) =>
|
|
190
|
+
items.length === 0 ? -1 : current <= 0 ? items.length - 1 : current - 1,
|
|
191
|
+
);
|
|
192
|
+
} else if (event.key === "Enter") {
|
|
193
|
+
// A choice, or nothing: the form around the field is never sent by this key.
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
const chosen = active >= 0 ? items[active] : undefined;
|
|
196
|
+
if (chosen !== undefined) {
|
|
197
|
+
choose(chosen);
|
|
198
|
+
}
|
|
199
|
+
} else if (event.key === "Escape" && open) {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
setOpen(false);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (props.value !== undefined || props.held !== undefined) {
|
|
206
|
+
return (
|
|
207
|
+
<div className="sc-picker sc-picker-chosen">
|
|
208
|
+
<span className="sc-picker-value">
|
|
209
|
+
{props.value === undefined ? props.held : props.render(props.value)}
|
|
210
|
+
</span>
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
className="sc-picker-clear"
|
|
214
|
+
aria-label={words.remove}
|
|
215
|
+
disabled={props.disabled}
|
|
216
|
+
onClick={() => props.onChange(undefined)}
|
|
217
|
+
>
|
|
218
|
+
<span aria-hidden="true">×</span>
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div className="sc-picker">
|
|
226
|
+
<input
|
|
227
|
+
ref={field}
|
|
228
|
+
className="sc-input"
|
|
229
|
+
type="text"
|
|
230
|
+
role="combobox"
|
|
231
|
+
aria-label={props.label}
|
|
232
|
+
aria-expanded={open}
|
|
233
|
+
aria-controls={listId}
|
|
234
|
+
aria-autocomplete="list"
|
|
235
|
+
aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
|
|
236
|
+
autoComplete="off"
|
|
237
|
+
placeholder={props.placeholder}
|
|
238
|
+
value={query}
|
|
239
|
+
disabled={props.disabled}
|
|
240
|
+
onChange={(event) => {
|
|
241
|
+
setQuery(event.target.value);
|
|
242
|
+
setOpen(true);
|
|
243
|
+
}}
|
|
244
|
+
onFocus={() => setOpen(true)}
|
|
245
|
+
onBlur={() => setOpen(false)}
|
|
246
|
+
onKeyDown={onKeyDown}
|
|
247
|
+
/>
|
|
248
|
+
{open && (
|
|
249
|
+
// The list of a combobox: read and chosen through the input, which keeps the focus; an
|
|
250
|
+
// option is chosen on the press, before the input loses its focus and the list closes.
|
|
251
|
+
<div
|
|
252
|
+
ref={list}
|
|
253
|
+
className={cx("sc-picker-list", above && "sc-picker-list-above")}
|
|
254
|
+
id={listId}
|
|
255
|
+
role="listbox"
|
|
256
|
+
aria-label={props.label}
|
|
257
|
+
>
|
|
258
|
+
{items.map((item, index) => (
|
|
259
|
+
<div
|
|
260
|
+
key={props.keyOf(item)}
|
|
261
|
+
id={`${listId}-${index}`}
|
|
262
|
+
role="option"
|
|
263
|
+
tabIndex={-1}
|
|
264
|
+
aria-selected={index === active}
|
|
265
|
+
className={cx("sc-picker-option", index === active && "sc-picker-option-active")}
|
|
266
|
+
onMouseDown={(event) => {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
choose(item);
|
|
269
|
+
}}
|
|
270
|
+
onMouseEnter={() => setActive(index)}
|
|
271
|
+
>
|
|
272
|
+
{props.render(item)}
|
|
273
|
+
</div>
|
|
274
|
+
))}
|
|
275
|
+
{!loading && items.length === 0 && (
|
|
276
|
+
<div className="sc-picker-note sc-muted">{words.picker.nothingFound}</div>
|
|
277
|
+
)}
|
|
278
|
+
{loading && <div className="sc-picker-note sc-muted">{words.picker.searching}</div>}
|
|
279
|
+
{more && !loading && (
|
|
280
|
+
<div className="sc-picker-note">
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
284
|
+
onMouseDown={(event) => {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
loadMore();
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
{words.picker.more}
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { type FormEvent, useState } from "react";
|
|
1
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
MAX_BODY_LENGTH,
|
|
4
4
|
MAX_LINK_LABEL_LENGTH,
|
|
5
5
|
MAX_LINKS,
|
|
6
6
|
MAX_TITLE_LENGTH,
|
|
7
7
|
MAX_URL_LENGTH,
|
|
8
|
+
MAX_WORKERS,
|
|
8
9
|
type RestCategory,
|
|
9
10
|
type RestGoal,
|
|
10
11
|
type RestPerson,
|
|
@@ -14,22 +15,33 @@ import {
|
|
|
14
15
|
TASK_STATES,
|
|
15
16
|
} from "../api.js";
|
|
16
17
|
import { useWords } from "../i18n/index.js";
|
|
17
|
-
import {
|
|
18
|
+
import { Picker, type PickerSearch } from "./picker.js";
|
|
19
|
+
import { Button, GoalStateBadge, PersonChip, personName, StateBadge } from "./ui.js";
|
|
18
20
|
|
|
19
21
|
// Writing a task, or changing one. The bounds are the package's, said before a request is made.
|
|
20
22
|
// The plan is two days (ADR-0019); what happened to the task is the board's to keep, not a
|
|
21
|
-
// field here.
|
|
23
|
+
// field here. The workers are any number of the people (ADR-0037); the task this one is part of
|
|
24
|
+
// and the goal it works towards are found by searching, a page at a time, never offered whole.
|
|
25
|
+
|
|
26
|
+
/** What words find among the tasks of the project, a page at a time. */
|
|
27
|
+
export type TaskSearch = PickerSearch<RestTask>;
|
|
28
|
+
/** What words find among the open goals of the project, a page at a time. */
|
|
29
|
+
export type GoalSearch = PickerSearch<RestGoal>;
|
|
22
30
|
|
|
23
31
|
export interface TaskFormProps {
|
|
24
32
|
readonly people: readonly RestPerson[];
|
|
25
33
|
/** The task to change; left out, the form writes a new one. */
|
|
26
34
|
readonly task?: RestTask | undefined;
|
|
27
|
-
/** The
|
|
28
|
-
readonly
|
|
35
|
+
/** The task this one is part of, as it stands, for the form to show it; the search finds another. */
|
|
36
|
+
readonly parent?: RestTask | undefined;
|
|
37
|
+
/** Left out, a task cannot be made part of one here. */
|
|
38
|
+
readonly findTasks?: TaskSearch | undefined;
|
|
29
39
|
/** The categories of the workspace, to sort the task into one. */
|
|
30
40
|
readonly categories?: readonly RestCategory[] | undefined;
|
|
31
|
-
/** The
|
|
32
|
-
readonly
|
|
41
|
+
/** The goal the task works towards, as it stands, for the form to show it; the search finds another. */
|
|
42
|
+
readonly goal?: RestGoal | undefined;
|
|
43
|
+
/** Left out, no goal is offered. */
|
|
44
|
+
readonly findGoals?: GoalSearch | undefined;
|
|
33
45
|
readonly busy?: boolean | undefined;
|
|
34
46
|
readonly error?: string | undefined;
|
|
35
47
|
readonly onSubmit: (input: RestTaskInput) => void;
|
|
@@ -41,6 +53,25 @@ interface LinkDraft {
|
|
|
41
53
|
readonly label: string;
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
/** A task as a picker names one: its number and its title, with its state. */
|
|
57
|
+
export function taskLabel(task: RestTask): ReactNode {
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<span className="sc-card-number">#{task.number}</span> {task.title}{" "}
|
|
61
|
+
<StateBadge state={task.state} />
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A goal as a picker names one: its title, with its state. */
|
|
67
|
+
export function goalLabel(goal: RestGoal): ReactNode {
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
{goal.title} <GoalStateBadge state={goal.state} />
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
44
75
|
export function TaskForm(props: TaskFormProps) {
|
|
45
76
|
const words = useWords();
|
|
46
77
|
const form = words.task.form;
|
|
@@ -49,10 +80,16 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
49
80
|
const [body, setBody] = useState(task?.body ?? "");
|
|
50
81
|
const [state, setState] = useState(task?.state ?? "idea");
|
|
51
82
|
const [priority, setPriority] = useState(task?.priority ?? "normal");
|
|
52
|
-
const [
|
|
53
|
-
|
|
83
|
+
const [workerIds, setWorkerIds] = useState<readonly string[]>(
|
|
84
|
+
task?.workers.map((worker) => worker.id) ?? [],
|
|
85
|
+
);
|
|
86
|
+
// What is chosen is kept by id, so that a parent or a goal the page has not read back yet is
|
|
87
|
+
// kept as it is rather than dropped; the one chosen here is kept whole, to be shown.
|
|
88
|
+
const [parentId, setParentId] = useState<string | null>(task?.parentId ?? null);
|
|
89
|
+
const [parentChosen, setParentChosen] = useState<RestTask | undefined>(undefined);
|
|
90
|
+
const [goalId, setGoalId] = useState<string | null>(task?.goalId ?? null);
|
|
91
|
+
const [goalChosen, setGoalChosen] = useState<RestGoal | undefined>(undefined);
|
|
54
92
|
const [category, setCategory] = useState(task?.category?.key ?? "");
|
|
55
|
-
const [goalId, setGoalId] = useState(task?.goalId ?? "");
|
|
56
93
|
const [startsOn, setStartsOn] = useState(task?.startsOn ?? "");
|
|
57
94
|
const [dueOn, setDueOn] = useState(task?.dueOn ?? "");
|
|
58
95
|
const [links, setLinks] = useState<readonly LinkDraft[]>(
|
|
@@ -66,10 +103,10 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
66
103
|
body,
|
|
67
104
|
state,
|
|
68
105
|
priority,
|
|
69
|
-
|
|
70
|
-
parentId
|
|
106
|
+
workerIds: [...workerIds],
|
|
107
|
+
parentId,
|
|
71
108
|
category: category === "" ? null : category,
|
|
72
|
-
goalId
|
|
109
|
+
goalId,
|
|
73
110
|
startsOn: startsOn === "" ? null : startsOn,
|
|
74
111
|
dueOn: dueOn === "" ? null : dueOn,
|
|
75
112
|
links: links
|
|
@@ -81,12 +118,33 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
81
118
|
});
|
|
82
119
|
};
|
|
83
120
|
|
|
84
|
-
|
|
121
|
+
// A worker is one of the people, or one the task had who is no longer among them.
|
|
122
|
+
const personOf = (id: string): RestPerson | undefined =>
|
|
123
|
+
props.people.find((person) => person.id === id) ??
|
|
124
|
+
task?.workers.find((worker) => worker.id === id);
|
|
125
|
+
const chosenWorkers = workerIds.flatMap((id) => {
|
|
126
|
+
const person = personOf(id);
|
|
127
|
+
return person === undefined ? [] : [person];
|
|
128
|
+
});
|
|
129
|
+
const otherPeople = props.people.filter((person) => !workerIds.includes(person.id));
|
|
130
|
+
const parent =
|
|
131
|
+
parentId === null
|
|
132
|
+
? undefined
|
|
133
|
+
: parentChosen?.id === parentId
|
|
134
|
+
? parentChosen
|
|
135
|
+
: props.parent?.id === parentId
|
|
136
|
+
? props.parent
|
|
137
|
+
: undefined;
|
|
138
|
+
const goal =
|
|
139
|
+
goalId === null
|
|
140
|
+
? undefined
|
|
141
|
+
: goalChosen?.id === goalId
|
|
142
|
+
? goalChosen
|
|
143
|
+
: props.goal?.id === goalId
|
|
144
|
+
? props.goal
|
|
145
|
+
: undefined;
|
|
146
|
+
const held = <span className="sc-muted">…</span>;
|
|
85
147
|
const categories = props.categories ?? [];
|
|
86
|
-
// A goal let go is not offered, unless the task is on it already.
|
|
87
|
-
const goals = (props.goals ?? []).filter(
|
|
88
|
-
(goal) => goal.state !== "dropped" || goal.id === task?.goalId,
|
|
89
|
-
);
|
|
90
148
|
const datesInOrder = startsOn === "" || dueOn === "" || startsOn <= dueOn;
|
|
91
149
|
|
|
92
150
|
return (
|
|
@@ -142,21 +200,6 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
142
200
|
))}
|
|
143
201
|
</select>
|
|
144
202
|
</label>
|
|
145
|
-
<label className="sc-field">
|
|
146
|
-
<span className="sc-field-label">{form.assignee}</span>
|
|
147
|
-
<select
|
|
148
|
-
className="sc-input"
|
|
149
|
-
value={assigneeId}
|
|
150
|
-
onChange={(event) => setAssigneeId(event.target.value)}
|
|
151
|
-
>
|
|
152
|
-
<option value="">{words.common.nobody}</option>
|
|
153
|
-
{props.people.map((person) => (
|
|
154
|
-
<option key={person.id} value={person.id}>
|
|
155
|
-
{personName(person)}
|
|
156
|
-
</option>
|
|
157
|
-
))}
|
|
158
|
-
</select>
|
|
159
|
-
</label>
|
|
160
203
|
{categories.length > 0 && (
|
|
161
204
|
<label className="sc-field">
|
|
162
205
|
<span className="sc-field-label">{form.category}</span>
|
|
@@ -175,6 +218,44 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
175
218
|
</label>
|
|
176
219
|
)}
|
|
177
220
|
</div>
|
|
221
|
+
<fieldset className="sc-field sc-workers-field">
|
|
222
|
+
<legend className="sc-field-label">{form.workers}</legend>
|
|
223
|
+
<div className="sc-chips">
|
|
224
|
+
{chosenWorkers.map((person) => (
|
|
225
|
+
<span key={person.id} className="sc-chip">
|
|
226
|
+
<PersonChip person={person} />
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
className="sc-chip-remove"
|
|
230
|
+
aria-label={`${words.common.remove}: ${personName(person)}`}
|
|
231
|
+
onClick={() => setWorkerIds(workerIds.filter((id) => id !== person.id))}
|
|
232
|
+
>
|
|
233
|
+
<span aria-hidden="true">×</span>
|
|
234
|
+
</button>
|
|
235
|
+
</span>
|
|
236
|
+
))}
|
|
237
|
+
{chosenWorkers.length === 0 && <span className="sc-muted">{words.common.nobody}</span>}
|
|
238
|
+
{otherPeople.length > 0 && workerIds.length < MAX_WORKERS && (
|
|
239
|
+
<select
|
|
240
|
+
className="sc-input sc-chip-add"
|
|
241
|
+
aria-label={form.addWorker}
|
|
242
|
+
value=""
|
|
243
|
+
onChange={(event) => {
|
|
244
|
+
if (event.target.value !== "") {
|
|
245
|
+
setWorkerIds([...workerIds, event.target.value]);
|
|
246
|
+
}
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
<option value="">{form.addWorker}</option>
|
|
250
|
+
{otherPeople.map((person) => (
|
|
251
|
+
<option key={person.id} value={person.id}>
|
|
252
|
+
{personName(person)}
|
|
253
|
+
</option>
|
|
254
|
+
))}
|
|
255
|
+
</select>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
</fieldset>
|
|
178
259
|
<div className="sc-field-row">
|
|
179
260
|
<label className="sc-field">
|
|
180
261
|
<span className="sc-field-label">{form.startsOn}</span>
|
|
@@ -195,39 +276,44 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
195
276
|
onChange={(event) => setDueOn(event.target.value)}
|
|
196
277
|
/>
|
|
197
278
|
</label>
|
|
198
|
-
{
|
|
199
|
-
<
|
|
279
|
+
{props.findGoals !== undefined && (
|
|
280
|
+
<div className="sc-field">
|
|
200
281
|
<span className="sc-field-label">{form.goal}</span>
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
{
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
282
|
+
<Picker
|
|
283
|
+
value={goal}
|
|
284
|
+
held={goalId === null ? undefined : held}
|
|
285
|
+
search={props.findGoals}
|
|
286
|
+
keyOf={(candidate) => candidate.id}
|
|
287
|
+
render={goalLabel}
|
|
288
|
+
label={form.goal}
|
|
289
|
+
placeholder={form.searchGoals}
|
|
290
|
+
disabled={props.busy}
|
|
291
|
+
onChange={(chosen) => {
|
|
292
|
+
setGoalChosen(chosen);
|
|
293
|
+
setGoalId(chosen?.id ?? null);
|
|
294
|
+
}}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
214
297
|
)}
|
|
215
|
-
{
|
|
216
|
-
<
|
|
298
|
+
{props.findTasks !== undefined && (
|
|
299
|
+
<div className="sc-field">
|
|
217
300
|
<span className="sc-field-label">{form.partOf}</span>
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
{
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
|
|
301
|
+
<Picker
|
|
302
|
+
value={parent}
|
|
303
|
+
held={parentId === null ? undefined : held}
|
|
304
|
+
search={props.findTasks}
|
|
305
|
+
keyOf={(candidate) => candidate.id}
|
|
306
|
+
render={taskLabel}
|
|
307
|
+
label={form.partOf}
|
|
308
|
+
placeholder={form.searchTasks}
|
|
309
|
+
disabled={props.busy}
|
|
310
|
+
exclude={(candidate) => candidate.id === task?.id}
|
|
311
|
+
onChange={(chosen) => {
|
|
312
|
+
setParentChosen(chosen);
|
|
313
|
+
setParentId(chosen?.id ?? null);
|
|
314
|
+
}}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
231
317
|
)}
|
|
232
318
|
</div>
|
|
233
319
|
<fieldset className="sc-field sc-links">
|
|
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { RestTask } from "../api.js";
|
|
3
3
|
import { useWords } from "../i18n/index.js";
|
|
4
4
|
import { ShowMore, usePaged } from "./paged.js";
|
|
5
|
-
import {
|
|
5
|
+
import { Badge, CategoryBadge, DueBadge, StateBadge, Workers } from "./ui.js";
|
|
6
6
|
|
|
7
7
|
// A list of tasks as rows, where the board's columns are not the point: the calendar's late
|
|
8
8
|
// and unscheduled tasks, a goal's tasks, a person's own work. Each row says what a card says,
|
|
@@ -52,7 +52,7 @@ export function TaskList(props: TaskListProps) {
|
|
|
52
52
|
{task.dueOn !== null && (
|
|
53
53
|
<DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
|
|
54
54
|
)}
|
|
55
|
-
|
|
55
|
+
<Workers workers={task.workers} size="sm" />
|
|
56
56
|
</span>
|
|
57
57
|
</li>
|
|
58
58
|
);
|
|
@@ -21,7 +21,7 @@ import { type CommentDraft, TaskThread } from "./comments.js";
|
|
|
21
21
|
import { DecisionForm } from "./decision-form.js";
|
|
22
22
|
import { EventFeed } from "./event-feed.js";
|
|
23
23
|
import { Markdown } from "./markdown.js";
|
|
24
|
-
import { TaskForm } from "./task-form.js";
|
|
24
|
+
import { type GoalSearch, TaskForm, type TaskSearch } from "./task-form.js";
|
|
25
25
|
import {
|
|
26
26
|
Badge,
|
|
27
27
|
Button,
|
|
@@ -44,8 +44,13 @@ import {
|
|
|
44
44
|
export interface TaskViewProps {
|
|
45
45
|
readonly task: RestTask;
|
|
46
46
|
readonly people: readonly RestPerson[];
|
|
47
|
-
/**
|
|
47
|
+
/** The current tasks of the board, for the breakdown and for naming what this one is part of. */
|
|
48
48
|
readonly tasks: readonly RestTask[];
|
|
49
|
+
/** The task this one is part of, when the page has it: one put away is not among `tasks`. Left out, it is looked for among them. */
|
|
50
|
+
readonly parent?: RestTask | undefined;
|
|
51
|
+
/** What words find among the tasks and the open goals of the project, for the form (ADR-0037); left out, the form offers neither. */
|
|
52
|
+
readonly findTasks?: TaskSearch | undefined;
|
|
53
|
+
readonly findGoals?: GoalSearch | undefined;
|
|
49
54
|
readonly decisions: readonly RestDecision[];
|
|
50
55
|
/** The runs of the board, or of this task; the ones on this task are shown. */
|
|
51
56
|
readonly runs?: readonly RestRun[] | undefined;
|
|
@@ -97,9 +102,10 @@ export function TaskView(props: TaskViewProps) {
|
|
|
97
102
|
const { tag } = useLanguage();
|
|
98
103
|
const { task } = props;
|
|
99
104
|
const parent =
|
|
100
|
-
|
|
105
|
+
props.parent ??
|
|
106
|
+
(task.parentId === null
|
|
101
107
|
? undefined
|
|
102
|
-
: props.tasks.find((candidate) => candidate.id === task.parentId);
|
|
108
|
+
: props.tasks.find((candidate) => candidate.id === task.parentId));
|
|
103
109
|
const subtasks = props.tasks.filter((candidate) => candidate.parentId === task.id);
|
|
104
110
|
const about = props.decisions.filter((decision) => decision.taskId === task.id);
|
|
105
111
|
const runs = (props.runs ?? []).filter((run) => run.taskId === task.id);
|
|
@@ -119,9 +125,11 @@ export function TaskView(props: TaskViewProps) {
|
|
|
119
125
|
<TaskForm
|
|
120
126
|
people={props.people}
|
|
121
127
|
task={task}
|
|
122
|
-
|
|
128
|
+
parent={parent}
|
|
129
|
+
findTasks={props.findTasks}
|
|
123
130
|
categories={props.categories}
|
|
124
|
-
|
|
131
|
+
goal={goal}
|
|
132
|
+
findGoals={props.findGoals}
|
|
125
133
|
busy={props.busy}
|
|
126
134
|
error={props.error}
|
|
127
135
|
onSubmit={(input) => props.onChange(task, input)}
|
|
@@ -289,12 +297,18 @@ export function TaskView(props: TaskViewProps) {
|
|
|
289
297
|
</dd>
|
|
290
298
|
</div>
|
|
291
299
|
<div>
|
|
292
|
-
<dt>{words.task.
|
|
300
|
+
<dt>{words.task.workers}</dt>
|
|
293
301
|
<dd>
|
|
294
|
-
{task.
|
|
302
|
+
{task.workers.length === 0 ? (
|
|
295
303
|
<span className="sc-muted">{words.common.nobody}</span>
|
|
296
304
|
) : (
|
|
297
|
-
<
|
|
305
|
+
<ul className="sc-worker-list">
|
|
306
|
+
{task.workers.map((worker) => (
|
|
307
|
+
<li key={worker.id}>
|
|
308
|
+
<PersonChip person={worker} />
|
|
309
|
+
</li>
|
|
310
|
+
))}
|
|
311
|
+
</ul>
|
|
298
312
|
)}
|
|
299
313
|
</dd>
|
|
300
314
|
</div>
|