@skillcdn/console 0.1.16 → 0.1.18
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 +4 -3
- package/dist/api.d.ts +4 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +3 -2
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +12 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +226 -21
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +8 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +31 -5
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +34 -8
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/install.d.ts +21 -0
- package/dist/cli/install.d.ts.map +1 -0
- package/dist/cli/install.js +87 -0
- package/dist/cli/install.js.map +1 -0
- package/dist/cli/main.js +24 -4
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/notices.d.ts +13 -0
- package/dist/cli/notices.d.ts.map +1 -0
- package/dist/cli/notices.js +31 -0
- package/dist/cli/notices.js.map +1 -0
- package/dist/cli/terminal.d.ts +5 -0
- package/dist/cli/terminal.d.ts.map +1 -1
- package/dist/cli/terminal.js +11 -1
- package/dist/cli/terminal.js.map +1 -1
- package/dist/client.d.ts +43 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +69 -4
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +66 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +115 -0
- package/dist/compat.js.map +1 -0
- package/dist/components/calendar.d.ts +3 -1
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +21 -2
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/comments.d.ts +59 -0
- package/dist/components/comments.d.ts.map +1 -0
- package/dist/components/comments.js +138 -0
- package/dist/components/comments.js.map +1 -0
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +2 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/goals.d.ts +3 -1
- package/dist/components/goals.d.ts.map +1 -1
- package/dist/components/goals.js +23 -11
- package/dist/components/goals.js.map +1 -1
- package/dist/components/markdown.d.ts +6 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +2 -1
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +6 -1
- package/dist/components/projects.js.map +1 -1
- package/dist/components/roadmap.d.ts +37 -0
- package/dist/components/roadmap.d.ts.map +1 -0
- package/dist/components/roadmap.js +76 -0
- package/dist/components/roadmap.js.map +1 -0
- package/dist/components/task-view.d.ts +8 -3
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/tokens.d.ts +2 -0
- package/dist/components/tokens.d.ts.map +1 -1
- package/dist/components/tokens.js +13 -3
- package/dist/components/tokens.js.map +1 -1
- package/dist/console.css +314 -2
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +30 -11
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +5 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +3 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +38 -2
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +38 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +38 -2
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +3 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +3 -0
- package/dist/limits.js.map +1 -1
- package/dist/router.d.ts +6 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +10 -1
- package/dist/router.js.map +1 -1
- package/dist/rules.js +2 -2
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +147 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +50 -1
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +1 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +17 -10
- package/docs/specs/rest.md +17 -6
- package/package.json +3 -2
- package/skills/working-the-board/SKILL.md +8 -5
- package/src/api.ts +35 -0
- package/src/cli/cli.ts +273 -22
- package/src/cli/format.ts +36 -4
- package/src/cli/help.ts +38 -7
- package/src/cli/install.ts +111 -0
- package/src/cli/main.ts +25 -4
- package/src/cli/notices.ts +48 -0
- package/src/cli/terminal.ts +12 -1
- package/src/client.ts +142 -8
- package/src/compat.ts +173 -0
- package/src/components/calendar.tsx +41 -1
- package/src/components/comments.tsx +342 -0
- package/src/components/event-feed.tsx +2 -0
- package/src/components/goals.tsx +86 -7
- package/src/components/markdown.tsx +9 -1
- package/src/components/projects.tsx +16 -3
- package/src/components/roadmap.tsx +242 -0
- package/src/components/task-view.tsx +32 -33
- package/src/components/tokens.tsx +38 -1
- package/src/console.tsx +45 -3
- package/src/data.ts +9 -1
- package/src/i18n/en.ts +40 -2
- package/src/i18n/ko.ts +40 -2
- package/src/index.ts +20 -0
- package/src/limits.ts +3 -0
- package/src/router.ts +13 -2
- package/src/rules.ts +2 -2
- package/src/schemas.ts +60 -1
- package/src/styles/console.css +314 -2
- package/src/vocabulary.ts +1 -0
|
@@ -48,9 +48,11 @@ export interface CalendarProps {
|
|
|
48
48
|
readonly today?: string | undefined;
|
|
49
49
|
/** The workspace's categories, for the legend to say what the colours mean. */
|
|
50
50
|
readonly categories?: readonly RestCategory[] | undefined;
|
|
51
|
-
/** The project's goals, so that the task under the pointer is shown with its goal's title. */
|
|
51
|
+
/** The project's goals, so that the task under the pointer is shown with its goal's title, and, given `goalHref`, so that each goal marks its target day. */
|
|
52
52
|
readonly goals?: readonly RestGoal[] | undefined;
|
|
53
53
|
readonly taskHref: (task: RestTask) => string;
|
|
54
|
+
/** Where a goal's page is: given, the goals mark their target days on the month (ADR-0026). */
|
|
55
|
+
readonly goalHref?: ((goal: RestGoal) => string) | undefined;
|
|
54
56
|
/** Where the calendar is on another month. */
|
|
55
57
|
readonly monthHref: (month: string) => string;
|
|
56
58
|
}
|
|
@@ -324,12 +326,32 @@ interface WeekRowProps {
|
|
|
324
326
|
readonly pointed: Pointed | null;
|
|
325
327
|
/** The project's goals, so that the item under the pointer names its goal; none across projects. */
|
|
326
328
|
readonly goals?: readonly RestGoal[] | undefined;
|
|
329
|
+
/** The goals whose target day is each day, where the goals mark the month. */
|
|
330
|
+
readonly goalsOn?: ReadonlyMap<string, readonly RestGoal[]> | undefined;
|
|
331
|
+
readonly goalHref?: ((goal: RestGoal) => string) | undefined;
|
|
327
332
|
/** The pointer, or the focus, arrived on a task's item on a day. */
|
|
328
333
|
readonly onPoint: (task: RestTask, day: string, item: HTMLElement) => void;
|
|
329
334
|
/** It left. */
|
|
330
335
|
readonly onLeave: () => void;
|
|
331
336
|
}
|
|
332
337
|
|
|
338
|
+
/** The goals that aim at each day, by day: those with a target day, still planned or worked towards. */
|
|
339
|
+
function goalsOnDays(goals: readonly RestGoal[]): ReadonlyMap<string, readonly RestGoal[]> {
|
|
340
|
+
const on = new Map<string, RestGoal[]>();
|
|
341
|
+
for (const goal of goals) {
|
|
342
|
+
if (goal.dueOn === null || goal.state === "dropped") {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const list = on.get(goal.dueOn);
|
|
346
|
+
if (list === undefined) {
|
|
347
|
+
on.set(goal.dueOn, [goal]);
|
|
348
|
+
} else {
|
|
349
|
+
list.push(goal);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return on;
|
|
353
|
+
}
|
|
354
|
+
|
|
333
355
|
/** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
|
|
334
356
|
function WeekRow(props: WeekRowProps) {
|
|
335
357
|
const { week, segments, now } = props;
|
|
@@ -361,6 +383,21 @@ function WeekRow(props: WeekRowProps) {
|
|
|
361
383
|
)}
|
|
362
384
|
>
|
|
363
385
|
<span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
|
|
386
|
+
{props.goalHref !== undefined &&
|
|
387
|
+
(props.goalsOn?.get(cell.day) ?? []).map((goal) => (
|
|
388
|
+
<a
|
|
389
|
+
key={goal.id}
|
|
390
|
+
className={cx(
|
|
391
|
+
"sc-calendar-goal",
|
|
392
|
+
`sc-category-${goal.category?.color ?? "gray"}`,
|
|
393
|
+
goal.state === "done" && "sc-calendar-over",
|
|
394
|
+
)}
|
|
395
|
+
href={props.goalHref?.(goal)}
|
|
396
|
+
>
|
|
397
|
+
<span className="sc-calendar-flag" aria-hidden="true" />
|
|
398
|
+
<span className="sc-calendar-task-title">{goal.title}</span>
|
|
399
|
+
</a>
|
|
400
|
+
))}
|
|
364
401
|
{Array.from({ length: lanes }, (_, lane) => {
|
|
365
402
|
const segment = segments.find(
|
|
366
403
|
(candidate) =>
|
|
@@ -435,6 +472,7 @@ export function Calendar(props: CalendarProps) {
|
|
|
435
472
|
const firstDay = firstDayOfWeek(tag);
|
|
436
473
|
const weeks = monthGrid(props.month, firstDay);
|
|
437
474
|
const on = onDays(props.tasks);
|
|
475
|
+
const goalsOn = props.goalHref === undefined ? undefined : goalsOnDays(props.goals ?? []);
|
|
438
476
|
const inMonth = (day: string) => monthOf(day) === props.month;
|
|
439
477
|
const monthWeeks = weeks.map((week) => ({ week, segments: weekSegments(week, props.tasks) }));
|
|
440
478
|
const nothing =
|
|
@@ -492,6 +530,8 @@ export function Calendar(props: CalendarProps) {
|
|
|
492
530
|
taskHref={props.taskHref}
|
|
493
531
|
pointed={pointed}
|
|
494
532
|
goals={props.goals}
|
|
533
|
+
goalsOn={goalsOn}
|
|
534
|
+
goalHref={props.goalHref}
|
|
495
535
|
onPoint={point}
|
|
496
536
|
onLeave={leave}
|
|
497
537
|
people
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { type ClipboardEvent, type FormEvent, type ReactNode, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
agentLabel,
|
|
4
|
+
MAX_BODY_LENGTH,
|
|
5
|
+
MAX_FILES_PER_COMMENT,
|
|
6
|
+
type RestComment,
|
|
7
|
+
type RestCommentFile,
|
|
8
|
+
type RestDecision,
|
|
9
|
+
type RestRun,
|
|
10
|
+
type RestTask,
|
|
11
|
+
} from "../api.js";
|
|
12
|
+
import { useWords } from "../i18n/index.js";
|
|
13
|
+
import { DecisionCard, type DecisionCardProps } from "./decision-list.js";
|
|
14
|
+
import { Markdown } from "./markdown.js";
|
|
15
|
+
import { RunCard } from "./runs.js";
|
|
16
|
+
import { Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
|
|
17
|
+
|
|
18
|
+
// The discussion on a task (ADR-0024): what people and agents said about it, each word with
|
|
19
|
+
// the files put in it, read in one thread with the runs on the task and the decisions about
|
|
20
|
+
// it, in the order they came. Takes its data as props and nothing from the network.
|
|
21
|
+
|
|
22
|
+
/** The kinds of picture the pages show in place: what a browser renders and cannot run. */
|
|
23
|
+
const PICTURE_TYPES: ReadonlySet<string> = new Set([
|
|
24
|
+
"image/png",
|
|
25
|
+
"image/jpeg",
|
|
26
|
+
"image/gif",
|
|
27
|
+
"image/webp",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** Whether a file is a picture the pages show in place, by the media type the console kept. */
|
|
31
|
+
export function isPicture(contentType: string): boolean {
|
|
32
|
+
return PICTURE_TYPES.has(contentType);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether the text places a file by its name, as `` or ``. */
|
|
36
|
+
function placed(body: string, name: string): boolean {
|
|
37
|
+
return body.includes(`](${name})`) || body.includes(`](./${name})`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CommentCardProps {
|
|
41
|
+
readonly comment: RestComment;
|
|
42
|
+
/** Where a file put in the comment is read: the project's address for it. Left out, a file is named and not linked. */
|
|
43
|
+
readonly fileHref?: ((file: RestCommentFile) => string) | undefined;
|
|
44
|
+
/** Where a document of the project is read, by its path, for the links in what was written. */
|
|
45
|
+
readonly docHref?: ((path: string) => string) | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function CommentCard(props: CommentCardProps) {
|
|
49
|
+
const words = useWords();
|
|
50
|
+
const { comment } = props;
|
|
51
|
+
const hrefOf = (file: RestCommentFile) => props.fileHref?.(file);
|
|
52
|
+
// A picture the text names by its file name is shown where the text puts it; the rest of
|
|
53
|
+
// the files follow the text, pictures in place and everything else as a link.
|
|
54
|
+
const named = (source: string): string | undefined => {
|
|
55
|
+
const name = source.startsWith("./") ? source.slice(2) : source;
|
|
56
|
+
const file = comment.files.find(
|
|
57
|
+
(candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
|
|
58
|
+
);
|
|
59
|
+
return file === undefined ? undefined : hrefOf(file);
|
|
60
|
+
};
|
|
61
|
+
const following = comment.files.filter((file) => !placed(comment.body, file.file.name));
|
|
62
|
+
return (
|
|
63
|
+
<article
|
|
64
|
+
className="sc-comment"
|
|
65
|
+
aria-label={words.comments.label(personName(comment.author))}
|
|
66
|
+
id={comment.id}
|
|
67
|
+
>
|
|
68
|
+
<header className="sc-comment-header">
|
|
69
|
+
<PersonChip person={comment.author} />
|
|
70
|
+
{comment.agent !== null && (
|
|
71
|
+
<span className="sc-feed-agent" title={comment.agent}>
|
|
72
|
+
{words.feed.as(agentLabel(comment.agent))}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
<span className="sc-comment-meta">
|
|
76
|
+
<Time iso={comment.createdAt} />
|
|
77
|
+
</span>
|
|
78
|
+
</header>
|
|
79
|
+
{comment.body.trim().length > 0 && (
|
|
80
|
+
<Markdown source={comment.body} docHref={props.docHref} fileSrc={named} />
|
|
81
|
+
)}
|
|
82
|
+
{following.length > 0 && (
|
|
83
|
+
<ul className="sc-comment-files" aria-label={words.comments.files}>
|
|
84
|
+
{following.map((attached) => {
|
|
85
|
+
const href = hrefOf(attached);
|
|
86
|
+
const { file } = attached;
|
|
87
|
+
if (href !== undefined && isPicture(file.contentType)) {
|
|
88
|
+
return (
|
|
89
|
+
<li key={attached.id} className="sc-comment-picture">
|
|
90
|
+
<a href={href} rel="noopener nofollow ugc" target="_blank">
|
|
91
|
+
<img
|
|
92
|
+
src={href}
|
|
93
|
+
alt={words.comments.picture(file.name)}
|
|
94
|
+
loading="lazy"
|
|
95
|
+
decoding="async"
|
|
96
|
+
/>
|
|
97
|
+
</a>
|
|
98
|
+
<span className="sc-muted">
|
|
99
|
+
{file.name}, {formatBytes(file.size)}
|
|
100
|
+
</span>
|
|
101
|
+
</li>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return (
|
|
105
|
+
<li key={attached.id}>
|
|
106
|
+
{href === undefined ? (
|
|
107
|
+
<span>{file.name}</span>
|
|
108
|
+
) : (
|
|
109
|
+
<a href={href} rel="nofollow ugc">
|
|
110
|
+
{file.name}
|
|
111
|
+
</a>
|
|
112
|
+
)}{" "}
|
|
113
|
+
<span className="sc-muted">{formatBytes(file.size)}</span>
|
|
114
|
+
</li>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</ul>
|
|
118
|
+
)}
|
|
119
|
+
</article>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What a comment form hands over: the word, and the files chosen or pasted. */
|
|
124
|
+
export interface CommentDraft {
|
|
125
|
+
readonly body: string;
|
|
126
|
+
readonly files: readonly File[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface CommentFormProps {
|
|
130
|
+
readonly onSubmit: (draft: CommentDraft) => void;
|
|
131
|
+
readonly busy?: boolean | undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A name for a file among those chosen already: the same, or with a count before its extension. */
|
|
135
|
+
function unusedName(name: string, taken: readonly File[]): string {
|
|
136
|
+
const names = new Set(taken.map((file) => file.name));
|
|
137
|
+
if (!names.has(name)) {
|
|
138
|
+
return name;
|
|
139
|
+
}
|
|
140
|
+
const dot = name.lastIndexOf(".");
|
|
141
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
142
|
+
const extension = dot > 0 ? name.slice(dot) : "";
|
|
143
|
+
for (let n = 2; ; n += 1) {
|
|
144
|
+
const candidate = `${stem}-${n}${extension}`;
|
|
145
|
+
if (!names.has(candidate)) {
|
|
146
|
+
return candidate;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function CommentForm(props: CommentFormProps) {
|
|
152
|
+
const words = useWords().comments;
|
|
153
|
+
const [body, setBody] = useState("");
|
|
154
|
+
const [files, setFiles] = useState<readonly File[]>([]);
|
|
155
|
+
const empty = body.trim().length === 0 && files.length === 0;
|
|
156
|
+
|
|
157
|
+
const add = (chosen: readonly File[]): File[] => {
|
|
158
|
+
const added: File[] = [];
|
|
159
|
+
let held = files;
|
|
160
|
+
for (const file of chosen) {
|
|
161
|
+
if (held.length >= MAX_FILES_PER_COMMENT) {
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
const name = unusedName(file.name, held);
|
|
165
|
+
const kept = name === file.name ? file : new File([file], name, { type: file.type });
|
|
166
|
+
held = [...held, kept];
|
|
167
|
+
added.push(kept);
|
|
168
|
+
}
|
|
169
|
+
setFiles(held);
|
|
170
|
+
return added;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// A picture pasted in goes with the comment, placed in the text where pictures are read.
|
|
174
|
+
const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
175
|
+
const pasted = [...event.clipboardData.files].filter((file) => isPicture(file.type));
|
|
176
|
+
if (pasted.length === 0) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
const added = add(pasted);
|
|
181
|
+
if (added.length > 0) {
|
|
182
|
+
const lines = added.map((file) => ``).join("\n");
|
|
183
|
+
setBody((current) =>
|
|
184
|
+
current.trim().length === 0 ? lines : `${current.trimEnd()}\n\n${lines}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const submit = (event: FormEvent) => {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
if (empty || props.busy === true) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
props.onSubmit({ body, files });
|
|
195
|
+
setBody("");
|
|
196
|
+
setFiles([]);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<form className="sc-comment-form" aria-label={words.write} onSubmit={submit}>
|
|
201
|
+
<textarea
|
|
202
|
+
className="sc-input sc-textarea"
|
|
203
|
+
aria-label={words.write}
|
|
204
|
+
placeholder={words.placeholder}
|
|
205
|
+
maxLength={MAX_BODY_LENGTH}
|
|
206
|
+
rows={4}
|
|
207
|
+
value={body}
|
|
208
|
+
disabled={props.busy === true}
|
|
209
|
+
onChange={(event) => setBody(event.target.value)}
|
|
210
|
+
onPaste={paste}
|
|
211
|
+
/>
|
|
212
|
+
{files.length > 0 && (
|
|
213
|
+
<ul className="sc-comment-chosen" aria-label={words.files}>
|
|
214
|
+
{files.map((file) => (
|
|
215
|
+
<li key={file.name} className="sc-comment-chosen-file">
|
|
216
|
+
<span>{file.name}</span> <span className="sc-muted">{formatBytes(file.size)}</span>{" "}
|
|
217
|
+
<Button
|
|
218
|
+
variant="ghost"
|
|
219
|
+
size="sm"
|
|
220
|
+
aria-label={words.removeFile(file.name)}
|
|
221
|
+
onClick={() => setFiles((current) => current.filter((held) => held !== file))}
|
|
222
|
+
>
|
|
223
|
+
×
|
|
224
|
+
</Button>
|
|
225
|
+
</li>
|
|
226
|
+
))}
|
|
227
|
+
</ul>
|
|
228
|
+
)}
|
|
229
|
+
<div className="sc-comment-form-actions">
|
|
230
|
+
<label className="sc-button sc-button-ghost sc-button-sm">
|
|
231
|
+
{words.chooseFiles}
|
|
232
|
+
<input
|
|
233
|
+
className="sc-comment-file-input"
|
|
234
|
+
type="file"
|
|
235
|
+
multiple
|
|
236
|
+
onChange={(event) => {
|
|
237
|
+
add([...(event.target.files ?? [])]);
|
|
238
|
+
event.target.value = "";
|
|
239
|
+
}}
|
|
240
|
+
/>
|
|
241
|
+
</label>
|
|
242
|
+
<Button type="submit" variant="primary" disabled={empty || props.busy === true}>
|
|
243
|
+
{words.one}
|
|
244
|
+
</Button>
|
|
245
|
+
</div>
|
|
246
|
+
<p className="sc-field-hint">{words.hint}</p>
|
|
247
|
+
</form>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** One thing said or done about a task, with when, for the thread's order. */
|
|
252
|
+
export type ThreadItem =
|
|
253
|
+
| { readonly kind: "comment"; readonly at: string; readonly comment: RestComment }
|
|
254
|
+
| { readonly kind: "run"; readonly at: string; readonly run: RestRun }
|
|
255
|
+
| { readonly kind: "decision"; readonly at: string; readonly decision: RestDecision };
|
|
256
|
+
|
|
257
|
+
/** The comments, the runs and the decisions of a task as one thread, in the order they came. */
|
|
258
|
+
export function threadOf(
|
|
259
|
+
comments: readonly RestComment[],
|
|
260
|
+
runs: readonly RestRun[],
|
|
261
|
+
decisions: readonly RestDecision[],
|
|
262
|
+
): ThreadItem[] {
|
|
263
|
+
const items: ThreadItem[] = [
|
|
264
|
+
...comments.map((comment) => ({ kind: "comment" as const, at: comment.createdAt, comment })),
|
|
265
|
+
...runs.map((run) => ({ kind: "run" as const, at: run.startedAt, run })),
|
|
266
|
+
...decisions.map((decision) => ({
|
|
267
|
+
kind: "decision" as const,
|
|
268
|
+
at: decision.createdAt,
|
|
269
|
+
decision,
|
|
270
|
+
})),
|
|
271
|
+
];
|
|
272
|
+
return items.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface TaskThreadProps {
|
|
276
|
+
readonly task: RestTask;
|
|
277
|
+
readonly comments: readonly RestComment[];
|
|
278
|
+
readonly runs: readonly RestRun[];
|
|
279
|
+
readonly decisions: readonly RestDecision[];
|
|
280
|
+
/** Where a file of the project is read, by its id: a run's as a comment's. */
|
|
281
|
+
readonly fileHref?: ((fileId: string) => string) | undefined;
|
|
282
|
+
/** Where a decision is answered, for a run that waits for one. */
|
|
283
|
+
readonly decisionHref?: ((decisionId: string) => string) | undefined;
|
|
284
|
+
readonly docHref?: ((path: string) => string) | undefined;
|
|
285
|
+
readonly onAnswer?: DecisionCardProps["onAnswer"];
|
|
286
|
+
readonly onUpdateDecision?: DecisionCardProps["onUpdate"];
|
|
287
|
+
readonly onAbandonRun?: ((run: RestRun) => void) | undefined;
|
|
288
|
+
/** Called with what was written; left out, nothing can be said here. */
|
|
289
|
+
readonly onComment?: ((draft: CommentDraft) => void) | undefined;
|
|
290
|
+
readonly busy?: boolean | undefined;
|
|
291
|
+
readonly empty?: ReactNode;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function TaskThread(props: TaskThreadProps) {
|
|
295
|
+
const items = threadOf(props.comments, props.runs, props.decisions);
|
|
296
|
+
const fileHref = props.fileHref;
|
|
297
|
+
const shown = (item: ThreadItem): ReactNode => {
|
|
298
|
+
if (item.kind === "comment") {
|
|
299
|
+
return (
|
|
300
|
+
<CommentCard
|
|
301
|
+
key={item.comment.id}
|
|
302
|
+
comment={item.comment}
|
|
303
|
+
fileHref={fileHref === undefined ? undefined : (file) => fileHref(file.id)}
|
|
304
|
+
docHref={props.docHref}
|
|
305
|
+
/>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (item.kind === "run") {
|
|
309
|
+
return (
|
|
310
|
+
<RunCard
|
|
311
|
+
key={item.run.id}
|
|
312
|
+
run={item.run}
|
|
313
|
+
decisionHref={props.decisionHref}
|
|
314
|
+
fileHref={fileHref === undefined ? undefined : (artifact) => fileHref(artifact.id)}
|
|
315
|
+
docHref={props.docHref}
|
|
316
|
+
onAbandon={props.onAbandonRun}
|
|
317
|
+
busy={props.busy}
|
|
318
|
+
/>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return (
|
|
322
|
+
<DecisionCard
|
|
323
|
+
key={item.decision.id}
|
|
324
|
+
decision={item.decision}
|
|
325
|
+
task={props.task}
|
|
326
|
+
docHref={props.docHref}
|
|
327
|
+
onAnswer={props.onAnswer}
|
|
328
|
+
onUpdate={props.onUpdateDecision}
|
|
329
|
+
busy={props.busy}
|
|
330
|
+
/>
|
|
331
|
+
);
|
|
332
|
+
};
|
|
333
|
+
return (
|
|
334
|
+
<div className="sc-thread">
|
|
335
|
+
{items.length === 0 && props.empty}
|
|
336
|
+
{items.map(shown)}
|
|
337
|
+
{props.onComment !== undefined && (
|
|
338
|
+
<CommentForm onSubmit={props.onComment} busy={props.busy} />
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -76,6 +76,8 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
|
|
|
76
76
|
);
|
|
77
77
|
case "task.updated":
|
|
78
78
|
return feed.event.taskUpdated(fields, task);
|
|
79
|
+
case "task.commented":
|
|
80
|
+
return feed.event.taskCommented(task, data.excerpt);
|
|
79
81
|
case "decision.raised":
|
|
80
82
|
return feed.event.decisionRaised(question);
|
|
81
83
|
case "decision.answered":
|
package/src/components/goals.tsx
CHANGED
|
@@ -4,20 +4,32 @@ import {
|
|
|
4
4
|
type GoalState,
|
|
5
5
|
MAX_BODY_LENGTH,
|
|
6
6
|
MAX_TITLE_LENGTH,
|
|
7
|
+
type RestCategory,
|
|
7
8
|
type RestGoal,
|
|
8
9
|
type RestGoalInput,
|
|
9
10
|
type RestTask,
|
|
10
11
|
TASK_STATES,
|
|
11
12
|
} from "../api.js";
|
|
12
|
-
import {
|
|
13
|
+
import { formatDays } from "../dates.js";
|
|
14
|
+
import { useLanguage, useWords } from "../i18n/index.js";
|
|
13
15
|
import { Markdown } from "./markdown.js";
|
|
14
16
|
import { TaskList } from "./task-list.js";
|
|
15
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
Button,
|
|
19
|
+
CategoryBadge,
|
|
20
|
+
Day,
|
|
21
|
+
DueBadge,
|
|
22
|
+
GoalStateBadge,
|
|
23
|
+
PersonChip,
|
|
24
|
+
ProgressBar,
|
|
25
|
+
Time,
|
|
26
|
+
} from "./ui.js";
|
|
16
27
|
|
|
17
|
-
// The roadmap of a project (ADR-0019): its goals, each
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
28
|
+
// The roadmap of a project (ADR-0019): its goals, each planned in days as a task is, a start
|
|
29
|
+
// and a target, sorted into a category of the workspace (ADR-0026), with how far its tasks
|
|
30
|
+
// are, read from the board as it stands. The list, one goal with its tasks, and the form an
|
|
31
|
+
// owner sets one with. Each takes its data as props and nothing from the network; what people
|
|
32
|
+
// wrote is shown as text, never as HTML.
|
|
21
33
|
|
|
22
34
|
/** A goal's day on the calendar's terms: reached or let go, it is a plain fact; else it may be late. */
|
|
23
35
|
const dueState = (goal: RestGoal) =>
|
|
@@ -33,6 +45,7 @@ export interface GoalListProps {
|
|
|
33
45
|
|
|
34
46
|
export function GoalList(props: GoalListProps) {
|
|
35
47
|
const words = useWords().roadmap;
|
|
48
|
+
const { tag } = useLanguage();
|
|
36
49
|
if (props.goals.length === 0) {
|
|
37
50
|
return <>{props.empty ?? null}</>;
|
|
38
51
|
}
|
|
@@ -44,7 +57,11 @@ export function GoalList(props: GoalListProps) {
|
|
|
44
57
|
<a className="sc-goal-title" href={props.goalHref(goal)}>
|
|
45
58
|
{goal.title}
|
|
46
59
|
</a>
|
|
60
|
+
{goal.category !== null && <CategoryBadge category={goal.category} />}
|
|
47
61
|
<GoalStateBadge state={goal.state} />
|
|
62
|
+
{goal.startsOn !== null && goal.dueOn !== null && goal.startsOn < goal.dueOn && (
|
|
63
|
+
<span className="sc-goal-days">{formatDays(goal.startsOn, goal.dueOn, tag)}</span>
|
|
64
|
+
)}
|
|
48
65
|
{goal.dueOn !== null && (
|
|
49
66
|
<DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
|
|
50
67
|
)}
|
|
@@ -59,6 +76,8 @@ export function GoalList(props: GoalListProps) {
|
|
|
59
76
|
export interface GoalFormProps {
|
|
60
77
|
/** The goal to change; left out, the form sets a new one. */
|
|
61
78
|
readonly goal?: RestGoal | undefined;
|
|
79
|
+
/** The workspace's categories to sort the goal into; left out, the form offers none. */
|
|
80
|
+
readonly categories?: readonly RestCategory[] | undefined;
|
|
62
81
|
readonly busy?: boolean | undefined;
|
|
63
82
|
readonly error?: string | undefined;
|
|
64
83
|
readonly onSubmit: (input: RestGoalInput) => void;
|
|
@@ -72,11 +91,20 @@ export function GoalForm(props: GoalFormProps) {
|
|
|
72
91
|
const [title, setTitle] = useState(goal?.title ?? "");
|
|
73
92
|
const [body, setBody] = useState(goal?.body ?? "");
|
|
74
93
|
const [state, setState] = useState<GoalState>(goal?.state ?? "planned");
|
|
94
|
+
const [category, setCategory] = useState(goal?.category?.key ?? "");
|
|
95
|
+
const [startsOn, setStartsOn] = useState(goal?.startsOn ?? "");
|
|
75
96
|
const [dueOn, setDueOn] = useState(goal?.dueOn ?? "");
|
|
76
97
|
|
|
77
98
|
const submit = (event: FormEvent) => {
|
|
78
99
|
event.preventDefault();
|
|
79
|
-
props.onSubmit({
|
|
100
|
+
props.onSubmit({
|
|
101
|
+
title: title.trim(),
|
|
102
|
+
body,
|
|
103
|
+
state,
|
|
104
|
+
category: category === "" ? null : category,
|
|
105
|
+
startsOn: startsOn === "" ? null : startsOn,
|
|
106
|
+
dueOn: dueOn === "" ? null : dueOn,
|
|
107
|
+
});
|
|
80
108
|
};
|
|
81
109
|
|
|
82
110
|
return (
|
|
@@ -117,12 +145,42 @@ export function GoalForm(props: GoalFormProps) {
|
|
|
117
145
|
))}
|
|
118
146
|
</select>
|
|
119
147
|
</label>
|
|
148
|
+
{props.categories !== undefined && (
|
|
149
|
+
<label className="sc-field">
|
|
150
|
+
<span className="sc-field-label">{form.category}</span>
|
|
151
|
+
<select
|
|
152
|
+
className="sc-input"
|
|
153
|
+
value={category}
|
|
154
|
+
onChange={(event) => setCategory(event.target.value)}
|
|
155
|
+
>
|
|
156
|
+
<option value="">{form.noCategory}</option>
|
|
157
|
+
{props.categories.map((candidate) => (
|
|
158
|
+
<option key={candidate.key} value={candidate.key}>
|
|
159
|
+
{candidate.name}
|
|
160
|
+
</option>
|
|
161
|
+
))}
|
|
162
|
+
</select>
|
|
163
|
+
</label>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
<div className="sc-field-row">
|
|
167
|
+
<label className="sc-field">
|
|
168
|
+
<span className="sc-field-label">{form.startsOn}</span>
|
|
169
|
+
<input
|
|
170
|
+
className="sc-input"
|
|
171
|
+
type="date"
|
|
172
|
+
value={startsOn}
|
|
173
|
+
max={dueOn === "" ? undefined : dueOn}
|
|
174
|
+
onChange={(event) => setStartsOn(event.target.value)}
|
|
175
|
+
/>
|
|
176
|
+
</label>
|
|
120
177
|
<label className="sc-field">
|
|
121
178
|
<span className="sc-field-label">{form.dueOn}</span>
|
|
122
179
|
<input
|
|
123
180
|
className="sc-input"
|
|
124
181
|
type="date"
|
|
125
182
|
value={dueOn}
|
|
183
|
+
min={startsOn === "" ? undefined : startsOn}
|
|
126
184
|
onChange={(event) => setDueOn(event.target.value)}
|
|
127
185
|
/>
|
|
128
186
|
</label>
|
|
@@ -175,6 +233,7 @@ export function GoalView(props: GoalViewProps) {
|
|
|
175
233
|
<header className="sc-task-header">
|
|
176
234
|
<h1 className="sc-task-title">{goal.title}</h1>
|
|
177
235
|
<div className="sc-task-badges">
|
|
236
|
+
{goal.category !== null && <CategoryBadge category={goal.category} />}
|
|
178
237
|
<GoalStateBadge state={goal.state} />
|
|
179
238
|
{goal.dueOn !== null && (
|
|
180
239
|
<DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
|
|
@@ -233,6 +292,26 @@ export function GoalView(props: GoalViewProps) {
|
|
|
233
292
|
)}
|
|
234
293
|
</dd>
|
|
235
294
|
</div>
|
|
295
|
+
<div>
|
|
296
|
+
<dt>{roadmap.category}</dt>
|
|
297
|
+
<dd>
|
|
298
|
+
{goal.category === null ? (
|
|
299
|
+
<span className="sc-muted">{roadmap.noCategory}</span>
|
|
300
|
+
) : (
|
|
301
|
+
<CategoryBadge category={goal.category} />
|
|
302
|
+
)}
|
|
303
|
+
</dd>
|
|
304
|
+
</div>
|
|
305
|
+
<div>
|
|
306
|
+
<dt>{roadmap.starts}</dt>
|
|
307
|
+
<dd>
|
|
308
|
+
{goal.startsOn === null ? (
|
|
309
|
+
<span className="sc-muted">{roadmap.noStart}</span>
|
|
310
|
+
) : (
|
|
311
|
+
<Day day={goal.startsOn} />
|
|
312
|
+
)}
|
|
313
|
+
</dd>
|
|
314
|
+
</div>
|
|
236
315
|
<div>
|
|
237
316
|
<dt>{roadmap.target}</dt>
|
|
238
317
|
<dd>
|
|
@@ -14,6 +14,12 @@ export interface MarkdownProps {
|
|
|
14
14
|
readonly source: string;
|
|
15
15
|
/** Where a document of the project is read, by its path; left out, such links stay text. */
|
|
16
16
|
readonly docHref?: ((path: string) => string) | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Where a picture named in the text is read, when the text comes with files: given the
|
|
19
|
+
* source of ``, the address of the file it names, or nothing. A picture that
|
|
20
|
+
* is neither one of those nor over https stays text.
|
|
21
|
+
*/
|
|
22
|
+
readonly fileSrc?: ((source: string) => string | undefined) | undefined;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export function Markdown(props: MarkdownProps) {
|
|
@@ -48,7 +54,9 @@ export function Markdown(props: MarkdownProps) {
|
|
|
48
54
|
return <span>{children}</span>;
|
|
49
55
|
},
|
|
50
56
|
img({ src, alt, title }) {
|
|
51
|
-
const
|
|
57
|
+
const named = typeof src === "string" ? props.fileSrc?.(src) : undefined;
|
|
58
|
+
const url =
|
|
59
|
+
named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined);
|
|
52
60
|
if (url === undefined) {
|
|
53
61
|
return <span className="sc-prose-image-text">{alt ?? ""}</span>;
|
|
54
62
|
}
|
|
@@ -46,18 +46,31 @@ export function ProjectMark(props: {
|
|
|
46
46
|
const icon = props.project.icon ?? null;
|
|
47
47
|
const src =
|
|
48
48
|
props.iconSrc ?? (icon === null ? undefined : projectIconPath(props.project.key, icon));
|
|
49
|
+
// The symbol stands in until the image is there, and comes back should the image fail; once
|
|
50
|
+
// the image is shown, the symbol goes, since an image with clear parts would show it through.
|
|
51
|
+
const [shown, setShown] = useState<string | undefined>(undefined);
|
|
52
|
+
const [failed, setFailed] = useState<string | undefined>(undefined);
|
|
53
|
+
const pictured = src !== undefined && failed !== src;
|
|
49
54
|
return (
|
|
50
55
|
<span
|
|
51
56
|
className={cx(
|
|
52
57
|
"sc-mark",
|
|
53
58
|
`sc-mark-${size}`,
|
|
54
59
|
`sc-category-${projectColorOf(props.project)}`,
|
|
55
|
-
|
|
60
|
+
pictured && "sc-mark-pictured",
|
|
56
61
|
)}
|
|
57
62
|
aria-hidden="true"
|
|
58
63
|
>
|
|
59
|
-
{projectSymbolOf(props.project)}
|
|
60
|
-
{
|
|
64
|
+
{(!pictured || shown !== src) && projectSymbolOf(props.project)}
|
|
65
|
+
{pictured && (
|
|
66
|
+
<img
|
|
67
|
+
className="sc-mark-image"
|
|
68
|
+
src={src}
|
|
69
|
+
alt=""
|
|
70
|
+
onLoad={() => setShown(src)}
|
|
71
|
+
onError={() => setFailed(src)}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
61
74
|
</span>
|
|
62
75
|
);
|
|
63
76
|
}
|