@skillcdn/console 0.1.9 → 0.1.10
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 +5 -5
- package/dist/api.d.ts +5 -5
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +255 -10
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +9 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +80 -0
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +62 -7
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +31 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +19 -3
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +27 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +75 -7
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +34 -0
- package/dist/components/calendar.d.ts.map +1 -0
- package/dist/components/calendar.js +96 -0
- package/dist/components/calendar.js.map +1 -0
- package/dist/components/categories.d.ts +13 -0
- package/dist/components/categories.d.ts.map +1 -0
- package/dist/components/categories.js +52 -0
- package/dist/components/categories.js.map +1 -0
- package/dist/components/decision-list.d.ts +10 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +14 -1
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +12 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/goals.d.ts +35 -0
- package/dist/components/goals.d.ts.map +1 -0
- package/dist/components/goals.js +45 -0
- package/dist/components/goals.js.map +1 -0
- package/dist/components/task-form.d.ts +5 -1
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +13 -1
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/task-list.d.ts +16 -0
- package/dist/components/task-list.d.ts.map +1 -0
- package/dist/components/task-list.js +14 -0
- package/dist/components/task-list.js.map +1 -0
- package/dist/components/task-view.d.ts +9 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +10 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +30 -1
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +60 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts +10 -0
- package/dist/components/work.d.ts.map +1 -0
- package/dist/components/work.js +29 -0
- package/dist/components/work.js.map +1 -0
- package/dist/console.css +511 -0
- package/dist/console.d.ts +8 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +124 -8
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +14 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +36 -2
- package/dist/data.js.map +1 -1
- package/dist/dates.d.ts +39 -0
- package/dist/dates.d.ts.map +1 -0
- package/dist/dates.js +120 -0
- package/dist/dates.js.map +1 -0
- package/dist/i18n/en.d.ts +162 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +174 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +169 -1
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +5 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +5 -0
- package/dist/limits.js.map +1 -1
- package/dist/router.d.ts +50 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +74 -1
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +7 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +9 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +366 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +112 -3
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +15 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +26 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +11 -4
- package/docs/specs/rest.md +33 -8
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +7 -6
- package/src/api.ts +30 -0
- package/src/cli/cli.ts +311 -10
- package/src/cli/format.ts +88 -0
- package/src/cli/help.ts +69 -7
- package/src/client.ts +70 -2
- package/src/components/board.tsx +205 -12
- package/src/components/calendar.tsx +255 -0
- package/src/components/categories.tsx +209 -0
- package/src/components/decision-list.tsx +62 -5
- package/src/components/event-feed.tsx +12 -0
- package/src/components/goals.tsx +264 -0
- package/src/components/task-form.tsx +78 -1
- package/src/components/task-list.tsx +61 -0
- package/src/components/task-view.tsx +74 -3
- package/src/components/ui.tsx +116 -1
- package/src/components/work.tsx +83 -0
- package/src/console.tsx +337 -4
- package/src/data.ts +63 -8
- package/src/dates.ts +143 -0
- package/src/i18n/en.ts +184 -1
- package/src/i18n/ko.ts +175 -1
- package/src/index.ts +58 -1
- package/src/limits.ts +5 -0
- package/src/router.ts +119 -6
- package/src/routes.ts +10 -0
- package/src/schemas.ts +141 -1
- package/src/styles/console.css +511 -0
- package/src/vocabulary.ts +33 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
CATEGORY_COLORS,
|
|
4
|
+
type CategoryColor,
|
|
5
|
+
MAX_CATEGORY_KEY_LENGTH,
|
|
6
|
+
MAX_CATEGORY_NAME_LENGTH,
|
|
7
|
+
type RestCategory,
|
|
8
|
+
type RestCategoryInput,
|
|
9
|
+
type RestCategoryPatch,
|
|
10
|
+
} from "../api.js";
|
|
11
|
+
import { useWords } from "../i18n/index.js";
|
|
12
|
+
import { keyOf } from "./projects.js";
|
|
13
|
+
import { Button, CategoryBadge, cx } from "./ui.js";
|
|
14
|
+
|
|
15
|
+
// The categories tasks are sorted into (ADR-0019), as the workspace's page lists them: each in
|
|
16
|
+
// its colour, with the ways for an administrator to rename, recolour, add and remove one. Takes
|
|
17
|
+
// its data as props and nothing from the network.
|
|
18
|
+
|
|
19
|
+
export interface CategoryListProps {
|
|
20
|
+
readonly categories: readonly RestCategory[];
|
|
21
|
+
readonly busy?: boolean | undefined;
|
|
22
|
+
/** Called with a category to add. Left out, the list is read here, not changed. */
|
|
23
|
+
readonly onAdd?: ((input: RestCategoryInput) => void) | undefined;
|
|
24
|
+
readonly onChange?: ((category: RestCategory, patch: RestCategoryPatch) => void) | undefined;
|
|
25
|
+
readonly onRemove?: ((category: RestCategory) => void) | undefined;
|
|
26
|
+
readonly empty?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One category, with its name and its colour open to change where an administrator is looking. */
|
|
30
|
+
function CategoryRow(props: {
|
|
31
|
+
readonly category: RestCategory;
|
|
32
|
+
readonly busy: boolean | undefined;
|
|
33
|
+
readonly onChange: ((category: RestCategory, patch: RestCategoryPatch) => void) | undefined;
|
|
34
|
+
readonly onRemove: ((category: RestCategory) => void) | undefined;
|
|
35
|
+
}) {
|
|
36
|
+
const words = useWords();
|
|
37
|
+
const { category } = props;
|
|
38
|
+
const [name, setName] = useState(category.name);
|
|
39
|
+
const [color, setColor] = useState<CategoryColor>(category.color);
|
|
40
|
+
const changed = name.trim() !== category.name || color !== category.color;
|
|
41
|
+
if (props.onChange === undefined) {
|
|
42
|
+
return (
|
|
43
|
+
<li className="sc-category-row">
|
|
44
|
+
<CategoryBadge category={category} />
|
|
45
|
+
<code className="sc-project-key">{category.key}</code>
|
|
46
|
+
</li>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return (
|
|
50
|
+
<li className="sc-category-row">
|
|
51
|
+
<form
|
|
52
|
+
className="sc-category-form"
|
|
53
|
+
onSubmit={(event) => {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
if (changed && name.trim() !== "") {
|
|
56
|
+
props.onChange?.(category, { name: name.trim(), color });
|
|
57
|
+
}
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<span className={cx("sc-category-swatch", `sc-category-${color}`)} aria-hidden="true" />
|
|
61
|
+
<label className="sc-field sc-field-inline">
|
|
62
|
+
<span className="sc-visually-hidden">{words.categories.nameOf(category.key)}</span>
|
|
63
|
+
<input
|
|
64
|
+
className="sc-input"
|
|
65
|
+
value={name}
|
|
66
|
+
maxLength={MAX_CATEGORY_NAME_LENGTH}
|
|
67
|
+
required
|
|
68
|
+
onChange={(event) => setName(event.target.value)}
|
|
69
|
+
/>
|
|
70
|
+
</label>
|
|
71
|
+
<code className="sc-project-key">{category.key}</code>
|
|
72
|
+
<label className="sc-field sc-field-inline">
|
|
73
|
+
<span className="sc-visually-hidden">{words.categories.colorOf(category.key)}</span>
|
|
74
|
+
<select
|
|
75
|
+
className="sc-input"
|
|
76
|
+
value={color}
|
|
77
|
+
onChange={(event) => setColor(event.target.value as CategoryColor)}
|
|
78
|
+
>
|
|
79
|
+
{CATEGORY_COLORS.map((candidate) => (
|
|
80
|
+
<option key={candidate} value={candidate}>
|
|
81
|
+
{words.vocabulary.categoryColor[candidate]}
|
|
82
|
+
</option>
|
|
83
|
+
))}
|
|
84
|
+
</select>
|
|
85
|
+
</label>
|
|
86
|
+
<Button
|
|
87
|
+
type="submit"
|
|
88
|
+
variant="primary"
|
|
89
|
+
size="sm"
|
|
90
|
+
disabled={props.busy === true || !changed || name.trim() === ""}
|
|
91
|
+
>
|
|
92
|
+
{words.common.save}
|
|
93
|
+
</Button>
|
|
94
|
+
{props.onRemove !== undefined && (
|
|
95
|
+
<Button
|
|
96
|
+
variant="danger"
|
|
97
|
+
size="sm"
|
|
98
|
+
disabled={props.busy === true}
|
|
99
|
+
onClick={() => props.onRemove?.(category)}
|
|
100
|
+
>
|
|
101
|
+
{words.common.remove}
|
|
102
|
+
</Button>
|
|
103
|
+
)}
|
|
104
|
+
</form>
|
|
105
|
+
</li>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function CategoryList(props: CategoryListProps) {
|
|
110
|
+
const words = useWords();
|
|
111
|
+
const form = words.categories.form;
|
|
112
|
+
const [name, setName] = useState("");
|
|
113
|
+
const [key, setKey] = useState("");
|
|
114
|
+
const [keyTouched, setKeyTouched] = useState(false);
|
|
115
|
+
const [color, setColor] = useState<CategoryColor>("gray");
|
|
116
|
+
const add = (event: FormEvent) => {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
if (name.trim() === "" || key.trim() === "") {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
props.onAdd?.({ key: key.trim(), name: name.trim(), color });
|
|
122
|
+
setName("");
|
|
123
|
+
setKey("");
|
|
124
|
+
setKeyTouched(false);
|
|
125
|
+
setColor("gray");
|
|
126
|
+
};
|
|
127
|
+
return (
|
|
128
|
+
<div className="sc-categories">
|
|
129
|
+
{props.categories.length === 0 ? (
|
|
130
|
+
(props.empty ?? null)
|
|
131
|
+
) : (
|
|
132
|
+
<ul className="sc-category-list" aria-label={words.categories.list}>
|
|
133
|
+
{props.categories.map((category) => (
|
|
134
|
+
<CategoryRow
|
|
135
|
+
key={category.key}
|
|
136
|
+
category={category}
|
|
137
|
+
busy={props.busy}
|
|
138
|
+
onChange={props.onChange}
|
|
139
|
+
onRemove={props.onRemove}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
)}
|
|
144
|
+
{props.onAdd !== undefined && (
|
|
145
|
+
<form
|
|
146
|
+
className="sc-form sc-member-form"
|
|
147
|
+
aria-label={words.categories.addAria}
|
|
148
|
+
onSubmit={add}
|
|
149
|
+
>
|
|
150
|
+
<div className="sc-field-row">
|
|
151
|
+
<label className="sc-field">
|
|
152
|
+
<span className="sc-field-label">{form.name}</span>
|
|
153
|
+
<input
|
|
154
|
+
className="sc-input"
|
|
155
|
+
value={name}
|
|
156
|
+
maxLength={MAX_CATEGORY_NAME_LENGTH}
|
|
157
|
+
required
|
|
158
|
+
onChange={(event) => {
|
|
159
|
+
setName(event.target.value);
|
|
160
|
+
if (!keyTouched) {
|
|
161
|
+
setKey(keyOf(event.target.value).slice(0, MAX_CATEGORY_KEY_LENGTH));
|
|
162
|
+
}
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
</label>
|
|
166
|
+
<label className="sc-field">
|
|
167
|
+
<span className="sc-field-label">{form.key}</span>
|
|
168
|
+
<input
|
|
169
|
+
className="sc-input"
|
|
170
|
+
value={key}
|
|
171
|
+
maxLength={MAX_CATEGORY_KEY_LENGTH}
|
|
172
|
+
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
|
|
173
|
+
required
|
|
174
|
+
title={form.keyHint}
|
|
175
|
+
onChange={(event) => {
|
|
176
|
+
setKeyTouched(true);
|
|
177
|
+
setKey(event.target.value);
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
</label>
|
|
181
|
+
<label className="sc-field">
|
|
182
|
+
<span className="sc-field-label">{form.color}</span>
|
|
183
|
+
<select
|
|
184
|
+
className="sc-input"
|
|
185
|
+
value={color}
|
|
186
|
+
onChange={(event) => setColor(event.target.value as CategoryColor)}
|
|
187
|
+
>
|
|
188
|
+
{CATEGORY_COLORS.map((candidate) => (
|
|
189
|
+
<option key={candidate} value={candidate}>
|
|
190
|
+
{words.vocabulary.categoryColor[candidate]}
|
|
191
|
+
</option>
|
|
192
|
+
))}
|
|
193
|
+
</select>
|
|
194
|
+
</label>
|
|
195
|
+
<div className="sc-form-actions">
|
|
196
|
+
<Button
|
|
197
|
+
type="submit"
|
|
198
|
+
variant="primary"
|
|
199
|
+
disabled={props.busy === true || name.trim() === "" || key.trim() === ""}
|
|
200
|
+
>
|
|
201
|
+
{form.add}
|
|
202
|
+
</Button>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</form>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -11,9 +11,11 @@ import { useWords } from "../i18n/index.js";
|
|
|
11
11
|
import { Markdown } from "./markdown.js";
|
|
12
12
|
import { Button, cx, PersonChip, Time } from "./ui.js";
|
|
13
13
|
|
|
14
|
-
// The decisions: the ones that wait, with the way to answer
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// The decisions: the ones that wait, each a card with the way to answer it, since they are what
|
|
15
|
+
// a person came for; and the ones answered, the record, a row each, since they are read back
|
|
16
|
+
// rarely and looked up by what was asked. Each decision is a record (ADR-0009) of its context,
|
|
17
|
+
// its answer with the rationale, and what followed, read on its own page. Each component takes
|
|
18
|
+
// its data as props and nothing from the network.
|
|
17
19
|
|
|
18
20
|
export interface DecisionCardProps {
|
|
19
21
|
readonly decision: RestDecision;
|
|
@@ -240,6 +242,55 @@ export interface DecisionListProps {
|
|
|
240
242
|
readonly empty?: React.ReactNode;
|
|
241
243
|
}
|
|
242
244
|
|
|
245
|
+
export interface DecisionLogProps {
|
|
246
|
+
/** The decisions answered, newest first. */
|
|
247
|
+
readonly decisions: readonly RestDecision[];
|
|
248
|
+
readonly tasks?: ReadonlyMap<string, RestTask> | undefined;
|
|
249
|
+
readonly taskHref?: ((task: RestTask) => string) | undefined;
|
|
250
|
+
/** Where each decision's own page is, with its context, its rationale and what followed. */
|
|
251
|
+
readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The record: one row per decision answered, what was asked, what was chosen, by whom, when. */
|
|
255
|
+
export function DecisionLog(props: DecisionLogProps) {
|
|
256
|
+
const words = useWords();
|
|
257
|
+
return (
|
|
258
|
+
<ol className="sc-decision-log" aria-label={words.decisions.record}>
|
|
259
|
+
{props.decisions.map((decision) => {
|
|
260
|
+
const task = decision.taskId === null ? undefined : props.tasks?.get(decision.taskId);
|
|
261
|
+
const href = props.decisionHref?.(decision);
|
|
262
|
+
const chosen =
|
|
263
|
+
decision.options.find((option) => option.id === decision.answer?.option)?.label ??
|
|
264
|
+
decision.answer?.option ??
|
|
265
|
+
"";
|
|
266
|
+
return (
|
|
267
|
+
<li key={decision.id} className="sc-decision-row">
|
|
268
|
+
<span className="sc-decision-row-question">
|
|
269
|
+
{href === undefined ? decision.question : <a href={href}>{decision.question}</a>}
|
|
270
|
+
</span>
|
|
271
|
+
<span className="sc-decision-row-answer">{chosen}</span>
|
|
272
|
+
<span className="sc-decision-row-meta">
|
|
273
|
+
{decision.answer !== null && (
|
|
274
|
+
<>
|
|
275
|
+
<PersonChip person={decision.answer.by} /> <Time iso={decision.answer.at} />
|
|
276
|
+
</>
|
|
277
|
+
)}
|
|
278
|
+
{task !== undefined &&
|
|
279
|
+
(props.taskHref === undefined ? (
|
|
280
|
+
<span className="sc-card-number">#{task.number}</span>
|
|
281
|
+
) : (
|
|
282
|
+
<a className="sc-card-number" href={props.taskHref(task)}>
|
|
283
|
+
#{task.number}
|
|
284
|
+
</a>
|
|
285
|
+
))}
|
|
286
|
+
</span>
|
|
287
|
+
</li>
|
|
288
|
+
);
|
|
289
|
+
})}
|
|
290
|
+
</ol>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
243
294
|
export function DecisionList(props: DecisionListProps) {
|
|
244
295
|
const words = useWords();
|
|
245
296
|
const waiting = props.decisions.filter((decision) => decision.answer === null);
|
|
@@ -268,9 +319,15 @@ export function DecisionList(props: DecisionListProps) {
|
|
|
268
319
|
{answered.length > 0 && (
|
|
269
320
|
<section aria-label={words.decisions.answered}>
|
|
270
321
|
<h2 className="sc-section-title">
|
|
271
|
-
{words.decisions.
|
|
322
|
+
{words.decisions.record} <span className="sc-column-count">{answered.length}</span>
|
|
272
323
|
</h2>
|
|
273
|
-
{
|
|
324
|
+
<p className="sc-lead">{words.decisions.recordLead}</p>
|
|
325
|
+
<DecisionLog
|
|
326
|
+
decisions={answered}
|
|
327
|
+
tasks={props.tasks}
|
|
328
|
+
taskHref={props.taskHref}
|
|
329
|
+
decisionHref={props.decisionHref}
|
|
330
|
+
/>
|
|
274
331
|
</section>
|
|
275
332
|
)}
|
|
276
333
|
</div>
|
|
@@ -30,6 +30,8 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
|
|
|
30
30
|
: feed.role.other;
|
|
31
31
|
const login = data.login ?? feed.someone;
|
|
32
32
|
const question = data.question ?? feed.aQuestion;
|
|
33
|
+
const goal = data.title ?? feed.aGoal;
|
|
34
|
+
const category = data.name ?? data.key ?? feed.aCategory;
|
|
33
35
|
switch (event.kind) {
|
|
34
36
|
case "person.joined":
|
|
35
37
|
return feed.event.personJoined();
|
|
@@ -45,6 +47,16 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
|
|
|
45
47
|
return feed.event.memberChanged(login, role(data.role), project);
|
|
46
48
|
case "project.member_removed":
|
|
47
49
|
return feed.event.memberRemoved(login, project);
|
|
50
|
+
case "category.created":
|
|
51
|
+
return feed.event.categoryCreated(category);
|
|
52
|
+
case "category.updated":
|
|
53
|
+
return feed.event.categoryUpdated(fields, category);
|
|
54
|
+
case "category.removed":
|
|
55
|
+
return feed.event.categoryRemoved(category);
|
|
56
|
+
case "goal.created":
|
|
57
|
+
return feed.event.goalCreated(goal);
|
|
58
|
+
case "goal.updated":
|
|
59
|
+
return feed.event.goalUpdated(fields, goal);
|
|
48
60
|
case "task.created":
|
|
49
61
|
return feed.event.taskCreated(task);
|
|
50
62
|
case "task.moved":
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
GOAL_STATES,
|
|
4
|
+
type GoalState,
|
|
5
|
+
MAX_BODY_LENGTH,
|
|
6
|
+
MAX_TITLE_LENGTH,
|
|
7
|
+
type RestGoal,
|
|
8
|
+
type RestGoalInput,
|
|
9
|
+
type RestTask,
|
|
10
|
+
TASK_STATES,
|
|
11
|
+
} from "../api.js";
|
|
12
|
+
import { useWords } from "../i18n/index.js";
|
|
13
|
+
import { Markdown } from "./markdown.js";
|
|
14
|
+
import { TaskList } from "./task-list.js";
|
|
15
|
+
import { Button, Day, DueBadge, GoalStateBadge, PersonChip, ProgressBar, Time } from "./ui.js";
|
|
16
|
+
|
|
17
|
+
// The roadmap of a project (ADR-0019): its goals, each with the day it is meant to be reached
|
|
18
|
+
// by and how far its tasks are, read from the board as it stands. The list, one goal with its
|
|
19
|
+
// tasks, and the form an owner sets one with. Each takes its data as props and nothing from
|
|
20
|
+
// the network; what people wrote is shown as text, never as HTML.
|
|
21
|
+
|
|
22
|
+
/** A goal's day on the calendar's terms: reached or let go, it is a plain fact; else it may be late. */
|
|
23
|
+
const dueState = (goal: RestGoal) =>
|
|
24
|
+
goal.state === "done" ? "done" : goal.state === "dropped" ? "dropped" : "ready";
|
|
25
|
+
|
|
26
|
+
export interface GoalListProps {
|
|
27
|
+
readonly goals: readonly RestGoal[];
|
|
28
|
+
readonly goalHref: (goal: RestGoal) => string;
|
|
29
|
+
/** The day to count from; the browser's today when left out. */
|
|
30
|
+
readonly today?: string | undefined;
|
|
31
|
+
readonly empty?: ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function GoalList(props: GoalListProps) {
|
|
35
|
+
const words = useWords().roadmap;
|
|
36
|
+
if (props.goals.length === 0) {
|
|
37
|
+
return <>{props.empty ?? null}</>;
|
|
38
|
+
}
|
|
39
|
+
return (
|
|
40
|
+
<ul className="sc-goals" aria-label={words.title}>
|
|
41
|
+
{props.goals.map((goal) => (
|
|
42
|
+
<li key={goal.id} className={`sc-goal sc-goal-${goal.state}`}>
|
|
43
|
+
<div className="sc-goal-head">
|
|
44
|
+
<a className="sc-goal-title" href={props.goalHref(goal)}>
|
|
45
|
+
{goal.title}
|
|
46
|
+
</a>
|
|
47
|
+
<GoalStateBadge state={goal.state} />
|
|
48
|
+
{goal.dueOn !== null && (
|
|
49
|
+
<DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
<ProgressBar counts={goal.taskCounts} />
|
|
53
|
+
</li>
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface GoalFormProps {
|
|
60
|
+
/** The goal to change; left out, the form sets a new one. */
|
|
61
|
+
readonly goal?: RestGoal | undefined;
|
|
62
|
+
readonly busy?: boolean | undefined;
|
|
63
|
+
readonly error?: string | undefined;
|
|
64
|
+
readonly onSubmit: (input: RestGoalInput) => void;
|
|
65
|
+
readonly onCancel?: (() => void) | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function GoalForm(props: GoalFormProps) {
|
|
69
|
+
const words = useWords();
|
|
70
|
+
const form = words.roadmap.form;
|
|
71
|
+
const { goal } = props;
|
|
72
|
+
const [title, setTitle] = useState(goal?.title ?? "");
|
|
73
|
+
const [body, setBody] = useState(goal?.body ?? "");
|
|
74
|
+
const [state, setState] = useState<GoalState>(goal?.state ?? "planned");
|
|
75
|
+
const [dueOn, setDueOn] = useState(goal?.dueOn ?? "");
|
|
76
|
+
|
|
77
|
+
const submit = (event: FormEvent) => {
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
props.onSubmit({ title: title.trim(), body, state, dueOn: dueOn === "" ? null : dueOn });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<form className="sc-form" onSubmit={submit}>
|
|
84
|
+
<label className="sc-field">
|
|
85
|
+
<span className="sc-field-label">{form.title}</span>
|
|
86
|
+
<input
|
|
87
|
+
className="sc-input"
|
|
88
|
+
value={title}
|
|
89
|
+
maxLength={MAX_TITLE_LENGTH}
|
|
90
|
+
required
|
|
91
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
92
|
+
/>
|
|
93
|
+
</label>
|
|
94
|
+
<label className="sc-field">
|
|
95
|
+
<span className="sc-field-label">{form.body}</span>
|
|
96
|
+
<textarea
|
|
97
|
+
className="sc-input sc-textarea"
|
|
98
|
+
value={body}
|
|
99
|
+
maxLength={MAX_BODY_LENGTH}
|
|
100
|
+
rows={4}
|
|
101
|
+
placeholder={form.bodyPlaceholder}
|
|
102
|
+
onChange={(event) => setBody(event.target.value)}
|
|
103
|
+
/>
|
|
104
|
+
</label>
|
|
105
|
+
<div className="sc-field-row">
|
|
106
|
+
<label className="sc-field">
|
|
107
|
+
<span className="sc-field-label">{form.state}</span>
|
|
108
|
+
<select
|
|
109
|
+
className="sc-input"
|
|
110
|
+
value={state}
|
|
111
|
+
onChange={(event) => setState(event.target.value as GoalState)}
|
|
112
|
+
>
|
|
113
|
+
{GOAL_STATES.map((candidate) => (
|
|
114
|
+
<option key={candidate} value={candidate}>
|
|
115
|
+
{words.vocabulary.goalState[candidate]}
|
|
116
|
+
</option>
|
|
117
|
+
))}
|
|
118
|
+
</select>
|
|
119
|
+
</label>
|
|
120
|
+
<label className="sc-field">
|
|
121
|
+
<span className="sc-field-label">{form.dueOn}</span>
|
|
122
|
+
<input
|
|
123
|
+
className="sc-input"
|
|
124
|
+
type="date"
|
|
125
|
+
value={dueOn}
|
|
126
|
+
onChange={(event) => setDueOn(event.target.value)}
|
|
127
|
+
/>
|
|
128
|
+
</label>
|
|
129
|
+
</div>
|
|
130
|
+
{props.error !== undefined && (
|
|
131
|
+
<p className="sc-form-error" role="alert">
|
|
132
|
+
{props.error}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
135
|
+
<div className="sc-form-actions">
|
|
136
|
+
<Button
|
|
137
|
+
type="submit"
|
|
138
|
+
variant="primary"
|
|
139
|
+
disabled={props.busy === true || title.trim() === ""}
|
|
140
|
+
>
|
|
141
|
+
{goal === undefined ? form.set : words.common.save}
|
|
142
|
+
</Button>
|
|
143
|
+
{props.onCancel !== undefined && (
|
|
144
|
+
<Button variant="ghost" onClick={props.onCancel}>
|
|
145
|
+
{words.common.cancel}
|
|
146
|
+
</Button>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
</form>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface GoalViewProps {
|
|
154
|
+
readonly goal: RestGoal;
|
|
155
|
+
/** The tasks of the board; those that work towards the goal are shown, by state. */
|
|
156
|
+
readonly tasks: readonly RestTask[];
|
|
157
|
+
readonly taskHref: (task: RestTask) => string;
|
|
158
|
+
/** Where a document of the project is read, by its path, for the links in what was written. */
|
|
159
|
+
readonly docHref?: ((path: string) => string) | undefined;
|
|
160
|
+
readonly today?: string | undefined;
|
|
161
|
+
/** Where the goal is changed; left out, it cannot be changed from here. */
|
|
162
|
+
readonly editHref?: string | undefined;
|
|
163
|
+
/** Called when an owner moves the goal to another state from here. */
|
|
164
|
+
readonly onMove?: ((goal: RestGoal, state: GoalState) => void) | undefined;
|
|
165
|
+
readonly busy?: boolean | undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function GoalView(props: GoalViewProps) {
|
|
169
|
+
const words = useWords();
|
|
170
|
+
const roadmap = words.roadmap;
|
|
171
|
+
const { goal } = props;
|
|
172
|
+
const towards = props.tasks.filter((task) => task.goalId === goal.id);
|
|
173
|
+
return (
|
|
174
|
+
<div className="sc-task sc-goal-view">
|
|
175
|
+
<header className="sc-task-header">
|
|
176
|
+
<h1 className="sc-task-title">{goal.title}</h1>
|
|
177
|
+
<div className="sc-task-badges">
|
|
178
|
+
<GoalStateBadge state={goal.state} />
|
|
179
|
+
{goal.dueOn !== null && (
|
|
180
|
+
<DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</header>
|
|
184
|
+
<div className="sc-task-body">
|
|
185
|
+
<div className="sc-task-main">
|
|
186
|
+
<ProgressBar counts={goal.taskCounts} />
|
|
187
|
+
{goal.body.length > 0 ? (
|
|
188
|
+
<Markdown source={goal.body} docHref={props.docHref} />
|
|
189
|
+
) : (
|
|
190
|
+
<p className="sc-muted">{roadmap.nothingMore}</p>
|
|
191
|
+
)}
|
|
192
|
+
<section className="sc-task-section" aria-label={roadmap.tasks}>
|
|
193
|
+
<h2 className="sc-section-title">{roadmap.tasks}</h2>
|
|
194
|
+
{towards.length === 0 ? (
|
|
195
|
+
<p className="sc-muted">{roadmap.noTasks}</p>
|
|
196
|
+
) : (
|
|
197
|
+
TASK_STATES.map((state) => {
|
|
198
|
+
const inState = towards.filter((task) => task.state === state);
|
|
199
|
+
return inState.length === 0 ? null : (
|
|
200
|
+
<div key={state} className="sc-goal-tasks">
|
|
201
|
+
<h3 className="sc-goal-tasks-state">
|
|
202
|
+
{words.vocabulary.state[state]}{" "}
|
|
203
|
+
<span className="sc-column-count">{inState.length}</span>
|
|
204
|
+
</h3>
|
|
205
|
+
<TaskList tasks={inState} taskHref={props.taskHref} today={props.today} />
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
})
|
|
209
|
+
)}
|
|
210
|
+
</section>
|
|
211
|
+
</div>
|
|
212
|
+
<aside className="sc-task-aside" aria-label={words.task.facts}>
|
|
213
|
+
<dl className="sc-facts">
|
|
214
|
+
<div>
|
|
215
|
+
<dt>{roadmap.state}</dt>
|
|
216
|
+
<dd>
|
|
217
|
+
{props.onMove === undefined ? (
|
|
218
|
+
words.vocabulary.goalState[goal.state]
|
|
219
|
+
) : (
|
|
220
|
+
<select
|
|
221
|
+
className="sc-input"
|
|
222
|
+
aria-label={roadmap.state}
|
|
223
|
+
value={goal.state}
|
|
224
|
+
disabled={props.busy === true}
|
|
225
|
+
onChange={(event) => props.onMove?.(goal, event.target.value as GoalState)}
|
|
226
|
+
>
|
|
227
|
+
{GOAL_STATES.map((state) => (
|
|
228
|
+
<option key={state} value={state}>
|
|
229
|
+
{words.vocabulary.goalState[state]}
|
|
230
|
+
</option>
|
|
231
|
+
))}
|
|
232
|
+
</select>
|
|
233
|
+
)}
|
|
234
|
+
</dd>
|
|
235
|
+
</div>
|
|
236
|
+
<div>
|
|
237
|
+
<dt>{roadmap.target}</dt>
|
|
238
|
+
<dd>
|
|
239
|
+
{goal.dueOn === null ? (
|
|
240
|
+
<span className="sc-muted">{roadmap.noTarget}</span>
|
|
241
|
+
) : (
|
|
242
|
+
<Day day={goal.dueOn} />
|
|
243
|
+
)}
|
|
244
|
+
</dd>
|
|
245
|
+
</div>
|
|
246
|
+
<div>
|
|
247
|
+
<dt>{roadmap.setBy}</dt>
|
|
248
|
+
<dd>
|
|
249
|
+
<PersonChip person={goal.createdBy} /> <Time iso={goal.createdAt} />
|
|
250
|
+
</dd>
|
|
251
|
+
</div>
|
|
252
|
+
</dl>
|
|
253
|
+
{props.editHref !== undefined && (
|
|
254
|
+
<div className="sc-task-actions">
|
|
255
|
+
<a className="sc-button sc-button-secondary" href={props.editHref}>
|
|
256
|
+
{words.common.edit}
|
|
257
|
+
</a>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
</aside>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|