@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.
Files changed (138) hide show
  1. package/README.md +5 -5
  2. package/dist/api.d.ts +5 -5
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +4 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +255 -10
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +9 -1
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +80 -0
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +62 -7
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +31 -4
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +19 -3
  19. package/dist/client.js.map +1 -1
  20. package/dist/components/board.d.ts +27 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +75 -7
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/calendar.d.ts +34 -0
  25. package/dist/components/calendar.d.ts.map +1 -0
  26. package/dist/components/calendar.js +96 -0
  27. package/dist/components/calendar.js.map +1 -0
  28. package/dist/components/categories.d.ts +13 -0
  29. package/dist/components/categories.d.ts.map +1 -0
  30. package/dist/components/categories.js +52 -0
  31. package/dist/components/categories.js.map +1 -0
  32. package/dist/components/decision-list.d.ts +10 -0
  33. package/dist/components/decision-list.d.ts.map +1 -1
  34. package/dist/components/decision-list.js +14 -1
  35. package/dist/components/decision-list.js.map +1 -1
  36. package/dist/components/event-feed.d.ts.map +1 -1
  37. package/dist/components/event-feed.js +12 -0
  38. package/dist/components/event-feed.js.map +1 -1
  39. package/dist/components/goals.d.ts +35 -0
  40. package/dist/components/goals.d.ts.map +1 -0
  41. package/dist/components/goals.js +45 -0
  42. package/dist/components/goals.js.map +1 -0
  43. package/dist/components/task-form.d.ts +5 -1
  44. package/dist/components/task-form.d.ts.map +1 -1
  45. package/dist/components/task-form.js +13 -1
  46. package/dist/components/task-form.js.map +1 -1
  47. package/dist/components/task-list.d.ts +16 -0
  48. package/dist/components/task-list.d.ts.map +1 -0
  49. package/dist/components/task-list.js +14 -0
  50. package/dist/components/task-list.js.map +1 -0
  51. package/dist/components/task-view.d.ts +9 -1
  52. package/dist/components/task-view.d.ts.map +1 -1
  53. package/dist/components/task-view.js +10 -4
  54. package/dist/components/task-view.js.map +1 -1
  55. package/dist/components/ui.d.ts +30 -1
  56. package/dist/components/ui.d.ts.map +1 -1
  57. package/dist/components/ui.js +60 -0
  58. package/dist/components/ui.js.map +1 -1
  59. package/dist/components/work.d.ts +10 -0
  60. package/dist/components/work.d.ts.map +1 -0
  61. package/dist/components/work.js +29 -0
  62. package/dist/components/work.js.map +1 -0
  63. package/dist/console.css +511 -0
  64. package/dist/console.d.ts +8 -0
  65. package/dist/console.d.ts.map +1 -1
  66. package/dist/console.js +124 -8
  67. package/dist/console.js.map +1 -1
  68. package/dist/data.d.ts +14 -1
  69. package/dist/data.d.ts.map +1 -1
  70. package/dist/data.js +36 -2
  71. package/dist/data.js.map +1 -1
  72. package/dist/dates.d.ts +39 -0
  73. package/dist/dates.d.ts.map +1 -0
  74. package/dist/dates.js +120 -0
  75. package/dist/dates.js.map +1 -0
  76. package/dist/i18n/en.d.ts +162 -1
  77. package/dist/i18n/en.d.ts.map +1 -1
  78. package/dist/i18n/en.js +174 -1
  79. package/dist/i18n/en.js.map +1 -1
  80. package/dist/i18n/ko.d.ts.map +1 -1
  81. package/dist/i18n/ko.js +169 -1
  82. package/dist/i18n/ko.js.map +1 -1
  83. package/dist/index.d.ts +10 -4
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +10 -4
  86. package/dist/index.js.map +1 -1
  87. package/dist/limits.d.ts +5 -0
  88. package/dist/limits.d.ts.map +1 -1
  89. package/dist/limits.js +5 -0
  90. package/dist/limits.js.map +1 -1
  91. package/dist/router.d.ts +50 -1
  92. package/dist/router.d.ts.map +1 -1
  93. package/dist/router.js +74 -1
  94. package/dist/router.js.map +1 -1
  95. package/dist/routes.d.ts +7 -1
  96. package/dist/routes.d.ts.map +1 -1
  97. package/dist/routes.js +9 -0
  98. package/dist/routes.js.map +1 -1
  99. package/dist/schemas.d.ts +366 -0
  100. package/dist/schemas.d.ts.map +1 -1
  101. package/dist/schemas.js +112 -3
  102. package/dist/schemas.js.map +1 -1
  103. package/dist/vocabulary.d.ts +15 -1
  104. package/dist/vocabulary.d.ts.map +1 -1
  105. package/dist/vocabulary.js +26 -0
  106. package/dist/vocabulary.js.map +1 -1
  107. package/docs/specs/cli.md +11 -4
  108. package/docs/specs/rest.md +33 -8
  109. package/package.json +1 -1
  110. package/skills/working-the-board/SKILL.md +7 -6
  111. package/src/api.ts +30 -0
  112. package/src/cli/cli.ts +311 -10
  113. package/src/cli/format.ts +88 -0
  114. package/src/cli/help.ts +69 -7
  115. package/src/client.ts +70 -2
  116. package/src/components/board.tsx +205 -12
  117. package/src/components/calendar.tsx +255 -0
  118. package/src/components/categories.tsx +209 -0
  119. package/src/components/decision-list.tsx +62 -5
  120. package/src/components/event-feed.tsx +12 -0
  121. package/src/components/goals.tsx +264 -0
  122. package/src/components/task-form.tsx +78 -1
  123. package/src/components/task-list.tsx +61 -0
  124. package/src/components/task-view.tsx +74 -3
  125. package/src/components/ui.tsx +116 -1
  126. package/src/components/work.tsx +83 -0
  127. package/src/console.tsx +337 -4
  128. package/src/data.ts +63 -8
  129. package/src/dates.ts +143 -0
  130. package/src/i18n/en.ts +184 -1
  131. package/src/i18n/ko.ts +175 -1
  132. package/src/index.ts +58 -1
  133. package/src/limits.ts +5 -0
  134. package/src/router.ts +119 -6
  135. package/src/routes.ts +10 -0
  136. package/src/schemas.ts +141 -1
  137. package/src/styles/console.css +511 -0
  138. 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 each, and the ones answered, with
15
- // who answered and when; each a record (ADR-0009) of its context, its answer with the
16
- // rationale, and what followed. Each takes its data as props and nothing from the network.
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.answered} <span className="sc-column-count">{answered.length}</span>
322
+ {words.decisions.record} <span className="sc-column-count">{answered.length}</span>
272
323
  </h2>
273
- {answered.map(card)}
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
+ }