@skillcdn/console 0.1.8 → 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/building-a-console/SKILL.md +4 -4
- package/skills/building-a-console/references/the-default-console.md +1 -1
- package/skills/deploying-a-console/SKILL.md +11 -10
- package/skills/deploying-a-console/assets/deployer-policy.json +13 -12
- package/skills/deploying-a-console/assets/secrets.example.json +4 -4
- package/skills/deploying-a-console/references/access.md +10 -9
- package/skills/deploying-a-console/references/resources.md +107 -50
- package/skills/deploying-a-console/references/sizing.md +1 -1
- package/skills/deploying-a-console/scripts/store-secrets.mjs +1 -15
- 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,255 @@
|
|
|
1
|
+
import { OPEN_TASK_STATES, type RestPerson, type RestTask } from "../api.js";
|
|
2
|
+
import {
|
|
3
|
+
addMonths,
|
|
4
|
+
daysBetween,
|
|
5
|
+
firstDayOfWeek,
|
|
6
|
+
formatMonth,
|
|
7
|
+
formatWeekday,
|
|
8
|
+
monthGrid,
|
|
9
|
+
monthOf,
|
|
10
|
+
today as todayOf,
|
|
11
|
+
weekdays,
|
|
12
|
+
} from "../dates.js";
|
|
13
|
+
import { useLanguage, useWords } from "../i18n/index.js";
|
|
14
|
+
import { cx, PersonChip } from "./ui.js";
|
|
15
|
+
|
|
16
|
+
// The calendar of a project (ADR-0019): the month as a grid, each task on the day it is due
|
|
17
|
+
// and, lightly, on the day it starts; and who has what, so that whoever runs the project sees
|
|
18
|
+
// at a glance whether the work is on its days. Takes its data as props and nothing from the
|
|
19
|
+
// network; the month shown is the address's (ADR-0010).
|
|
20
|
+
|
|
21
|
+
const isOpen = (task: RestTask): boolean => OPEN_TASK_STATES.includes(task.state);
|
|
22
|
+
|
|
23
|
+
export interface CalendarProps {
|
|
24
|
+
readonly tasks: readonly RestTask[];
|
|
25
|
+
/** The month shown: `2026-10`. */
|
|
26
|
+
readonly month: string;
|
|
27
|
+
/** The day to count from; the browser's today when left out. */
|
|
28
|
+
readonly today?: string | undefined;
|
|
29
|
+
readonly taskHref: (task: RestTask) => string;
|
|
30
|
+
/** Where the calendar is on another month. */
|
|
31
|
+
readonly monthHref: (month: string) => string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function push(map: Map<string, RestTask[]>, day: string, task: RestTask): void {
|
|
35
|
+
const list = map.get(day);
|
|
36
|
+
if (list === undefined) {
|
|
37
|
+
map.set(day, [task]);
|
|
38
|
+
} else {
|
|
39
|
+
list.push(task);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function Calendar(props: CalendarProps) {
|
|
44
|
+
const words = useWords().calendar;
|
|
45
|
+
const { tag } = useLanguage();
|
|
46
|
+
const now = props.today ?? todayOf();
|
|
47
|
+
const firstDay = firstDayOfWeek(tag);
|
|
48
|
+
const weeks = monthGrid(props.month, firstDay);
|
|
49
|
+
const due = new Map<string, RestTask[]>();
|
|
50
|
+
const starting = new Map<string, RestTask[]>();
|
|
51
|
+
for (const task of props.tasks) {
|
|
52
|
+
if (task.dueOn !== null) {
|
|
53
|
+
push(due, task.dueOn, task);
|
|
54
|
+
}
|
|
55
|
+
if (task.startsOn !== null && task.startsOn !== task.dueOn) {
|
|
56
|
+
push(starting, task.startsOn, task);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Within a day, what is still open comes first; the rest reads as what was.
|
|
60
|
+
for (const list of due.values()) {
|
|
61
|
+
list.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
|
|
62
|
+
}
|
|
63
|
+
const inMonth = (day: string) => monthOf(day) === props.month;
|
|
64
|
+
const nothing =
|
|
65
|
+
[...due.keys()].every((day) => !inMonth(day)) &&
|
|
66
|
+
[...starting.keys()].every((day) => !inMonth(day));
|
|
67
|
+
return (
|
|
68
|
+
<div className="sc-calendar">
|
|
69
|
+
<div className="sc-calendar-head">
|
|
70
|
+
<h2 className="sc-calendar-month">{formatMonth(props.month, tag)}</h2>
|
|
71
|
+
<nav className="sc-calendar-nav" aria-label={words.grid}>
|
|
72
|
+
<a
|
|
73
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
74
|
+
href={props.monthHref(addMonths(props.month, -1))}
|
|
75
|
+
aria-label={words.previous}
|
|
76
|
+
>
|
|
77
|
+
{"<"}
|
|
78
|
+
</a>
|
|
79
|
+
<a
|
|
80
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
81
|
+
href={props.monthHref(monthOf(now))}
|
|
82
|
+
>
|
|
83
|
+
{words.thisMonth}
|
|
84
|
+
</a>
|
|
85
|
+
<a
|
|
86
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
87
|
+
href={props.monthHref(addMonths(props.month, 1))}
|
|
88
|
+
aria-label={words.next}
|
|
89
|
+
>
|
|
90
|
+
{">"}
|
|
91
|
+
</a>
|
|
92
|
+
</nav>
|
|
93
|
+
</div>
|
|
94
|
+
<table className="sc-calendar-grid" aria-label={words.grid}>
|
|
95
|
+
<thead>
|
|
96
|
+
<tr>
|
|
97
|
+
{weekdays(firstDay).map((weekday) => (
|
|
98
|
+
<th key={weekday} scope="col">
|
|
99
|
+
{formatWeekday(weekday, tag)}
|
|
100
|
+
</th>
|
|
101
|
+
))}
|
|
102
|
+
</tr>
|
|
103
|
+
</thead>
|
|
104
|
+
<tbody>
|
|
105
|
+
{weeks.map((week) => (
|
|
106
|
+
<tr key={week[0]?.day}>
|
|
107
|
+
{week.map((cell) => (
|
|
108
|
+
<td
|
|
109
|
+
key={cell.day}
|
|
110
|
+
className={cx(
|
|
111
|
+
"sc-calendar-cell",
|
|
112
|
+
!cell.inMonth && "sc-calendar-cell-outside",
|
|
113
|
+
cell.day === now && "sc-calendar-cell-today",
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
<span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
|
|
117
|
+
{(due.get(cell.day) ?? []).map((task) => (
|
|
118
|
+
<a
|
|
119
|
+
key={task.id}
|
|
120
|
+
className={cx(
|
|
121
|
+
"sc-calendar-task",
|
|
122
|
+
!isOpen(task) && "sc-calendar-task-over",
|
|
123
|
+
isOpen(task) && cell.day < now && "sc-calendar-task-late",
|
|
124
|
+
)}
|
|
125
|
+
href={props.taskHref(task)}
|
|
126
|
+
title={`#${task.number} ${task.title}`}
|
|
127
|
+
>
|
|
128
|
+
<span
|
|
129
|
+
className={cx(
|
|
130
|
+
"sc-calendar-dot",
|
|
131
|
+
`sc-category-${task.category?.color ?? "gray"}`,
|
|
132
|
+
)}
|
|
133
|
+
/>
|
|
134
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
135
|
+
</a>
|
|
136
|
+
))}
|
|
137
|
+
{(starting.get(cell.day) ?? []).map((task) => (
|
|
138
|
+
<a
|
|
139
|
+
key={task.id}
|
|
140
|
+
className="sc-calendar-task sc-calendar-task-start"
|
|
141
|
+
href={props.taskHref(task)}
|
|
142
|
+
title={`#${task.number} ${task.title}: ${words.starts}`}
|
|
143
|
+
>
|
|
144
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
145
|
+
</a>
|
|
146
|
+
))}
|
|
147
|
+
</td>
|
|
148
|
+
))}
|
|
149
|
+
</tr>
|
|
150
|
+
))}
|
|
151
|
+
</tbody>
|
|
152
|
+
</table>
|
|
153
|
+
{nothing && <p className="sc-muted">{words.nothingDue}</p>}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One person's share of the open work, as the table of who has what says it. */
|
|
159
|
+
export interface WorkloadRow {
|
|
160
|
+
/** The person, or nobody for the tasks assigned to no one. */
|
|
161
|
+
readonly person: RestPerson | undefined;
|
|
162
|
+
readonly open: number;
|
|
163
|
+
readonly inProgress: number;
|
|
164
|
+
/** Open, and due within the next seven days from today. */
|
|
165
|
+
readonly dueSoon: number;
|
|
166
|
+
/** Open, and past its day. */
|
|
167
|
+
readonly overdue: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Who has what, from the open tasks: by login, with the unassigned last. */
|
|
171
|
+
export function workloadOf(tasks: readonly RestTask[], today: string): WorkloadRow[] {
|
|
172
|
+
const rows = new Map<string, { person: RestPerson | undefined; tasks: RestTask[] }>();
|
|
173
|
+
for (const task of tasks.filter(isOpen)) {
|
|
174
|
+
const key = task.assignee?.id ?? "";
|
|
175
|
+
const row = rows.get(key);
|
|
176
|
+
if (row === undefined) {
|
|
177
|
+
rows.set(key, { person: task.assignee ?? undefined, tasks: [task] });
|
|
178
|
+
} else {
|
|
179
|
+
row.tasks.push(task);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const count = (list: readonly RestTask[]): Omit<WorkloadRow, "person"> => ({
|
|
183
|
+
open: list.length,
|
|
184
|
+
inProgress: list.filter((task) => task.state === "in_progress" || task.state === "in_review")
|
|
185
|
+
.length,
|
|
186
|
+
dueSoon: list.filter((task) => {
|
|
187
|
+
if (task.dueOn === null) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
const days = daysBetween(today, task.dueOn);
|
|
191
|
+
return days >= 0 && days <= 7;
|
|
192
|
+
}).length,
|
|
193
|
+
overdue: list.filter((task) => task.dueOn !== null && task.dueOn < today).length,
|
|
194
|
+
});
|
|
195
|
+
return [...rows.values()]
|
|
196
|
+
.map((row) => ({ person: row.person, ...count(row.tasks) }))
|
|
197
|
+
.sort((a, b) => {
|
|
198
|
+
if (a.person === undefined) {
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
if (b.person === undefined) {
|
|
202
|
+
return -1;
|
|
203
|
+
}
|
|
204
|
+
return a.person.login.localeCompare(b.person.login);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface WorkloadProps {
|
|
209
|
+
readonly tasks: readonly RestTask[];
|
|
210
|
+
readonly today?: string | undefined;
|
|
211
|
+
/** Where the board is, narrowed to one person's tasks, or to the unassigned. */
|
|
212
|
+
readonly personHref?: ((person: RestPerson | undefined) => string) | undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Who has what: a row per person, and what of theirs is late or due soon. */
|
|
216
|
+
export function Workload(props: WorkloadProps) {
|
|
217
|
+
const words = useWords().calendar.workload;
|
|
218
|
+
const rows = workloadOf(props.tasks, props.today ?? todayOf());
|
|
219
|
+
if (rows.length === 0) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return (
|
|
223
|
+
<table className="sc-workload" aria-label={words.title}>
|
|
224
|
+
<thead>
|
|
225
|
+
<tr>
|
|
226
|
+
<th scope="col">{words.person}</th>
|
|
227
|
+
<th scope="col">{words.open}</th>
|
|
228
|
+
<th scope="col">{words.inProgress}</th>
|
|
229
|
+
<th scope="col">{words.dueSoon}</th>
|
|
230
|
+
<th scope="col">{words.overdue}</th>
|
|
231
|
+
</tr>
|
|
232
|
+
</thead>
|
|
233
|
+
<tbody>
|
|
234
|
+
{rows.map((row) => {
|
|
235
|
+
const href = props.personHref?.(row.person);
|
|
236
|
+
const who =
|
|
237
|
+
row.person === undefined ? (
|
|
238
|
+
<span className="sc-muted">{words.unassigned}</span>
|
|
239
|
+
) : (
|
|
240
|
+
<PersonChip person={row.person} />
|
|
241
|
+
);
|
|
242
|
+
return (
|
|
243
|
+
<tr key={row.person?.id ?? ""}>
|
|
244
|
+
<th scope="row">{href === undefined ? who : <a href={href}>{who}</a>}</th>
|
|
245
|
+
<td>{row.open}</td>
|
|
246
|
+
<td>{row.inProgress}</td>
|
|
247
|
+
<td className={cx(row.dueSoon > 0 && "sc-workload-soon")}>{row.dueSoon}</td>
|
|
248
|
+
<td className={cx(row.overdue > 0 && "sc-workload-late")}>{row.overdue}</td>
|
|
249
|
+
</tr>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
</tbody>
|
|
253
|
+
</table>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -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":
|