@skillcdn/console 0.4.4 → 0.4.5

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 (192) hide show
  1. package/README.md +3 -3
  2. package/dist/api.d.ts +3 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -3
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +162 -15
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +16 -3
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +68 -19
  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 +26 -5
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +28 -1
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +12 -2
  19. package/dist/client.js.map +1 -1
  20. package/dist/compat.js +1 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +46 -15
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/decision-form.d.ts +0 -3
  25. package/dist/components/decision-form.d.ts.map +1 -1
  26. package/dist/components/decision-form.js +3 -15
  27. package/dist/components/decision-form.js.map +1 -1
  28. package/dist/components/event-feed.d.ts.map +1 -1
  29. package/dist/components/event-feed.js +30 -10
  30. package/dist/components/event-feed.js.map +1 -1
  31. package/dist/components/my-decisions.d.ts +20 -3
  32. package/dist/components/my-decisions.d.ts.map +1 -1
  33. package/dist/components/my-decisions.js +14 -1
  34. package/dist/components/my-decisions.js.map +1 -1
  35. package/dist/components/people.d.ts +43 -1
  36. package/dist/components/people.d.ts.map +1 -1
  37. package/dist/components/people.js +117 -9
  38. package/dist/components/people.js.map +1 -1
  39. package/dist/components/picker.d.ts +8 -0
  40. package/dist/components/picker.d.ts.map +1 -1
  41. package/dist/components/picker.js +2 -2
  42. package/dist/components/picker.js.map +1 -1
  43. package/dist/components/plugin-frame.d.ts +0 -1
  44. package/dist/components/plugin-frame.d.ts.map +1 -1
  45. package/dist/components/plugin-frame.js +22 -1
  46. package/dist/components/plugin-frame.js.map +1 -1
  47. package/dist/components/plugin-settings.d.ts +5 -1
  48. package/dist/components/plugin-settings.d.ts.map +1 -1
  49. package/dist/components/plugin-settings.js +143 -30
  50. package/dist/components/plugin-settings.js.map +1 -1
  51. package/dist/components/projects.d.ts.map +1 -1
  52. package/dist/components/projects.js +3 -1
  53. package/dist/components/projects.js.map +1 -1
  54. package/dist/components/searches.d.ts +11 -0
  55. package/dist/components/searches.d.ts.map +1 -0
  56. package/dist/components/searches.js +30 -0
  57. package/dist/components/searches.js.map +1 -0
  58. package/dist/components/task-form.d.ts.map +1 -1
  59. package/dist/components/task-form.js +6 -5
  60. package/dist/components/task-form.js.map +1 -1
  61. package/dist/components/ui.d.ts +16 -0
  62. package/dist/components/ui.d.ts.map +1 -1
  63. package/dist/components/ui.js +10 -0
  64. package/dist/components/ui.js.map +1 -1
  65. package/dist/console.css +362 -67
  66. package/dist/console.d.ts +2 -1
  67. package/dist/console.d.ts.map +1 -1
  68. package/dist/console.js +90 -14
  69. package/dist/console.js.map +1 -1
  70. package/dist/data.d.ts +14 -1
  71. package/dist/data.d.ts.map +1 -1
  72. package/dist/data.js +36 -1
  73. package/dist/data.js.map +1 -1
  74. package/dist/i18n/en.d.ts +89 -0
  75. package/dist/i18n/en.d.ts.map +1 -1
  76. package/dist/i18n/en.js +87 -2
  77. package/dist/i18n/en.js.map +1 -1
  78. package/dist/i18n/ko.d.ts.map +1 -1
  79. package/dist/i18n/ko.js +87 -3
  80. package/dist/i18n/ko.js.map +1 -1
  81. package/dist/index.d.ts +3 -2
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +3 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/limits.d.ts +6 -0
  86. package/dist/limits.d.ts.map +1 -1
  87. package/dist/limits.js +6 -0
  88. package/dist/limits.js.map +1 -1
  89. package/dist/plugin/args.d.ts +6 -1
  90. package/dist/plugin/args.d.ts.map +1 -1
  91. package/dist/plugin/args.js +16 -11
  92. package/dist/plugin/args.js.map +1 -1
  93. package/dist/plugin/bridge.d.ts +20 -1
  94. package/dist/plugin/bridge.d.ts.map +1 -1
  95. package/dist/plugin/bridge.js +28 -0
  96. package/dist/plugin/bridge.js.map +1 -1
  97. package/dist/plugin/capabilities.d.ts +1 -1
  98. package/dist/plugin/capabilities.d.ts.map +1 -1
  99. package/dist/plugin/capabilities.js +10 -0
  100. package/dist/plugin/capabilities.js.map +1 -1
  101. package/dist/plugin/context.d.ts +158 -13
  102. package/dist/plugin/context.d.ts.map +1 -1
  103. package/dist/plugin/define.d.ts +13 -1
  104. package/dist/plugin/define.d.ts.map +1 -1
  105. package/dist/plugin/define.js +17 -1
  106. package/dist/plugin/define.js.map +1 -1
  107. package/dist/plugin/limits.d.ts +4 -0
  108. package/dist/plugin/limits.d.ts.map +1 -1
  109. package/dist/plugin/limits.js +4 -0
  110. package/dist/plugin/limits.js.map +1 -1
  111. package/dist/plugin/manifest.d.ts +15 -0
  112. package/dist/plugin/manifest.d.ts.map +1 -1
  113. package/dist/plugin/manifest.js +61 -4
  114. package/dist/plugin/manifest.js.map +1 -1
  115. package/dist/plugin/rest-schemas.d.ts +44 -0
  116. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  117. package/dist/plugin/rest-schemas.js +55 -3
  118. package/dist/plugin/rest-schemas.js.map +1 -1
  119. package/dist/plugin/testing.d.ts +15 -1
  120. package/dist/plugin/testing.d.ts.map +1 -1
  121. package/dist/plugin/testing.js +40 -4
  122. package/dist/plugin/testing.js.map +1 -1
  123. package/dist/plugin/web.d.ts +6 -0
  124. package/dist/plugin/web.d.ts.map +1 -1
  125. package/dist/plugin/web.js +10 -1
  126. package/dist/plugin/web.js.map +1 -1
  127. package/dist/plugin.d.ts +1 -1
  128. package/dist/plugin.d.ts.map +1 -1
  129. package/dist/plugin.js +1 -1
  130. package/dist/plugin.js.map +1 -1
  131. package/dist/router.d.ts +19 -0
  132. package/dist/router.d.ts.map +1 -1
  133. package/dist/router.js +18 -0
  134. package/dist/router.js.map +1 -1
  135. package/dist/routes.d.ts +11 -2
  136. package/dist/routes.d.ts.map +1 -1
  137. package/dist/routes.js +14 -1
  138. package/dist/routes.js.map +1 -1
  139. package/dist/schemas.d.ts +367 -0
  140. package/dist/schemas.d.ts.map +1 -1
  141. package/dist/schemas.js +62 -2
  142. package/dist/schemas.js.map +1 -1
  143. package/dist/vocabulary.d.ts +1 -1
  144. package/dist/vocabulary.d.ts.map +1 -1
  145. package/dist/vocabulary.js +6 -0
  146. package/dist/vocabulary.js.map +1 -1
  147. package/docs/specs/cli.md +7 -4
  148. package/docs/specs/plugins.md +47 -39
  149. package/docs/specs/rest.md +22 -5
  150. package/package.json +1 -1
  151. package/skills/building-a-plugin/SKILL.md +8 -6
  152. package/skills/working-the-board/SKILL.md +1 -1
  153. package/src/api.ts +17 -0
  154. package/src/cli/cli.ts +200 -12
  155. package/src/cli/format.ts +82 -19
  156. package/src/cli/help.ts +30 -5
  157. package/src/client.ts +70 -0
  158. package/src/compat.ts +1 -1
  159. package/src/components/board.tsx +83 -42
  160. package/src/components/decision-form.tsx +13 -32
  161. package/src/components/event-feed.tsx +36 -9
  162. package/src/components/my-decisions.tsx +50 -5
  163. package/src/components/people.tsx +394 -37
  164. package/src/components/picker.tsx +11 -2
  165. package/src/components/plugin-frame.tsx +23 -0
  166. package/src/components/plugin-settings.tsx +346 -137
  167. package/src/components/projects.tsx +14 -14
  168. package/src/components/searches.ts +47 -0
  169. package/src/components/task-form.tsx +23 -28
  170. package/src/components/ui.tsx +36 -0
  171. package/src/console.tsx +229 -40
  172. package/src/data.ts +53 -1
  173. package/src/i18n/en.ts +98 -2
  174. package/src/i18n/ko.ts +95 -3
  175. package/src/index.ts +11 -0
  176. package/src/limits.ts +6 -0
  177. package/src/plugin/args.ts +16 -11
  178. package/src/plugin/bridge.ts +45 -0
  179. package/src/plugin/capabilities.ts +11 -0
  180. package/src/plugin/context.ts +170 -10
  181. package/src/plugin/define.ts +34 -0
  182. package/src/plugin/limits.ts +4 -0
  183. package/src/plugin/manifest.ts +74 -3
  184. package/src/plugin/rest-schemas.ts +62 -5
  185. package/src/plugin/testing.ts +61 -5
  186. package/src/plugin/web.ts +17 -0
  187. package/src/plugin.ts +1 -0
  188. package/src/router.ts +25 -0
  189. package/src/routes.ts +17 -2
  190. package/src/schemas.ts +81 -2
  191. package/src/styles/console.css +362 -67
  192. package/src/vocabulary.ts +6 -0
@@ -23,6 +23,8 @@ import {
23
23
  type RestProjectPatch,
24
24
  } from "../api.js";
25
25
  import { useWords } from "../i18n/index.js";
26
+ import { Picker } from "./picker.js";
27
+ import { peopleSearch } from "./searches.js";
26
28
  import { Badge, Button, cx, PersonChip, personName, Time } from "./ui.js";
27
29
 
28
30
  /**
@@ -462,21 +464,19 @@ export function MemberList(props: MemberListProps) {
462
464
  }}
463
465
  >
464
466
  <div className="sc-field-row">
465
- <label className="sc-field">
467
+ <div className="sc-field">
466
468
  <span className="sc-field-label">{members.person}</span>
467
- <select
468
- className="sc-input"
469
- value={personId}
470
- onChange={(event) => setPersonId(event.target.value)}
471
- >
472
- <option value="">{members.chooseSomeone}</option>
473
- {candidates.map((person) => (
474
- <option key={person.id} value={person.id}>
475
- {personName(person)}
476
- </option>
477
- ))}
478
- </select>
479
- </label>
469
+ <Picker
470
+ value={candidates.find((person) => person.id === personId)}
471
+ search={peopleSearch(candidates)}
472
+ keyOf={(person) => person.id}
473
+ render={(person) => <PersonChip person={person} size="sm" />}
474
+ label={members.person}
475
+ placeholder={members.chooseSomeone}
476
+ disabled={props.busy === true}
477
+ onChange={(person) => setPersonId(person?.id ?? "")}
478
+ />
479
+ </div>
480
480
  <label className="sc-field">
481
481
  <span className="sc-field-label">{members.as}</span>
482
482
  <select
@@ -0,0 +1,47 @@
1
+ import type { RestGoal, RestPerson, RestProject } from "../api.js";
2
+ import type { PickerSearch } from "./picker.js";
3
+ import { personName } from "./ui.js";
4
+
5
+ // What a picker asks for, answered from a listing the page already holds (ADR-0037): the people,
6
+ // the projects or the goals the page was given, found by the words typed, a page at a time, so
7
+ // that choosing one among many is the same wherever one is chosen, and the board's filters, a
8
+ // setting, a manager or a worker never offer a list too long to read.
9
+
10
+ /** A page of `items` found by the words typed, as a picker searches; everything while the words are empty. */
11
+ export function listSearch<T>(
12
+ items: readonly T[],
13
+ matches: (item: T, words: string) => boolean,
14
+ ): PickerSearch<T> {
15
+ return async (query, page) => {
16
+ const words = query.trim().toLowerCase();
17
+ const found = words.length === 0 ? items : items.filter((item) => matches(item, words));
18
+ return {
19
+ items: found.slice(page.offset, page.offset + page.limit),
20
+ more: found.length > page.offset + page.limit,
21
+ };
22
+ };
23
+ }
24
+
25
+ /** The people given, found by name or login. */
26
+ export function peopleSearch(people: readonly RestPerson[]): PickerSearch<RestPerson> {
27
+ return listSearch(
28
+ people,
29
+ (person, words) =>
30
+ personName(person).toLowerCase().includes(words) ||
31
+ person.login.toLowerCase().includes(words),
32
+ );
33
+ }
34
+
35
+ /** The projects given, found by name or key. */
36
+ export function projectsSearch(projects: readonly RestProject[]): PickerSearch<RestProject> {
37
+ return listSearch(
38
+ projects,
39
+ (project, words) =>
40
+ project.name.toLowerCase().includes(words) || project.key.toLowerCase().includes(words),
41
+ );
42
+ }
43
+
44
+ /** The goals given, found by title. */
45
+ export function goalsSearch(goals: readonly RestGoal[]): PickerSearch<RestGoal> {
46
+ return listSearch(goals, (goal, words) => goal.title.toLowerCase().includes(words));
47
+ }
@@ -16,7 +16,8 @@ import {
16
16
  } from "../api.js";
17
17
  import { useWords } from "../i18n/index.js";
18
18
  import { Picker, type PickerSearch } from "./picker.js";
19
- import { Button, GoalStateBadge, PersonChip, personName, StateBadge } from "./ui.js";
19
+ import { peopleSearch } from "./searches.js";
20
+ import { Button, Chip, GoalStateBadge, PersonChip, personName, StateBadge } from "./ui.js";
20
21
 
21
22
  // Writing a task, or changing one. The bounds are the package's, said before a request is made.
22
23
  // The plan is two days (ADR-0019); what happened to the task is the board's to keep, not a
@@ -222,37 +223,31 @@ export function TaskForm(props: TaskFormProps) {
222
223
  <legend className="sc-field-label">{form.workers}</legend>
223
224
  <div className="sc-chips">
224
225
  {chosenWorkers.map((person) => (
225
- <span key={person.id} className="sc-chip">
226
+ <Chip
227
+ key={person.id}
228
+ onRemove={() => setWorkerIds(workerIds.filter((id) => id !== person.id))}
229
+ removeLabel={`${words.common.remove}: ${personName(person)}`}
230
+ >
226
231
  <PersonChip person={person} />
227
- <button
228
- type="button"
229
- className="sc-chip-remove"
230
- aria-label={`${words.common.remove}: ${personName(person)}`}
231
- onClick={() => setWorkerIds(workerIds.filter((id) => id !== person.id))}
232
- >
233
- <span aria-hidden="true">&times;</span>
234
- </button>
235
- </span>
232
+ </Chip>
236
233
  ))}
237
- {chosenWorkers.length === 0 && <span className="sc-muted">{words.common.nobody}</span>}
238
- {otherPeople.length > 0 && workerIds.length < MAX_WORKERS && (
239
- <select
240
- className="sc-input sc-chip-add"
241
- aria-label={form.addWorker}
242
- value=""
243
- onChange={(event) => {
244
- if (event.target.value !== "") {
245
- setWorkerIds([...workerIds, event.target.value]);
234
+ {otherPeople.length > 0 && workerIds.length < MAX_WORKERS ? (
235
+ <Picker
236
+ className="sc-picker-add"
237
+ value={undefined}
238
+ search={peopleSearch(otherPeople)}
239
+ keyOf={(person) => person.id}
240
+ render={(person) => <PersonChip person={person} size="sm" />}
241
+ label={form.addWorker}
242
+ placeholder={form.addWorker}
243
+ onChange={(person) => {
244
+ if (person !== undefined) {
245
+ setWorkerIds([...workerIds, person.id]);
246
246
  }
247
247
  }}
248
- >
249
- <option value="">{form.addWorker}</option>
250
- {otherPeople.map((person) => (
251
- <option key={person.id} value={person.id}>
252
- {personName(person)}
253
- </option>
254
- ))}
255
- </select>
248
+ />
249
+ ) : (
250
+ chosenWorkers.length === 0 && <span className="sc-muted">{words.common.nobody}</span>
256
251
  )}
257
252
  </div>
258
253
  </fieldset>
@@ -164,6 +164,42 @@ export function PersonChip(props: { readonly person: RestPerson; readonly size?:
164
164
  );
165
165
  }
166
166
 
167
+ /**
168
+ * A thing named among others in a row (`sc-chips`): a person (a `PersonChip` inside), a team, an
169
+ * item of a list, a holder of a role. Given `onRemove`, it carries the way to take it out, named
170
+ * for a screen reader by `removeLabel`; left out, the chip is read, not changed. Every row of
171
+ * things a person adds to and takes from is made of these, so that they read alike everywhere.
172
+ */
173
+ export function Chip(props: {
174
+ readonly children: ReactNode;
175
+ readonly onRemove?: (() => void) | undefined;
176
+ /** What the way out says to a screen reader: the word for removing, and what the chip names. */
177
+ readonly removeLabel?: string | undefined;
178
+ readonly disabled?: boolean | undefined;
179
+ readonly title?: string | undefined;
180
+ /** A class of the chip's own beside `sc-chip`. */
181
+ readonly className?: string | undefined;
182
+ }) {
183
+ const words = useWords().common;
184
+ return (
185
+ <span className={cx("sc-chip", props.className)} title={props.title}>
186
+ <span className="sc-chip-label">{props.children}</span>
187
+ {props.onRemove !== undefined && (
188
+ <button
189
+ type="button"
190
+ className="sc-chip-remove"
191
+ aria-label={props.removeLabel ?? words.remove}
192
+ title={props.removeLabel ?? words.remove}
193
+ disabled={props.disabled}
194
+ onClick={props.onRemove}
195
+ >
196
+ <span aria-hidden="true">&times;</span>
197
+ </button>
198
+ )}
199
+ </span>
200
+ );
201
+ }
202
+
167
203
  /** How many of a task's workers are shown as pictures before the rest are a count. */
168
204
  const WORKERS_SHOWN = 3;
169
205
 
package/src/console.tsx CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  type RestProjectInput,
30
30
  type RestProjectPatch,
31
31
  type RestProjectRules,
32
+ type RestReachedDecision,
32
33
  type RestRules,
33
34
  type RestTask,
34
35
  type RestTaskInput,
@@ -49,10 +50,17 @@ import { DecisionCard, DecisionList, type DecisionListProps } from "./components
49
50
  import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
50
51
  import { GoalForm, GoalList, type GoalListProps, GoalView, goalMonth } from "./components/goals.js";
51
52
  import { HomeGrid, type HomeGridProps } from "./components/home.js";
52
- import { MyDecisions } from "./components/my-decisions.js";
53
+ import { MyDecisions, ReachedDecision } from "./components/my-decisions.js";
53
54
  import { PackTokenWays } from "./components/pack-token.js";
54
55
  import { PAGE_SIZE } from "./components/paged.js";
55
- import { ListingForm, PeopleList, type PeopleListProps } from "./components/people.js";
56
+ import {
57
+ ListingForm,
58
+ PeopleList,
59
+ type PeopleListProps,
60
+ TeamForm,
61
+ TeamList,
62
+ type TeamListProps,
63
+ } from "./components/people.js";
56
64
  import { PluginFrame } from "./components/plugin-frame.js";
57
65
  import { PluginSettingsList, PluginSettingsView } from "./components/plugin-settings.js";
58
66
  import {
@@ -81,6 +89,7 @@ import {
81
89
  Button,
82
90
  Callout,
83
91
  CategoryBadge,
92
+ cx,
84
93
  EmptyState,
85
94
  LinkButton,
86
95
  personName,
@@ -119,6 +128,7 @@ import {
119
128
  type GoalTaskView,
120
129
  goalHref,
121
130
  matchRoute,
131
+ myDecisionHref,
122
132
  type Navigation,
123
133
  newDecisionHref,
124
134
  newGoalHref,
@@ -157,6 +167,7 @@ export interface ConsoleComponents {
157
167
  readonly SignIn: ComponentType<SignInProps>;
158
168
  readonly TokenList: ComponentType<TokenListProps>;
159
169
  readonly PeopleList: ComponentType<PeopleListProps>;
170
+ readonly TeamList: ComponentType<TeamListProps>;
160
171
  readonly SkillList: ComponentType<SkillListProps>;
161
172
  readonly HomeGrid: ComponentType<HomeGridProps>;
162
173
  readonly MemberList: ComponentType<MemberListProps>;
@@ -175,6 +186,7 @@ export const DEFAULT_COMPONENTS: ConsoleComponents = {
175
186
  SignIn,
176
187
  TokenList,
177
188
  PeopleList,
189
+ TeamList,
178
190
  SkillList,
179
191
  HomeGrid,
180
192
  MemberList,
@@ -644,6 +656,8 @@ function PluginSettingsPage(props: {
644
656
  detail={detail}
645
657
  projects={data.projects}
646
658
  people={data.people}
659
+ plugins={data.plugins}
660
+ teams={data.teams}
647
661
  language={language}
648
662
  client={client}
649
663
  onChanged={async () => {
@@ -2242,6 +2256,70 @@ function PlatePage(props: {
2242
2256
  );
2243
2257
  }
2244
2258
 
2259
+ /**
2260
+ * One decision wherever it is (ADR-0068): in a project the person may see, or one that reaches
2261
+ * them from elsewhere, addressed to them, raised by them, or a plugin's addressed to someone
2262
+ * they manage; answered here where they may. The address a plugin's page sends a person to,
2263
+ * who may not be in the project the decision waits in.
2264
+ */
2265
+ function MyDecisionPage(props: { readonly id: string; readonly data: ConsoleData }) {
2266
+ const words = useWords();
2267
+ const { id, data } = props;
2268
+ const { reachDecision, answerReachedDecision } = data.actions;
2269
+ const [reached, setReached] = useState<RestReachedDecision | undefined>(undefined);
2270
+ const [problem, setProblem] = useState<string | undefined>(undefined);
2271
+ const { busy, error, act } = useAction();
2272
+ const couldNotLoad = words.myDecisions.couldNotLoad;
2273
+ useEffect(() => {
2274
+ const controller = new AbortController();
2275
+ setReached(undefined);
2276
+ setProblem(undefined);
2277
+ reachDecision(id, controller.signal).then(
2278
+ (found) => {
2279
+ if (!controller.signal.aborted) {
2280
+ setReached(found);
2281
+ }
2282
+ },
2283
+ (failure: unknown) => {
2284
+ if (!controller.signal.aborted) {
2285
+ setProblem(errorWords(failure, couldNotLoad, words));
2286
+ }
2287
+ },
2288
+ );
2289
+ return () => controller.abort();
2290
+ }, [reachDecision, id, couldNotLoad, words]);
2291
+ const shown = problem ?? error;
2292
+ return (
2293
+ <>
2294
+ <div className="sc-page-head">
2295
+ <h1 className="sc-page-title">{words.decisions.one}</h1>
2296
+ <LinkButton href={PATHS.decisions}>{words.myDecisions.title}</LinkButton>
2297
+ </div>
2298
+ {shown !== undefined && <Callout tone="danger">{shown}</Callout>}
2299
+ {reached === undefined ? (
2300
+ shown === undefined && (
2301
+ <div className="sc-loading">
2302
+ <Spinner label={words.myDecisions.loading} />
2303
+ </div>
2304
+ )
2305
+ ) : (
2306
+ <ReachedDecision
2307
+ reached={reached}
2308
+ onAnswer={(_decision, input) =>
2309
+ void act(async () => {
2310
+ const answered = await answerReachedDecision(id, input);
2311
+ setReached((current) =>
2312
+ current === undefined ? current : { ...current, decision: answered },
2313
+ );
2314
+ })
2315
+ }
2316
+ busy={busy}
2317
+ />
2318
+ )}
2319
+ </>
2320
+ );
2321
+ }
2322
+
2245
2323
  /** What is on the person's own plate, across their projects. */
2246
2324
  /** How many of a person's decisions are read at a time. */
2247
2325
  const MY_DECISIONS_PAGE = 50;
@@ -2307,14 +2385,17 @@ function useMyDecisions(
2307
2385
  */
2308
2386
  function MyDecisionsPage(props: { readonly data: ConsoleData }) {
2309
2387
  const words = useWords();
2310
- const { myDecisions, answerDecisionIn } = props.data.actions;
2388
+ const { myDecisions, answerReachedDecision } = props.data.actions;
2311
2389
  const addressed = useMyDecisions(myDecisions, false, words.myDecisions.couldNotLoad);
2312
2390
  const raised = useMyDecisions(myDecisions, true, words.myDecisions.couldNotLoad);
2313
2391
  const { busy, error, act } = useAction();
2314
2392
  const problem = addressed.problem ?? raised.problem ?? error;
2315
- const onAnswer = (projectKey: string, decision: RestDecision, input: RestAnswerInput) =>
2393
+ // Answered at the decision's own address (ADR-0068), so that one that reaches the person from a
2394
+ // project they are not in, a plugin's up the reporting line or one they were named in before
2395
+ // they left it, is answered here as it is read here.
2396
+ const onAnswer = (_projectKey: string, decision: RestDecision, input: RestAnswerInput) =>
2316
2397
  void act(async () => {
2317
- await answerDecisionIn(projectKey, decision.id, input);
2398
+ await answerReachedDecision(decision.id, input);
2318
2399
  addressed.reload();
2319
2400
  raised.reload();
2320
2401
  });
@@ -2336,8 +2417,11 @@ function MyDecisionsPage(props: { readonly data: ConsoleData }) {
2336
2417
  <MyDecisions
2337
2418
  addressed={{ items: addressed.items, more: addressed.more, onMore: addressed.readMore }}
2338
2419
  raised={{ items: raised.items, more: raised.more, onMore: raised.readMore }}
2339
- decisionHref={decisionHref}
2340
- fileHref={(projectKey, fileId) => projectPath(projectKey, "files", fileId)}
2420
+ decisionHref={(projectKey, decisionId) =>
2421
+ props.data.projects.some((project) => project.key === projectKey)
2422
+ ? decisionHref(projectKey, decisionId)
2423
+ : myDecisionHref(decisionId)
2424
+ }
2341
2425
  onAnswer={onAnswer}
2342
2426
  busy={busy || addressed.busy || raised.busy}
2343
2427
  />
@@ -2440,6 +2524,9 @@ function WorkspacePage(props: {
2440
2524
  if (route.name === "my-decisions") {
2441
2525
  return <MyDecisionsPage data={data} />;
2442
2526
  }
2527
+ if (route.name === "my-decision") {
2528
+ return <MyDecisionPage id={route.id} data={data} />;
2529
+ }
2443
2530
  if (route.name === "person-work") {
2444
2531
  return <PersonWorkPage id={route.id} data={data} components={components} week={route.week} />;
2445
2532
  }
@@ -2488,26 +2575,106 @@ function WorkspacePage(props: {
2488
2575
  );
2489
2576
  }
2490
2577
 
2491
- if (route.name === "people" || route.name === "new-listing") {
2578
+ if (
2579
+ route.name === "people" ||
2580
+ route.name === "new-listing" ||
2581
+ route.name === "teams" ||
2582
+ route.name === "new-team"
2583
+ ) {
2492
2584
  const me = data.me?.person ?? undefined;
2493
2585
  const administrator = me?.role === "admin";
2586
+ // The people and the teams (ADR-0073) are two views of one page, each with its form.
2587
+ const onTeams = route.name === "teams" || route.name === "new-team";
2494
2588
  const making = route.name === "new-listing" && administrator;
2589
+ const makingTeam = route.name === "new-team" && administrator;
2495
2590
  // An owner sees the work of the people in their projects; the console refuses the rest.
2496
2591
  const oversees = administrator || data.projects.some((project) => project.role === "owner");
2497
2592
  // The list is what the policy shows (ADR-0035): everyone, or the people one shares a project with.
2498
2593
  const narrowed = peopleVisibilityOf(data.me?.workspace) === "shared_projects" && !administrator;
2594
+ const views = words.people.views;
2595
+ const inCount = data.people.filter((person) => person.status === "active").length;
2499
2596
  return (
2500
2597
  <>
2501
2598
  <div className="sc-page-head">
2502
2599
  <h1 className="sc-page-title">{words.people.title}</h1>
2503
- {administrator && !making && (
2600
+ {administrator && !onTeams && !making && (
2504
2601
  <LinkButton href={PATHS.newListing} variant="primary">
2505
2602
  {words.people.letIn}
2506
2603
  </LinkButton>
2507
2604
  )}
2605
+ {administrator && onTeams && !makingTeam && (
2606
+ <LinkButton href={PATHS.newTeam} variant="primary">
2607
+ {words.teams.newTeam}
2608
+ </LinkButton>
2609
+ )}
2508
2610
  </div>
2611
+ <nav className="sc-subtabs sc-people-views" aria-label={views.aria}>
2612
+ <a
2613
+ className={cx("sc-tab", !onTeams && "sc-tab-current")}
2614
+ href={PATHS.people}
2615
+ aria-current={onTeams ? undefined : "page"}
2616
+ >
2617
+ {views.people}
2618
+ <span className="sc-tab-count">{inCount}</span>
2619
+ </a>
2620
+ <a
2621
+ className={cx("sc-tab", onTeams && "sc-tab-current")}
2622
+ href={PATHS.teams}
2623
+ aria-current={onTeams ? "page" : undefined}
2624
+ >
2625
+ {views.teams}
2626
+ <span className="sc-tab-count">{data.teams.length}</span>
2627
+ </a>
2628
+ </nav>
2509
2629
  {narrowed && <p className="sc-lead">{words.people.narrowed}</p>}
2510
2630
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
2631
+ {makingTeam && (
2632
+ <section className="sc-panel sc-panel-form" aria-label={words.teams.newAria}>
2633
+ <TeamForm
2634
+ busy={busy}
2635
+ onSubmit={(input) =>
2636
+ void act(async () => {
2637
+ await data.actions.createTeam(input);
2638
+ navigation.replace(PATHS.teams);
2639
+ })
2640
+ }
2641
+ onCancel={() => navigation.back(PATHS.teams)}
2642
+ />
2643
+ </section>
2644
+ )}
2645
+ {onTeams && (
2646
+ <components.TeamList
2647
+ teams={data.teams}
2648
+ people={data.people}
2649
+ busy={busy}
2650
+ onRename={
2651
+ administrator
2652
+ ? (team, name) => void act(() => data.actions.renameTeam(team.key, { name }))
2653
+ : undefined
2654
+ }
2655
+ onRemove={
2656
+ administrator
2657
+ ? (team) => {
2658
+ // The roles granted to the team go with it: asked first, as nothing brings them back.
2659
+ if (window.confirm(words.teams.removeAsk(team.name))) {
2660
+ void act(() => data.actions.removeTeam(team.key));
2661
+ }
2662
+ }
2663
+ : undefined
2664
+ }
2665
+ onAddMember={
2666
+ administrator
2667
+ ? (team, person) => void act(() => data.actions.addTeamMember(team.key, person.id))
2668
+ : undefined
2669
+ }
2670
+ onRemoveMember={
2671
+ administrator
2672
+ ? (team, person) =>
2673
+ void act(() => data.actions.removeTeamMember(team.key, person.id))
2674
+ : undefined
2675
+ }
2676
+ />
2677
+ )}
2511
2678
  {making && (
2512
2679
  <section className="sc-panel sc-panel-form" aria-label={words.people.letInAria}>
2513
2680
  <ListingForm
@@ -2523,36 +2690,56 @@ function WorkspacePage(props: {
2523
2690
  />
2524
2691
  </section>
2525
2692
  )}
2526
- <components.PeopleList
2527
- people={data.people}
2528
- listed={data.listed}
2529
- organization={data.organization}
2530
- me={me}
2531
- busy={busy}
2532
- onChangeRole={
2533
- administrator
2534
- ? (person, role) => void act(() => data.actions.updatePerson(person.id, { role }))
2535
- : undefined
2536
- }
2537
- onChangeStatus={
2538
- administrator
2539
- ? (person, status) => void act(() => data.actions.updatePerson(person.id, { status }))
2540
- : undefined
2541
- }
2542
- onChangeManager={
2543
- administrator
2544
- ? (person, managerId) =>
2545
- void act(() => data.actions.updatePerson(person.id, { managerId }))
2546
- : undefined
2547
- }
2548
- onUnlist={
2549
- administrator
2550
- ? (listing) => void act(() => data.actions.removeListing(listing.id))
2551
- : undefined
2552
- }
2553
- workHref={oversees ? (person) => personWorkHref(person.id) : undefined}
2554
- agentsHref={administrator ? (person) => personAgentsHref(person.id) : undefined}
2555
- />
2693
+ {!onTeams && (
2694
+ <components.PeopleList
2695
+ people={data.people}
2696
+ listed={data.listed}
2697
+ organization={data.organization}
2698
+ me={me}
2699
+ busy={busy}
2700
+ onChangeRole={
2701
+ administrator
2702
+ ? (person, role) => void act(() => data.actions.updatePerson(person.id, { role }))
2703
+ : undefined
2704
+ }
2705
+ onChangeStatus={
2706
+ administrator
2707
+ ? (person, status) =>
2708
+ void act(() => data.actions.updatePerson(person.id, { status }))
2709
+ : undefined
2710
+ }
2711
+ onChangeManager={
2712
+ administrator
2713
+ ? (person, managerId) =>
2714
+ void act(() => data.actions.updatePerson(person.id, { managerId }))
2715
+ : undefined
2716
+ }
2717
+ onChangeEmail={
2718
+ administrator
2719
+ ? (person, email) => void act(() => data.actions.updatePerson(person.id, { email }))
2720
+ : undefined
2721
+ }
2722
+ onUnlist={
2723
+ administrator
2724
+ ? (listing) => void act(() => data.actions.removeListing(listing.id))
2725
+ : undefined
2726
+ }
2727
+ teams={data.teams}
2728
+ onJoinTeam={
2729
+ administrator
2730
+ ? (person, team) => void act(() => data.actions.addTeamMember(team.key, person.id))
2731
+ : undefined
2732
+ }
2733
+ onLeaveTeam={
2734
+ administrator
2735
+ ? (person, team) =>
2736
+ void act(() => data.actions.removeTeamMember(team.key, person.id))
2737
+ : undefined
2738
+ }
2739
+ workHref={oversees ? (person) => personWorkHref(person.id) : undefined}
2740
+ agentsHref={administrator ? (person) => personAgentsHref(person.id) : undefined}
2741
+ />
2742
+ )}
2556
2743
  </>
2557
2744
  );
2558
2745
  }
@@ -2713,7 +2900,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2713
2900
  {
2714
2901
  href: PATHS.decisions,
2715
2902
  label: words.nav.myDecisions,
2716
- current: route.name === "my-decisions",
2903
+ current: route.name === "my-decisions" || route.name === "my-decision",
2717
2904
  count: data.me?.decisions?.waiting,
2718
2905
  },
2719
2906
  {
@@ -2722,6 +2909,8 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2722
2909
  current:
2723
2910
  route.name === "people" ||
2724
2911
  route.name === "new-listing" ||
2912
+ route.name === "teams" ||
2913
+ route.name === "new-team" ||
2725
2914
  route.name === "person-agents",
2726
2915
  },
2727
2916
  ...(viaToken