@skillcdn/console 0.4.2 → 0.4.3
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/dist/api.d.ts +2 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2 -2
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +26 -14
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +6 -1
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +9 -5
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/plugin-check.d.ts +5 -0
- package/dist/cli/plugin-check.d.ts.map +1 -1
- package/dist/cli/plugin-check.js +22 -0
- package/dist/cli/plugin-check.js.map +1 -1
- package/dist/cli/plugin-cli.d.ts +1 -1
- package/dist/cli/plugin-cli.d.ts.map +1 -1
- package/dist/cli/plugin-cli.js +70 -4
- package/dist/cli/plugin-cli.js.map +1 -1
- package/dist/cli/plugin-init.d.ts +17 -0
- package/dist/cli/plugin-init.d.ts.map +1 -1
- package/dist/cli/plugin-init.js +135 -3
- package/dist/cli/plugin-init.js.map +1 -1
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +16 -5
- package/dist/client.js.map +1 -1
- package/dist/components/comments.d.ts.map +1 -1
- package/dist/components/comments.js +1 -1
- package/dist/components/comments.js.map +1 -1
- package/dist/components/decision-form.d.ts +2 -1
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +36 -4
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/decision-list.d.ts +4 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +29 -3
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/my-decisions.d.ts +2 -0
- package/dist/components/my-decisions.d.ts.map +1 -1
- package/dist/components/my-decisions.js +2 -2
- package/dist/components/my-decisions.js.map +1 -1
- package/dist/components/task-view.d.ts +2 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js.map +1 -1
- package/dist/console.css +3 -1
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +16 -15
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +2 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +26 -7
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +6 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +6 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +6 -0
- package/dist/i18n/ko.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/plugin/context.d.ts +6 -0
- package/dist/plugin/context.d.ts.map +1 -1
- package/dist/schemas.d.ts +77 -9
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +58 -16
- package/dist/schemas.js.map +1 -1
- package/docs/specs/cli.md +1 -1
- package/docs/specs/plugins.md +7 -6
- package/docs/specs/rest.md +8 -7
- package/package.json +7 -7
- package/skills/building-a-plugin/SKILL.md +4 -4
- package/skills/working-the-board/SKILL.md +1 -1
- package/src/api.ts +4 -0
- package/src/cli/cli.ts +40 -22
- package/src/cli/format.ts +12 -1
- package/src/cli/help.ts +9 -4
- package/src/cli/plugin-check.ts +27 -0
- package/src/cli/plugin-cli.ts +94 -5
- package/src/cli/plugin-init.ts +170 -3
- package/src/client.ts +28 -9
- package/src/components/comments.tsx +1 -0
- package/src/components/decision-form.tsx +92 -11
- package/src/components/decision-list.tsx +71 -2
- package/src/components/my-decisions.tsx +4 -1
- package/src/components/task-view.tsx +2 -1
- package/src/console.tsx +18 -12
- package/src/data.ts +32 -9
- package/src/i18n/en.ts +7 -0
- package/src/i18n/ko.ts +7 -0
- package/src/limits.ts +2 -0
- package/src/plugin/context.ts +6 -1
- package/src/schemas.ts +72 -18
- package/src/styles/console.css +3 -1
- package/template/plugin/package.json +3 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type FormEvent, useMemo, useState } from "react";
|
|
1
|
+
import { type ClipboardEvent, type FormEvent, useMemo, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
MAX_BODY_LENGTH,
|
|
4
|
+
MAX_FILES_PER_DECISION,
|
|
4
5
|
MAX_OPTION_LABEL_LENGTH,
|
|
5
6
|
MAX_OPTIONS,
|
|
6
7
|
MAX_QUESTION_LENGTH,
|
|
@@ -10,14 +11,17 @@ import {
|
|
|
10
11
|
type RestTask,
|
|
11
12
|
} from "../api.js";
|
|
12
13
|
import { useWords } from "../i18n/index.js";
|
|
14
|
+
import { isPicture, placing, unusedName } from "./files.js";
|
|
13
15
|
import { Picker, type PickerSearch } from "./picker.js";
|
|
14
16
|
import { type TaskSearch, taskLabel } from "./task-form.js";
|
|
15
|
-
import { Button, PersonChip, personName } from "./ui.js";
|
|
17
|
+
import { Button, formatBytes, PersonChip, personName } from "./ui.js";
|
|
16
18
|
|
|
17
19
|
// Raising a decision: the question, what a person needs to know, the options to choose from,
|
|
18
20
|
// and who is to answer (ADR-0042): the person raising it, unless they name another of the
|
|
19
21
|
// project. The task it is about is the page's, when raised from a task; otherwise it is found
|
|
20
|
-
// by searching the board a page at a time (ADR-0037), never picked from the board whole.
|
|
22
|
+
// by searching the board a page at a time (ADR-0037), never picked from the board whole. The
|
|
23
|
+
// files put on the decision (ADR-0055) are chosen or pasted here, a pasted picture placed in
|
|
24
|
+
// the context where pictures are read, as a comment's are.
|
|
21
25
|
|
|
22
26
|
export interface DecisionFormProps {
|
|
23
27
|
/** The task it is about from the start, when raised from a task's page; the field is not shown then. */
|
|
@@ -30,7 +34,8 @@ export interface DecisionFormProps {
|
|
|
30
34
|
readonly me?: RestPerson | undefined;
|
|
31
35
|
readonly busy?: boolean | undefined;
|
|
32
36
|
readonly error?: string | undefined;
|
|
33
|
-
|
|
37
|
+
/** Called with the decision as the API takes it, and the files put on it (ADR-0055), none when none were chosen. */
|
|
38
|
+
readonly onSubmit: (input: RestDecisionInput, files: readonly File[]) => void;
|
|
34
39
|
readonly onCancel?: (() => void) | undefined;
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -59,20 +64,61 @@ export function DecisionForm(props: DecisionFormProps) {
|
|
|
59
64
|
const [about, setAbout] = useState<RestTask | undefined>(undefined);
|
|
60
65
|
const [options, setOptions] = useState<readonly string[]>(["", ""]);
|
|
61
66
|
const [decider, setDecider] = useState<RestPerson | undefined>(props.me);
|
|
67
|
+
const [files, setFiles] = useState<readonly File[]>([]);
|
|
62
68
|
const findDeciders = useMemo(() => peopleSearch(props.deciders ?? []), [props.deciders]);
|
|
63
69
|
|
|
64
70
|
const labels = options.map((option) => option.trim()).filter((option) => option.length > 0);
|
|
65
71
|
const ready = question.trim().length > 0 && labels.length >= MIN_OPTIONS;
|
|
66
72
|
|
|
73
|
+
const add = (chosen: readonly File[]): File[] => {
|
|
74
|
+
const added: File[] = [];
|
|
75
|
+
let held = files;
|
|
76
|
+
for (const file of chosen) {
|
|
77
|
+
if (held.length >= MAX_FILES_PER_DECISION) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
const name = unusedName(
|
|
81
|
+
file.name,
|
|
82
|
+
held.map((kept) => kept.name),
|
|
83
|
+
);
|
|
84
|
+
const kept = name === file.name ? file : new File([file], name, { type: file.type });
|
|
85
|
+
held = [...held, kept];
|
|
86
|
+
added.push(kept);
|
|
87
|
+
}
|
|
88
|
+
setFiles(held);
|
|
89
|
+
return added;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// A picture pasted into the context goes with the decision, placed where pictures are read.
|
|
93
|
+
const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
94
|
+
const pasted = [...event.clipboardData.files].filter((file) => isPicture(file.type));
|
|
95
|
+
if (pasted.length === 0) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
const added = add(pasted);
|
|
100
|
+
if (added.length > 0) {
|
|
101
|
+
setBody((current) =>
|
|
102
|
+
placing(
|
|
103
|
+
current,
|
|
104
|
+
added.map((file) => file.name),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
67
110
|
const submit = (event: FormEvent) => {
|
|
68
111
|
event.preventDefault();
|
|
69
|
-
props.onSubmit(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
112
|
+
props.onSubmit(
|
|
113
|
+
{
|
|
114
|
+
question: question.trim(),
|
|
115
|
+
body,
|
|
116
|
+
options: labels.map((label) => ({ label })),
|
|
117
|
+
taskId: props.taskId ?? about?.id ?? null,
|
|
118
|
+
...(decider === undefined || decider.id === props.me?.id ? {} : { deciderId: decider.id }),
|
|
119
|
+
},
|
|
120
|
+
files,
|
|
121
|
+
);
|
|
76
122
|
};
|
|
77
123
|
|
|
78
124
|
return (
|
|
@@ -96,8 +142,43 @@ export function DecisionForm(props: DecisionFormProps) {
|
|
|
96
142
|
rows={4}
|
|
97
143
|
placeholder={form.contextPlaceholder}
|
|
98
144
|
onChange={(event) => setBody(event.target.value)}
|
|
145
|
+
onPaste={paste}
|
|
99
146
|
/>
|
|
100
147
|
</label>
|
|
148
|
+
<div className="sc-field">
|
|
149
|
+
<span className="sc-field-label">{form.files}</span>
|
|
150
|
+
{files.length > 0 && (
|
|
151
|
+
<ul className="sc-chosen-files" aria-label={form.files}>
|
|
152
|
+
{files.map((file) => (
|
|
153
|
+
<li key={file.name} className="sc-chosen-file">
|
|
154
|
+
<span>{file.name}</span> <span className="sc-muted">{formatBytes(file.size)}</span>{" "}
|
|
155
|
+
<Button
|
|
156
|
+
variant="ghost"
|
|
157
|
+
size="sm"
|
|
158
|
+
aria-label={form.removeFile(file.name)}
|
|
159
|
+
onClick={() => setFiles((current) => current.filter((held) => held !== file))}
|
|
160
|
+
>
|
|
161
|
+
×
|
|
162
|
+
</Button>
|
|
163
|
+
</li>
|
|
164
|
+
))}
|
|
165
|
+
</ul>
|
|
166
|
+
)}
|
|
167
|
+
<label className="sc-button sc-button-ghost sc-button-sm sc-file-choice">
|
|
168
|
+
{form.chooseFiles}
|
|
169
|
+
<input
|
|
170
|
+
className="sc-file-input"
|
|
171
|
+
type="file"
|
|
172
|
+
multiple
|
|
173
|
+
disabled={props.busy === true || files.length >= MAX_FILES_PER_DECISION}
|
|
174
|
+
onChange={(event) => {
|
|
175
|
+
add([...(event.target.files ?? [])]);
|
|
176
|
+
event.target.value = "";
|
|
177
|
+
}}
|
|
178
|
+
/>
|
|
179
|
+
</label>
|
|
180
|
+
<p className="sc-field-hint">{form.filesHint}</p>
|
|
181
|
+
</div>
|
|
101
182
|
{props.taskId === undefined && props.findTasks !== undefined && (
|
|
102
183
|
<div className="sc-field">
|
|
103
184
|
<span className="sc-field-label">{form.about}</span>
|
|
@@ -6,13 +6,16 @@ import {
|
|
|
6
6
|
MAX_OPTION_LABEL_LENGTH,
|
|
7
7
|
type RestAnswerInput,
|
|
8
8
|
type RestDecision,
|
|
9
|
+
type RestDecisionFile,
|
|
9
10
|
type RestDecisionPatch,
|
|
10
11
|
type RestTask,
|
|
11
12
|
} from "../api.js";
|
|
12
13
|
import { useWords } from "../i18n/index.js";
|
|
14
|
+
import { isPicture, namedFile, placed } from "./files.js";
|
|
13
15
|
import { Markdown } from "./markdown.js";
|
|
14
16
|
import { Pager, pageOf, RECORD_PAGE_SIZE } from "./paged.js";
|
|
15
|
-
import {
|
|
17
|
+
import { Lightbox, PictureThumb, usePicture } from "./pictures.js";
|
|
18
|
+
import { Badge, Button, cx, formatBytes, PersonChip, personName, Time } from "./ui.js";
|
|
16
19
|
|
|
17
20
|
// The decisions: the ones that wait, each a card with the way to answer it, since they are what
|
|
18
21
|
// a person came for; and the ones answered, the record, a row each, since they are read back
|
|
@@ -31,6 +34,8 @@ export interface DecisionCardProps {
|
|
|
31
34
|
readonly href?: string | undefined;
|
|
32
35
|
/** The project the decision is in, named where a page shows the decisions of several. */
|
|
33
36
|
readonly project?: React.ReactNode;
|
|
37
|
+
/** Where a file put on the decision is read, by its id (ADR-0055); left out, the files are named and not linked. */
|
|
38
|
+
readonly fileHref?: ((fileId: string) => string) | undefined;
|
|
34
39
|
/** Called with the person's answer. Left out, a waiting decision cannot be answered here. */
|
|
35
40
|
readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
|
|
36
41
|
/** Called to grow the record, with what followed. Left out, it cannot be written here. */
|
|
@@ -255,6 +260,20 @@ export function DecisionCard(props: DecisionCardProps) {
|
|
|
255
260
|
const words = useWords();
|
|
256
261
|
const { decision, task } = props;
|
|
257
262
|
const answered = decision.answer !== null;
|
|
263
|
+
// The files put on the decision (ADR-0055): a picture the context names by its file name is
|
|
264
|
+
// shown where the text puts it; the rest follow the context, pictures in place and
|
|
265
|
+
// everything else as a link.
|
|
266
|
+
const files = decision.files ?? [];
|
|
267
|
+
const pictures = usePicture();
|
|
268
|
+
const hrefOf = (file: RestDecisionFile) => props.fileHref?.(file.id);
|
|
269
|
+
const named = (source: string): string | undefined => {
|
|
270
|
+
const name = namedFile(source);
|
|
271
|
+
const file = files.find(
|
|
272
|
+
(candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
|
|
273
|
+
);
|
|
274
|
+
return file === undefined ? undefined : hrefOf(file);
|
|
275
|
+
};
|
|
276
|
+
const following = files.filter((file) => !placed(decision.body, file.file.name));
|
|
258
277
|
|
|
259
278
|
return (
|
|
260
279
|
<article id={decision.id} className={cx("sc-decision", answered && "sc-decision-answered")}>
|
|
@@ -289,7 +308,53 @@ export function DecisionCard(props: DecisionCardProps) {
|
|
|
289
308
|
{decision.body.length > 0 && (
|
|
290
309
|
<section className="sc-decision-section" aria-label={words.decisions.context}>
|
|
291
310
|
<h4 className="sc-decision-label">{words.decisions.context}</h4>
|
|
292
|
-
<Markdown
|
|
311
|
+
<Markdown
|
|
312
|
+
source={decision.body}
|
|
313
|
+
docHref={props.docHref}
|
|
314
|
+
fileSrc={named}
|
|
315
|
+
onOpenPicture={pictures.open}
|
|
316
|
+
/>
|
|
317
|
+
</section>
|
|
318
|
+
)}
|
|
319
|
+
{following.length > 0 && (
|
|
320
|
+
<section className="sc-decision-section" aria-label={words.decisions.files}>
|
|
321
|
+
<h4 className="sc-decision-label">{words.decisions.files}</h4>
|
|
322
|
+
<ul className="sc-decision-files">
|
|
323
|
+
{following.map((attached) => {
|
|
324
|
+
const href = hrefOf(attached);
|
|
325
|
+
const { file } = attached;
|
|
326
|
+
if (href !== undefined && isPicture(file.contentType)) {
|
|
327
|
+
return (
|
|
328
|
+
<li key={attached.id} className="sc-decision-picture">
|
|
329
|
+
<PictureThumb
|
|
330
|
+
picture={{
|
|
331
|
+
src: href,
|
|
332
|
+
alt: words.decisions.picture(file.name),
|
|
333
|
+
name: file.name,
|
|
334
|
+
size: file.size,
|
|
335
|
+
}}
|
|
336
|
+
onOpen={pictures.open}
|
|
337
|
+
/>
|
|
338
|
+
<span className="sc-muted">
|
|
339
|
+
{file.name}, {formatBytes(file.size)}
|
|
340
|
+
</span>
|
|
341
|
+
</li>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
return (
|
|
345
|
+
<li key={attached.id}>
|
|
346
|
+
{href === undefined ? (
|
|
347
|
+
<span>{file.name}</span>
|
|
348
|
+
) : (
|
|
349
|
+
<a href={href} rel="nofollow ugc">
|
|
350
|
+
{file.name}
|
|
351
|
+
</a>
|
|
352
|
+
)}{" "}
|
|
353
|
+
<span className="sc-muted">{formatBytes(file.size)}</span>
|
|
354
|
+
</li>
|
|
355
|
+
);
|
|
356
|
+
})}
|
|
357
|
+
</ul>
|
|
293
358
|
</section>
|
|
294
359
|
)}
|
|
295
360
|
{decision.answer !== null ? (
|
|
@@ -322,6 +387,7 @@ export function DecisionCard(props: DecisionCardProps) {
|
|
|
322
387
|
busy={props.busy}
|
|
323
388
|
/>
|
|
324
389
|
)}
|
|
390
|
+
<Lightbox picture={pictures.shown} onClose={pictures.close} />
|
|
325
391
|
</article>
|
|
326
392
|
);
|
|
327
393
|
}
|
|
@@ -332,6 +398,8 @@ export interface DecisionListProps {
|
|
|
332
398
|
readonly tasks: ReadonlyMap<string, RestTask>;
|
|
333
399
|
readonly taskHref?: ((task: RestTask) => string) | undefined;
|
|
334
400
|
readonly docHref?: ((path: string) => string) | undefined;
|
|
401
|
+
/** Where a file put on a decision is read, by its id (ADR-0055). */
|
|
402
|
+
readonly fileHref?: ((fileId: string) => string) | undefined;
|
|
335
403
|
/** Where each decision's own page is. */
|
|
336
404
|
readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
|
|
337
405
|
/** The page of the record shown, from 1, and where each page is (ADR-0010); left out, the record turns its pages in place. */
|
|
@@ -465,6 +533,7 @@ export function DecisionList(props: DecisionListProps) {
|
|
|
465
533
|
task={decision.taskId === null ? undefined : props.tasks.get(decision.taskId)}
|
|
466
534
|
taskHref={props.taskHref}
|
|
467
535
|
docHref={props.docHref}
|
|
536
|
+
fileHref={props.fileHref}
|
|
468
537
|
href={props.decisionHref?.(decision)}
|
|
469
538
|
onAnswer={props.onAnswer}
|
|
470
539
|
onUpdate={props.onUpdate}
|
|
@@ -27,6 +27,8 @@ export interface MyDecisionsProps {
|
|
|
27
27
|
/** The decisions the person asked of someone else: waiting and answered. */
|
|
28
28
|
readonly raised: DecisionsPage;
|
|
29
29
|
readonly decisionHref: (projectKey: string, decisionId: string) => string;
|
|
30
|
+
/** Where a file put on a decision is read, by its project's key and its id (ADR-0055). */
|
|
31
|
+
readonly fileHref?: ((projectKey: string, fileId: string) => string) | undefined;
|
|
30
32
|
/** Called with the person's answer on a decision of theirs, in its project. Left out, a waiting decision is answered on its own page. */
|
|
31
33
|
readonly onAnswer?:
|
|
32
34
|
| ((projectKey: string, decision: RestDecision, input: RestAnswerInput) => void)
|
|
@@ -67,7 +69,7 @@ function More(props: {
|
|
|
67
69
|
export function MyDecisions(props: MyDecisionsProps) {
|
|
68
70
|
const words = useWords();
|
|
69
71
|
const own = words.myDecisions;
|
|
70
|
-
const { onAnswer } = props;
|
|
72
|
+
const { onAnswer, fileHref } = props;
|
|
71
73
|
const waiting = props.addressed.items.filter(({ decision }) => decision.answer === null);
|
|
72
74
|
const asked = props.raised.items.filter(({ decision }) => decision.answer === null);
|
|
73
75
|
const record = [...props.addressed.items, ...props.raised.items]
|
|
@@ -80,6 +82,7 @@ export function MyDecisions(props: MyDecisionsProps) {
|
|
|
80
82
|
decision={item.decision}
|
|
81
83
|
href={props.decisionHref(item.project.key, item.decision.id)}
|
|
82
84
|
project={<InProject project={item.project} />}
|
|
85
|
+
fileHref={fileHref === undefined ? undefined : (fileId) => fileHref(item.project.key, fileId)}
|
|
83
86
|
onAnswer={
|
|
84
87
|
answerable && onAnswer !== undefined
|
|
85
88
|
? (decision, input) => onAnswer(item.project.key, decision, input)
|
|
@@ -83,7 +83,8 @@ export interface TaskViewProps {
|
|
|
83
83
|
readonly error?: string | undefined;
|
|
84
84
|
readonly onChange: (task: RestTask, patch: RestTaskInput) => void;
|
|
85
85
|
readonly onMove: (task: RestTask, state: TaskState) => void;
|
|
86
|
-
|
|
86
|
+
/** Called with a decision raised about the task, and the files put on it (ADR-0055). */
|
|
87
|
+
readonly onRaiseDecision: (input: RestDecisionInput, files: readonly File[]) => void;
|
|
87
88
|
readonly onAnswer: (decision: RestDecision, input: RestAnswerInput) => void;
|
|
88
89
|
/** Called to grow a decision's record with what followed. */
|
|
89
90
|
readonly onUpdateDecision?:
|
package/src/console.tsx
CHANGED
|
@@ -356,6 +356,14 @@ function useFollowLinks(navigation: Navigation): void {
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
/** The key of the project a route is on, or nothing. */
|
|
359
|
+
/** The files chosen or pasted on a page, as the client sends them: each by its name, its bytes and its type where the browser knows one. */
|
|
360
|
+
const uploadsOf = (files: readonly File[]): FileUpload[] =>
|
|
361
|
+
files.map((file) => ({
|
|
362
|
+
name: file.name,
|
|
363
|
+
bytes: file,
|
|
364
|
+
...(file.type === "" ? {} : { contentType: file.type }),
|
|
365
|
+
}));
|
|
366
|
+
|
|
359
367
|
const projectOf = (route: Route): string | undefined =>
|
|
360
368
|
"project" in route ? route.project : undefined;
|
|
361
369
|
|
|
@@ -863,9 +871,9 @@ function TaskPage(props: {
|
|
|
863
871
|
})
|
|
864
872
|
}
|
|
865
873
|
onMove={(target, state) => void act(() => data.actions.updateTask(target.id, { state }))}
|
|
866
|
-
onRaiseDecision={(input) =>
|
|
874
|
+
onRaiseDecision={(input, files) =>
|
|
867
875
|
void act(async () => {
|
|
868
|
-
await data.actions.raiseDecision(input);
|
|
876
|
+
await data.actions.raiseDecision(input, uploadsOf(files));
|
|
869
877
|
navigation.replace(here);
|
|
870
878
|
})
|
|
871
879
|
}
|
|
@@ -889,14 +897,7 @@ function TaskPage(props: {
|
|
|
889
897
|
}
|
|
890
898
|
onComment={(draft) =>
|
|
891
899
|
void act(async () => {
|
|
892
|
-
await data.actions.comment(task.id, {
|
|
893
|
-
body: draft.body,
|
|
894
|
-
files: draft.files.map((file) => ({
|
|
895
|
-
name: file.name,
|
|
896
|
-
bytes: file,
|
|
897
|
-
...(file.type === "" ? {} : { contentType: file.type }),
|
|
898
|
-
})),
|
|
899
|
-
});
|
|
900
|
+
await data.actions.comment(task.id, { body: draft.body, files: uploadsOf(draft.files) });
|
|
900
901
|
setComments(await taskComments(task.id));
|
|
901
902
|
})
|
|
902
903
|
}
|
|
@@ -1311,6 +1312,7 @@ function ProjectPage(props: {
|
|
|
1311
1312
|
tasks={tasksById}
|
|
1312
1313
|
taskHref={href}
|
|
1313
1314
|
docHref={toDoc}
|
|
1315
|
+
fileHref={(fileId) => projectPath(key, "files", fileId)}
|
|
1314
1316
|
decisionHref={(decision) => decisionHref(key, decision.id)}
|
|
1315
1317
|
page={route.page}
|
|
1316
1318
|
pageHref={(page) => decisionsHref(key, page)}
|
|
@@ -1337,9 +1339,9 @@ function ProjectPage(props: {
|
|
|
1337
1339
|
deciders={data.members.map((member) => member.person)}
|
|
1338
1340
|
me={data.me?.person ?? undefined}
|
|
1339
1341
|
busy={busy}
|
|
1340
|
-
onSubmit={(input: RestDecisionInput) =>
|
|
1342
|
+
onSubmit={(input: RestDecisionInput, files: readonly File[]) =>
|
|
1341
1343
|
void act(async () => {
|
|
1342
|
-
const made = await data.actions.raiseDecision(input);
|
|
1344
|
+
const made = await data.actions.raiseDecision(input, uploadsOf(files));
|
|
1343
1345
|
navigation.replace(decisionHref(key, made.id));
|
|
1344
1346
|
})
|
|
1345
1347
|
}
|
|
@@ -1366,6 +1368,7 @@ function ProjectPage(props: {
|
|
|
1366
1368
|
task={decision.taskId === null ? undefined : tasksById.get(decision.taskId)}
|
|
1367
1369
|
taskHref={href}
|
|
1368
1370
|
docHref={toDoc}
|
|
1371
|
+
fileHref={(fileId) => projectPath(key, "files", fileId)}
|
|
1369
1372
|
onAnswer={onAnswer}
|
|
1370
1373
|
onUpdate={onUpdate}
|
|
1371
1374
|
busy={busy}
|
|
@@ -2301,6 +2304,7 @@ function MyDecisionsPage(props: { readonly data: ConsoleData }) {
|
|
|
2301
2304
|
addressed={{ items: addressed.items, more: addressed.more, onMore: addressed.readMore }}
|
|
2302
2305
|
raised={{ items: raised.items, more: raised.more, onMore: raised.readMore }}
|
|
2303
2306
|
decisionHref={decisionHref}
|
|
2307
|
+
fileHref={(projectKey, fileId) => projectPath(projectKey, "files", fileId)}
|
|
2304
2308
|
onAnswer={onAnswer}
|
|
2305
2309
|
busy={busy || addressed.busy || raised.busy}
|
|
2306
2310
|
/>
|
|
@@ -2575,6 +2579,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
|
2575
2579
|
const languages = config.languages ?? DEFAULT_LANGUAGES;
|
|
2576
2580
|
const pages = config.pages ?? [];
|
|
2577
2581
|
|
|
2582
|
+
// biome-ignore lint/correctness/noComponentHookFactories: createConsole runs once, when a console is built, never during a render; App is that console's one root component, closing over its config.
|
|
2578
2583
|
function App(props: { readonly initialPath?: string | undefined }) {
|
|
2579
2584
|
const { location, navigation } = useNavigation(props.initialPath);
|
|
2580
2585
|
useFollowLinks(navigation);
|
|
@@ -2659,6 +2664,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
|
2659
2664
|
href: PATHS.decisions,
|
|
2660
2665
|
label: words.nav.myDecisions,
|
|
2661
2666
|
current: route.name === "my-decisions",
|
|
2667
|
+
count: data.me?.decisions?.waiting,
|
|
2662
2668
|
},
|
|
2663
2669
|
{
|
|
2664
2670
|
href: PATHS.people,
|
package/src/data.ts
CHANGED
|
@@ -125,7 +125,8 @@ export interface ConsoleActions {
|
|
|
125
125
|
page: { readonly limit: number; readonly offset: number },
|
|
126
126
|
signal?: AbortSignal,
|
|
127
127
|
): Promise<RestGoals>;
|
|
128
|
-
|
|
128
|
+
/** Raises a decision in the project; with `files`, the files put on it (ADR-0055). */
|
|
129
|
+
raiseDecision(input: RestDecisionInput, files?: readonly FileUpload[]): Promise<RestDecision>;
|
|
129
130
|
answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
|
|
130
131
|
/** Answers a decision of any project the person may see, by its project's key: for the person's own page of decisions (ADR-0042). */
|
|
131
132
|
answerDecisionIn(projectKey: string, id: string, input: RestAnswerInput): Promise<RestDecision>;
|
|
@@ -382,6 +383,20 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
|
|
|
382
383
|
|
|
383
384
|
const reload = useCallback(() => setGeneration((current) => current + 1), []);
|
|
384
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Asks again who is signed in, for what moves with the board: the decisions waiting for the
|
|
388
|
+
* person, said beside My decisions. What fails stays as it was.
|
|
389
|
+
*/
|
|
390
|
+
const refreshMe = useCallback(
|
|
391
|
+
async (signal?: AbortSignal) => {
|
|
392
|
+
const answer = await client.me(signal);
|
|
393
|
+
if (signal?.aborted !== true) {
|
|
394
|
+
setMe(answer);
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
[client],
|
|
398
|
+
);
|
|
399
|
+
|
|
385
400
|
/** Loads the workspace's lists again. What fails stays as it was. */
|
|
386
401
|
const refreshWorkspace = useCallback(
|
|
387
402
|
async (signal?: AbortSignal) => {
|
|
@@ -571,6 +586,11 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
|
|
|
571
586
|
if (event.kind.startsWith("project.")) {
|
|
572
587
|
refreshWorkspace(signal).catch(() => undefined);
|
|
573
588
|
}
|
|
589
|
+
// A decision raised or answered here may be one of the person's own: the count beside
|
|
590
|
+
// My decisions follows it.
|
|
591
|
+
if (event.kind.startsWith("decision.")) {
|
|
592
|
+
refreshMe(signal).catch(() => undefined);
|
|
593
|
+
}
|
|
574
594
|
}, RELOAD_DELAY_MS);
|
|
575
595
|
};
|
|
576
596
|
for (const kind of EVENT_KINDS) {
|
|
@@ -584,7 +604,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
|
|
|
584
604
|
source?.close();
|
|
585
605
|
setLive(false);
|
|
586
606
|
};
|
|
587
|
-
}, [client, projectKey, signedIn, generation, refreshProject, refreshWorkspace]);
|
|
607
|
+
}, [client, projectKey, signedIn, generation, refreshProject, refreshWorkspace, refreshMe]);
|
|
588
608
|
|
|
589
609
|
const actions = useMemo<ConsoleActions>(() => {
|
|
590
610
|
/** The project the page is on, for an action that needs one. */
|
|
@@ -684,16 +704,19 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
|
|
|
684
704
|
scope().tasks({ archived: false, ...searchWordsOf(query), ...page }, signal),
|
|
685
705
|
findGoals: (query, page, signal) =>
|
|
686
706
|
scope().goals({ open: true, ...searchWordsOf(query), ...page }, signal),
|
|
687
|
-
async raiseDecision(input) {
|
|
688
|
-
const decision = await scope().raiseDecision(input);
|
|
689
|
-
await refreshProject();
|
|
707
|
+
async raiseDecision(input, files) {
|
|
708
|
+
const decision = await scope().raiseDecision(input, files);
|
|
709
|
+
await Promise.all([refreshProject(), refreshMe().catch(() => undefined)]);
|
|
710
|
+
return decision;
|
|
711
|
+
},
|
|
712
|
+
async answerDecisionIn(projectKey, id, input) {
|
|
713
|
+
const decision = await client.project(projectKey).answerDecision(id, input);
|
|
714
|
+
await refreshMe().catch(() => undefined);
|
|
690
715
|
return decision;
|
|
691
716
|
},
|
|
692
|
-
answerDecisionIn: (projectKey, id, input) =>
|
|
693
|
-
client.project(projectKey).answerDecision(id, input),
|
|
694
717
|
async answerDecision(id, input) {
|
|
695
718
|
const decision = await scope().answerDecision(id, input);
|
|
696
|
-
await refreshProject();
|
|
719
|
+
await Promise.all([refreshProject(), refreshMe().catch(() => undefined)]);
|
|
697
720
|
return decision;
|
|
698
721
|
},
|
|
699
722
|
async updateDecision(id, patch) {
|
|
@@ -834,7 +857,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
|
|
|
834
857
|
setLoaded(false);
|
|
835
858
|
},
|
|
836
859
|
};
|
|
837
|
-
}, [client, projectKey, refreshProject, refreshWorkspace]);
|
|
860
|
+
}, [client, projectKey, refreshProject, refreshWorkspace, refreshMe]);
|
|
838
861
|
|
|
839
862
|
return {
|
|
840
863
|
me,
|
package/src/i18n/en.ts
CHANGED
|
@@ -548,6 +548,8 @@ export const en = {
|
|
|
548
548
|
about: "about",
|
|
549
549
|
answeredBy: "answered",
|
|
550
550
|
context: "Context",
|
|
551
|
+
files: "Files",
|
|
552
|
+
picture: (name: string) => `The picture ${name}`,
|
|
551
553
|
whatFollowed: "What followed",
|
|
552
554
|
nothingWritten: "Nothing written yet.",
|
|
553
555
|
outcomePlaceholder: "What was done with the answer, and what came of it, in Markdown.",
|
|
@@ -557,6 +559,11 @@ export const en = {
|
|
|
557
559
|
question: "Question",
|
|
558
560
|
context: "Context",
|
|
559
561
|
contextPlaceholder: "What a person needs to know to answer, in Markdown.",
|
|
562
|
+
files: "Files",
|
|
563
|
+
chooseFiles: "Add files",
|
|
564
|
+
filesHint:
|
|
565
|
+
"A picture pasted or chosen here goes with the decision;  places it in the context.",
|
|
566
|
+
removeFile: (name: string) => `Take ${name} out`,
|
|
560
567
|
about: "About",
|
|
561
568
|
noTask: "No task in particular",
|
|
562
569
|
decider: "Who decides",
|
package/src/i18n/ko.ts
CHANGED
|
@@ -489,6 +489,8 @@ export const ko: Messages = {
|
|
|
489
489
|
about: "관련 작업:",
|
|
490
490
|
answeredBy: "답변",
|
|
491
491
|
context: "맥락",
|
|
492
|
+
files: "파일",
|
|
493
|
+
picture: (name) => `그림 ${name}`,
|
|
492
494
|
whatFollowed: "그 뒤에 일어난 일",
|
|
493
495
|
nothingWritten: "아직 적힌 것이 없습니다.",
|
|
494
496
|
outcomePlaceholder: "답변으로 무엇을 했고 무엇이 됐는지, Markdown으로.",
|
|
@@ -498,6 +500,11 @@ export const ko: Messages = {
|
|
|
498
500
|
question: "질문",
|
|
499
501
|
context: "맥락",
|
|
500
502
|
contextPlaceholder: "답하려면 알아야 할 것, Markdown으로.",
|
|
503
|
+
files: "파일",
|
|
504
|
+
chooseFiles: "파일 추가",
|
|
505
|
+
filesHint:
|
|
506
|
+
"여기에 붙여 넣거나 고른 그림은 결정과 함께 올라갑니다. 으로 맥락에 넣을 수 있습니다.",
|
|
507
|
+
removeFile: (name) => `${name} 빼기`,
|
|
501
508
|
about: "관련 작업",
|
|
502
509
|
noTask: "특정 작업 없음",
|
|
503
510
|
decider: "결정할 사람",
|
package/src/limits.ts
CHANGED
|
@@ -33,6 +33,8 @@ export const MAX_ARTIFACTS_PER_RUN = 50;
|
|
|
33
33
|
/** How many comments one task carries, and how many files one comment carries, together within `MAX_FILE_BYTES`. */
|
|
34
34
|
export const MAX_COMMENTS_PER_TASK = 500;
|
|
35
35
|
export const MAX_FILES_PER_COMMENT = 10;
|
|
36
|
+
/** How many files one decision carries, put on it when it is raised (ADR-0055), together within `MAX_FILE_BYTES`. */
|
|
37
|
+
export const MAX_FILES_PER_DECISION = 10;
|
|
36
38
|
/** A file a run hands in: how many bytes, and its name, one line without a path. */
|
|
37
39
|
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
38
40
|
export const MAX_FILE_NAME_LENGTH = 200;
|
package/src/plugin/context.ts
CHANGED
|
@@ -255,7 +255,12 @@ export interface PluginWorkspace {
|
|
|
255
255
|
export interface DecisionAsk {
|
|
256
256
|
readonly question: string;
|
|
257
257
|
readonly body?: string;
|
|
258
|
-
|
|
258
|
+
/**
|
|
259
|
+
* The options, each a label and optionally a key (spelled as a plugin's key is, each once),
|
|
260
|
+
* which the answered event carries back as `optionKey`, so that the plugin reads the answer
|
|
261
|
+
* without comparing labels.
|
|
262
|
+
*/
|
|
263
|
+
readonly options: readonly { readonly label: string; readonly key?: string }[];
|
|
259
264
|
/** Who is to answer: a person who may work in the project; the project's owners when left out. */
|
|
260
265
|
readonly deciderId?: string;
|
|
261
266
|
readonly taskId?: string;
|