@skillcdn/console 0.4.3 → 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 (268) hide show
  1. package/README.md +6 -6
  2. package/dist/api.d.ts +4 -4
  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 -33
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +20 -5
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +98 -26
  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 +39 -15
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/plugin-check.d.ts +5 -1
  17. package/dist/cli/plugin-check.d.ts.map +1 -1
  18. package/dist/cli/plugin-check.js +23 -4
  19. package/dist/cli/plugin-check.js.map +1 -1
  20. package/dist/cli/plugin-cli.d.ts.map +1 -1
  21. package/dist/cli/plugin-cli.js +10 -1
  22. package/dist/cli/plugin-cli.js.map +1 -1
  23. package/dist/cli/plugin-init.d.ts +2 -0
  24. package/dist/cli/plugin-init.d.ts.map +1 -1
  25. package/dist/cli/plugin-init.js +6 -0
  26. package/dist/cli/plugin-init.js.map +1 -1
  27. package/dist/client.d.ts +32 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +14 -2
  30. package/dist/client.js.map +1 -1
  31. package/dist/compat.js +1 -1
  32. package/dist/components/board.d.ts +8 -0
  33. package/dist/components/board.d.ts.map +1 -1
  34. package/dist/components/board.js +49 -18
  35. package/dist/components/board.js.map +1 -1
  36. package/dist/components/decision-form.d.ts +6 -3
  37. package/dist/components/decision-form.d.ts.map +1 -1
  38. package/dist/components/decision-form.js +24 -18
  39. package/dist/components/decision-form.js.map +1 -1
  40. package/dist/components/decision-list.d.ts +5 -1
  41. package/dist/components/decision-list.d.ts.map +1 -1
  42. package/dist/components/decision-list.js +25 -5
  43. package/dist/components/decision-list.js.map +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +34 -8
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/goals.d.ts.map +1 -1
  48. package/dist/components/goals.js +2 -2
  49. package/dist/components/goals.js.map +1 -1
  50. package/dist/components/home.d.ts +35 -0
  51. package/dist/components/home.d.ts.map +1 -0
  52. package/dist/components/home.js +326 -0
  53. package/dist/components/home.js.map +1 -0
  54. package/dist/components/my-decisions.d.ts +20 -3
  55. package/dist/components/my-decisions.d.ts.map +1 -1
  56. package/dist/components/my-decisions.js +14 -1
  57. package/dist/components/my-decisions.js.map +1 -1
  58. package/dist/components/pack-connect.d.ts.map +1 -1
  59. package/dist/components/pack-connect.js +5 -4
  60. package/dist/components/pack-connect.js.map +1 -1
  61. package/dist/components/people.d.ts +45 -1
  62. package/dist/components/people.d.ts.map +1 -1
  63. package/dist/components/people.js +125 -4
  64. package/dist/components/people.js.map +1 -1
  65. package/dist/components/picker.d.ts +8 -0
  66. package/dist/components/picker.d.ts.map +1 -1
  67. package/dist/components/picker.js +2 -2
  68. package/dist/components/picker.js.map +1 -1
  69. package/dist/components/plugin-frame.d.ts +3 -13
  70. package/dist/components/plugin-frame.d.ts.map +1 -1
  71. package/dist/components/plugin-frame.js +75 -21
  72. package/dist/components/plugin-frame.js.map +1 -1
  73. package/dist/components/plugin-settings.d.ts +5 -1
  74. package/dist/components/plugin-settings.d.ts.map +1 -1
  75. package/dist/components/plugin-settings.js +283 -45
  76. package/dist/components/plugin-settings.js.map +1 -1
  77. package/dist/components/projects.d.ts +0 -8
  78. package/dist/components/projects.d.ts.map +1 -1
  79. package/dist/components/projects.js +4 -14
  80. package/dist/components/projects.js.map +1 -1
  81. package/dist/components/searches.d.ts +11 -0
  82. package/dist/components/searches.d.ts.map +1 -0
  83. package/dist/components/searches.js +30 -0
  84. package/dist/components/searches.js.map +1 -0
  85. package/dist/components/task-form.d.ts.map +1 -1
  86. package/dist/components/task-form.js +6 -5
  87. package/dist/components/task-form.js.map +1 -1
  88. package/dist/components/task-view.d.ts.map +1 -1
  89. package/dist/components/task-view.js +2 -1
  90. package/dist/components/task-view.js.map +1 -1
  91. package/dist/components/ui.d.ts +16 -0
  92. package/dist/components/ui.d.ts.map +1 -1
  93. package/dist/components/ui.js +15 -2
  94. package/dist/components/ui.js.map +1 -1
  95. package/dist/connect-page.d.ts.map +1 -1
  96. package/dist/connect-page.js +5 -6
  97. package/dist/connect-page.js.map +1 -1
  98. package/dist/console.css +1123 -258
  99. package/dist/console.d.ts +12 -4
  100. package/dist/console.d.ts.map +1 -1
  101. package/dist/console.js +123 -27
  102. package/dist/console.js.map +1 -1
  103. package/dist/data.d.ts +18 -3
  104. package/dist/data.d.ts.map +1 -1
  105. package/dist/data.js +41 -1
  106. package/dist/data.js.map +1 -1
  107. package/dist/docs-page.d.ts.map +1 -1
  108. package/dist/docs-page.js +6 -5
  109. package/dist/docs-page.js.map +1 -1
  110. package/dist/home-tiles.d.ts +92 -0
  111. package/dist/home-tiles.d.ts.map +1 -0
  112. package/dist/home-tiles.js +294 -0
  113. package/dist/home-tiles.js.map +1 -0
  114. package/dist/i18n/en.d.ts +264 -1
  115. package/dist/i18n/en.d.ts.map +1 -1
  116. package/dist/i18n/en.js +260 -1
  117. package/dist/i18n/en.js.map +1 -1
  118. package/dist/i18n/index.d.ts +4 -2
  119. package/dist/i18n/index.d.ts.map +1 -1
  120. package/dist/i18n/index.js +10 -11
  121. package/dist/i18n/index.js.map +1 -1
  122. package/dist/i18n/ko.d.ts.map +1 -1
  123. package/dist/i18n/ko.js +255 -4
  124. package/dist/i18n/ko.js.map +1 -1
  125. package/dist/index.d.ts +7 -5
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +7 -5
  128. package/dist/index.js.map +1 -1
  129. package/dist/limits.d.ts +12 -0
  130. package/dist/limits.d.ts.map +1 -1
  131. package/dist/limits.js +12 -0
  132. package/dist/limits.js.map +1 -1
  133. package/dist/plugin/args.d.ts +6 -1
  134. package/dist/plugin/args.d.ts.map +1 -1
  135. package/dist/plugin/args.js +16 -11
  136. package/dist/plugin/args.js.map +1 -1
  137. package/dist/plugin/bridge.d.ts +63 -3
  138. package/dist/plugin/bridge.d.ts.map +1 -1
  139. package/dist/plugin/bridge.js +54 -0
  140. package/dist/plugin/bridge.js.map +1 -1
  141. package/dist/plugin/capabilities.d.ts +7 -1
  142. package/dist/plugin/capabilities.d.ts.map +1 -1
  143. package/dist/plugin/capabilities.js +24 -1
  144. package/dist/plugin/capabilities.js.map +1 -1
  145. package/dist/plugin/context.d.ts +174 -12
  146. package/dist/plugin/context.d.ts.map +1 -1
  147. package/dist/plugin/define.d.ts +14 -1
  148. package/dist/plugin/define.d.ts.map +1 -1
  149. package/dist/plugin/define.js +18 -2
  150. package/dist/plugin/define.js.map +1 -1
  151. package/dist/plugin/limits.d.ts +9 -2
  152. package/dist/plugin/limits.d.ts.map +1 -1
  153. package/dist/plugin/limits.js +9 -2
  154. package/dist/plugin/limits.js.map +1 -1
  155. package/dist/plugin/manifest.d.ts +98 -6
  156. package/dist/plugin/manifest.d.ts.map +1 -1
  157. package/dist/plugin/manifest.js +171 -10
  158. package/dist/plugin/manifest.js.map +1 -1
  159. package/dist/plugin/rest-schemas.d.ts +120 -0
  160. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  161. package/dist/plugin/rest-schemas.js +78 -5
  162. package/dist/plugin/rest-schemas.js.map +1 -1
  163. package/dist/plugin/sandbox.d.ts +4 -3
  164. package/dist/plugin/sandbox.d.ts.map +1 -1
  165. package/dist/plugin/sandbox.js +33 -6
  166. package/dist/plugin/sandbox.js.map +1 -1
  167. package/dist/plugin/semver.d.ts +26 -0
  168. package/dist/plugin/semver.d.ts.map +1 -0
  169. package/dist/plugin/semver.js +76 -0
  170. package/dist/plugin/semver.js.map +1 -0
  171. package/dist/plugin/testing.d.ts +15 -1
  172. package/dist/plugin/testing.d.ts.map +1 -1
  173. package/dist/plugin/testing.js +42 -5
  174. package/dist/plugin/testing.js.map +1 -1
  175. package/dist/plugin/web.d.ts +16 -1
  176. package/dist/plugin/web.d.ts.map +1 -1
  177. package/dist/plugin/web.js +44 -1
  178. package/dist/plugin/web.js.map +1 -1
  179. package/dist/plugin.d.ts +3 -2
  180. package/dist/plugin.d.ts.map +1 -1
  181. package/dist/plugin.js +3 -2
  182. package/dist/plugin.js.map +1 -1
  183. package/dist/router.d.ts +19 -0
  184. package/dist/router.d.ts.map +1 -1
  185. package/dist/router.js +18 -0
  186. package/dist/router.js.map +1 -1
  187. package/dist/routes.d.ts +11 -2
  188. package/dist/routes.d.ts.map +1 -1
  189. package/dist/routes.js +14 -1
  190. package/dist/routes.js.map +1 -1
  191. package/dist/rules.d.ts +2 -2
  192. package/dist/rules.js +3 -3
  193. package/dist/schemas.d.ts +864 -2
  194. package/dist/schemas.d.ts.map +1 -1
  195. package/dist/schemas.js +156 -9
  196. package/dist/schemas.js.map +1 -1
  197. package/dist/use-action.d.ts +8 -2
  198. package/dist/use-action.d.ts.map +1 -1
  199. package/dist/use-action.js +13 -6
  200. package/dist/use-action.js.map +1 -1
  201. package/dist/vocabulary.d.ts +3 -1
  202. package/dist/vocabulary.d.ts.map +1 -1
  203. package/dist/vocabulary.js +9 -0
  204. package/dist/vocabulary.js.map +1 -1
  205. package/docs/specs/cli.md +10 -6
  206. package/docs/specs/plugins.md +83 -62
  207. package/docs/specs/rest.md +34 -16
  208. package/package.json +1 -1
  209. package/skills/building-a-plugin/SKILL.md +15 -12
  210. package/skills/planning-the-board/references/setting-up.md +2 -1
  211. package/skills/working-the-board/SKILL.md +3 -3
  212. package/src/api.ts +23 -0
  213. package/src/cli/cli.ts +323 -30
  214. package/src/cli/format.ts +122 -26
  215. package/src/cli/help.ts +43 -14
  216. package/src/cli/plugin-check.ts +41 -2
  217. package/src/cli/plugin-cli.ts +10 -1
  218. package/src/cli/plugin-init.ts +8 -0
  219. package/src/client.ts +78 -0
  220. package/src/compat.ts +1 -1
  221. package/src/components/board.tsx +86 -45
  222. package/src/components/decision-form.tsx +73 -36
  223. package/src/components/decision-list.tsx +45 -13
  224. package/src/components/event-feed.tsx +40 -7
  225. package/src/components/goals.tsx +4 -1
  226. package/src/components/home.tsx +615 -0
  227. package/src/components/my-decisions.tsx +50 -5
  228. package/src/components/pack-connect.tsx +5 -4
  229. package/src/components/people.tsx +500 -61
  230. package/src/components/picker.tsx +11 -2
  231. package/src/components/plugin-frame.tsx +85 -63
  232. package/src/components/plugin-settings.tsx +735 -185
  233. package/src/components/projects.tsx +14 -69
  234. package/src/components/searches.ts +47 -0
  235. package/src/components/task-form.tsx +23 -28
  236. package/src/components/task-view.tsx +7 -0
  237. package/src/components/ui.tsx +42 -2
  238. package/src/connect-page.tsx +5 -6
  239. package/src/console.tsx +303 -64
  240. package/src/data.ts +62 -3
  241. package/src/docs-page.tsx +6 -5
  242. package/src/home-tiles.ts +438 -0
  243. package/src/i18n/en.ts +274 -0
  244. package/src/i18n/index.ts +17 -14
  245. package/src/i18n/ko.ts +267 -4
  246. package/src/index.ts +18 -5
  247. package/src/limits.ts +12 -0
  248. package/src/plugin/args.ts +16 -11
  249. package/src/plugin/bridge.ts +122 -1
  250. package/src/plugin/capabilities.ts +28 -1
  251. package/src/plugin/context.ts +186 -9
  252. package/src/plugin/define.ts +38 -1
  253. package/src/plugin/limits.ts +9 -2
  254. package/src/plugin/manifest.ts +228 -13
  255. package/src/plugin/rest-schemas.ts +89 -6
  256. package/src/plugin/sandbox.ts +36 -8
  257. package/src/plugin/semver.ts +89 -0
  258. package/src/plugin/testing.ts +63 -6
  259. package/src/plugin/web.ts +66 -0
  260. package/src/plugin.ts +19 -0
  261. package/src/router.ts +25 -0
  262. package/src/routes.ts +17 -2
  263. package/src/rules.ts +3 -3
  264. package/src/schemas.ts +197 -10
  265. package/src/styles/console.css +1123 -258
  266. package/src/use-action.ts +16 -7
  267. package/src/vocabulary.ts +10 -0
  268. package/template/plugin/console-plugin.json +3 -1
package/src/console.tsx CHANGED
@@ -22,12 +22,14 @@ import {
22
22
  type RestDecisionInput,
23
23
  type RestDecisionPatch,
24
24
  type RestEvent,
25
+ type RestMe,
25
26
  type RestMyDecisions,
26
27
  type RestPluginDetail,
27
28
  type RestProject,
28
29
  type RestProjectInput,
29
30
  type RestProjectPatch,
30
31
  type RestProjectRules,
32
+ type RestReachedDecision,
31
33
  type RestRules,
32
34
  type RestTask,
33
35
  type RestTaskInput,
@@ -47,18 +49,24 @@ import { DecisionForm } from "./components/decision-form.js";
47
49
  import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
48
50
  import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
49
51
  import { GoalForm, GoalList, type GoalListProps, GoalView, goalMonth } from "./components/goals.js";
50
- import { MyDecisions } from "./components/my-decisions.js";
52
+ import { HomeGrid, type HomeGridProps } from "./components/home.js";
53
+ import { MyDecisions, ReachedDecision } from "./components/my-decisions.js";
51
54
  import { PackTokenWays } from "./components/pack-token.js";
52
55
  import { PAGE_SIZE } from "./components/paged.js";
53
- import { ListingForm, PeopleList, type PeopleListProps } from "./components/people.js";
54
- import { PluginFrame, PluginWidgets } from "./components/plugin-frame.js";
56
+ import {
57
+ ListingForm,
58
+ PeopleList,
59
+ type PeopleListProps,
60
+ TeamForm,
61
+ TeamList,
62
+ type TeamListProps,
63
+ } from "./components/people.js";
64
+ import { PluginFrame } from "./components/plugin-frame.js";
55
65
  import { PluginSettingsList, PluginSettingsView } from "./components/plugin-settings.js";
56
66
  import {
57
67
  MemberList,
58
68
  type MemberListProps,
59
69
  ProjectForm,
60
- ProjectList,
61
- type ProjectListProps,
62
70
  ProjectMark,
63
71
  } from "./components/projects.js";
64
72
  import { type RemotePictureSrc, RemotePicturesContext } from "./components/remote-pictures.js";
@@ -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,
@@ -100,6 +109,7 @@ import { type ConsoleData, useConsoleData } from "./data.js";
100
109
  import { monthOf, today } from "./dates.js";
101
110
  import { DocsPage } from "./docs-page.js";
102
111
  import {
112
+ browserPack,
103
113
  chooseLanguage,
104
114
  DEFAULT_LANGUAGES,
105
115
  LanguageContext,
@@ -118,6 +128,7 @@ import {
118
128
  type GoalTaskView,
119
129
  goalHref,
120
130
  matchRoute,
131
+ myDecisionHref,
121
132
  type Navigation,
122
133
  newDecisionHref,
123
134
  newGoalHref,
@@ -156,8 +167,9 @@ export interface ConsoleComponents {
156
167
  readonly SignIn: ComponentType<SignInProps>;
157
168
  readonly TokenList: ComponentType<TokenListProps>;
158
169
  readonly PeopleList: ComponentType<PeopleListProps>;
170
+ readonly TeamList: ComponentType<TeamListProps>;
159
171
  readonly SkillList: ComponentType<SkillListProps>;
160
- readonly ProjectList: ComponentType<ProjectListProps>;
172
+ readonly HomeGrid: ComponentType<HomeGridProps>;
161
173
  readonly MemberList: ComponentType<MemberListProps>;
162
174
  readonly Calendar: ComponentType<CalendarProps>;
163
175
  readonly GoalList: ComponentType<GoalListProps>;
@@ -174,8 +186,9 @@ export const DEFAULT_COMPONENTS: ConsoleComponents = {
174
186
  SignIn,
175
187
  TokenList,
176
188
  PeopleList,
189
+ TeamList,
177
190
  SkillList,
178
- ProjectList,
191
+ HomeGrid,
179
192
  MemberList,
180
193
  Calendar,
181
194
  GoalList,
@@ -384,6 +397,21 @@ function holdsToken(data: ConsoleData): boolean {
384
397
  return data.me !== undefined && data.me.agent !== null;
385
398
  }
386
399
 
400
+ /**
401
+ * The language to keep for a person the pages see for the first time (ADR-0065): the one their
402
+ * browser asks for, so that a plugin or an agent can write for them while they are away. Nothing
403
+ * for a person who has a language, for nobody, and where the pages only fall back on a language.
404
+ */
405
+ export function languageToKeep(
406
+ me: RestMe | undefined,
407
+ browser: readonly string[],
408
+ packs: readonly LanguagePack[],
409
+ ): string | undefined {
410
+ return me?.person !== null && me?.person !== undefined && me.language === null
411
+ ? browserPack(browser, packs)?.tag
412
+ : undefined;
413
+ }
414
+
387
415
  /**
388
416
  * The pages a console that holds a token does not offer: the tokens themselves, and the
389
417
  * approval of an agent's connection, which makes one; a person's own doing on the console's
@@ -413,21 +441,11 @@ function ProjectsPage(props: {
413
441
  readonly making: boolean;
414
442
  }) {
415
443
  const words = useWords().projects;
444
+ const homeWords = useWords().home;
416
445
  const { data, components, navigation, client, language } = props;
417
446
  const me = data.me;
418
- /** The plugins' widgets at a place of this page (ADR-0054), for the person's roles. */
419
- const widgets = (place: "home:top" | "home:bottom") =>
420
- me === undefined ? null : (
421
- <PluginWidgets
422
- plugins={data.plugins}
423
- place={place}
424
- me={me}
425
- language={language}
426
- client={client}
427
- navigation={navigation}
428
- nameOf={(name) => pluginWordOf(name, language)}
429
- />
430
- );
447
+ // The person arranges the page (ADR-0061): the projects and the widgets, tiles on one grid.
448
+ const [arranging, setArranging] = useState(false);
431
449
  const { busy, error, act } = useAction();
432
450
  const href = (project: RestProject) => projectHref(project.key);
433
451
  // A project is made by an administrator (ADR-0045): a member sees the list alone, the form's
@@ -438,14 +456,22 @@ function ProjectsPage(props: {
438
456
  <>
439
457
  <div className="sc-page-head">
440
458
  <h1 className="sc-page-title">{words.title}</h1>
441
- {administrator && !making && (
442
- <LinkButton href={PATHS.newProject} variant="primary">
443
- {words.newProject}
444
- </LinkButton>
459
+ {!arranging && (
460
+ <div className="sc-page-actions">
461
+ {me !== undefined && me.person !== null && (
462
+ <Button size="sm" variant="ghost" onClick={() => setArranging(true)}>
463
+ {homeWords.edit}
464
+ </Button>
465
+ )}
466
+ {administrator && !making && (
467
+ <LinkButton href={PATHS.newProject} variant="primary">
468
+ {words.newProject}
469
+ </LinkButton>
470
+ )}
471
+ </div>
445
472
  )}
446
473
  </div>
447
474
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
448
- {widgets("home:top")}
449
475
  {making && (
450
476
  <section className="sc-panel sc-panel-form" aria-label={words.form.aria}>
451
477
  <ProjectForm
@@ -465,10 +491,30 @@ function ProjectsPage(props: {
465
491
  />
466
492
  </section>
467
493
  )}
468
- <components.ProjectList
494
+ <components.HomeGrid
469
495
  projects={data.projects}
496
+ plugins={data.plugins}
497
+ home={me?.home}
498
+ editing={arranging}
499
+ onSave={(home) => data.actions.updateMe({ home })}
500
+ onDone={() => setArranging(false)}
470
501
  projectHref={href}
471
502
  onOpen={(project) => navigation.go(href(project))}
503
+ nameOf={(name) => pluginWordOf(name, language)}
504
+ renderWidget={(tile, size) =>
505
+ me === undefined ? null : (
506
+ <PluginFrame
507
+ plugin={tile.plugin}
508
+ widget={tile.widget.key}
509
+ size={size}
510
+ me={me}
511
+ language={language}
512
+ client={client}
513
+ navigation={navigation}
514
+ title={pluginWordOf(tile.widget.name, language)}
515
+ />
516
+ )
517
+ }
472
518
  empty={
473
519
  <EmptyState
474
520
  title={words.empty.title}
@@ -481,7 +527,6 @@ function ProjectsPage(props: {
481
527
  />
482
528
  }
483
529
  />
484
- {widgets("home:bottom")}
485
530
  </>
486
531
  );
487
532
  }
@@ -516,7 +561,7 @@ function PluginPage(props: {
516
561
  return (
517
562
  <>
518
563
  {pages.length > 1 && (
519
- <nav className="sc-subtabs" aria-label={pluginWordOf(plugin.name, language)}>
564
+ <nav className="sc-page-tabs" aria-label={pluginWordOf(plugin.name, language)}>
520
565
  {pages.map((candidate) => (
521
566
  <a
522
567
  key={candidate.key}
@@ -572,9 +617,9 @@ function PluginSettingsPage(props: {
572
617
  setDetail(await client.plugin(key).get());
573
618
  setFailure(undefined);
574
619
  } catch (error) {
575
- setFailure(error instanceof ApiError ? error.message : words.common.somethingWentWrong);
620
+ setFailure(errorWords(error, words.common.somethingWentWrong, words));
576
621
  }
577
- }, [client, key, words.common.somethingWentWrong]);
622
+ }, [client, key, words]);
578
623
  useEffect(() => {
579
624
  setDetail(undefined);
580
625
  void read();
@@ -611,6 +656,8 @@ function PluginSettingsPage(props: {
611
656
  detail={detail}
612
657
  projects={data.projects}
613
658
  people={data.people}
659
+ plugins={data.plugins}
660
+ teams={data.teams}
614
661
  language={language}
615
662
  client={client}
616
663
  onChanged={async () => {
@@ -2209,6 +2256,70 @@ function PlatePage(props: {
2209
2256
  );
2210
2257
  }
2211
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
+
2212
2323
  /** What is on the person's own plate, across their projects. */
2213
2324
  /** How many of a person's decisions are read at a time. */
2214
2325
  const MY_DECISIONS_PAGE = 50;
@@ -2274,14 +2385,17 @@ function useMyDecisions(
2274
2385
  */
2275
2386
  function MyDecisionsPage(props: { readonly data: ConsoleData }) {
2276
2387
  const words = useWords();
2277
- const { myDecisions, answerDecisionIn } = props.data.actions;
2388
+ const { myDecisions, answerReachedDecision } = props.data.actions;
2278
2389
  const addressed = useMyDecisions(myDecisions, false, words.myDecisions.couldNotLoad);
2279
2390
  const raised = useMyDecisions(myDecisions, true, words.myDecisions.couldNotLoad);
2280
2391
  const { busy, error, act } = useAction();
2281
2392
  const problem = addressed.problem ?? raised.problem ?? error;
2282
- 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) =>
2283
2397
  void act(async () => {
2284
- await answerDecisionIn(projectKey, decision.id, input);
2398
+ await answerReachedDecision(decision.id, input);
2285
2399
  addressed.reload();
2286
2400
  raised.reload();
2287
2401
  });
@@ -2303,8 +2417,11 @@ function MyDecisionsPage(props: { readonly data: ConsoleData }) {
2303
2417
  <MyDecisions
2304
2418
  addressed={{ items: addressed.items, more: addressed.more, onMore: addressed.readMore }}
2305
2419
  raised={{ items: raised.items, more: raised.more, onMore: raised.readMore }}
2306
- decisionHref={decisionHref}
2307
- 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
+ }
2308
2425
  onAnswer={onAnswer}
2309
2426
  busy={busy || addressed.busy || raised.busy}
2310
2427
  />
@@ -2407,6 +2524,9 @@ function WorkspacePage(props: {
2407
2524
  if (route.name === "my-decisions") {
2408
2525
  return <MyDecisionsPage data={data} />;
2409
2526
  }
2527
+ if (route.name === "my-decision") {
2528
+ return <MyDecisionPage id={route.id} data={data} />;
2529
+ }
2410
2530
  if (route.name === "person-work") {
2411
2531
  return <PersonWorkPage id={route.id} data={data} components={components} week={route.week} />;
2412
2532
  }
@@ -2455,26 +2575,106 @@ function WorkspacePage(props: {
2455
2575
  );
2456
2576
  }
2457
2577
 
2458
- 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
+ ) {
2459
2584
  const me = data.me?.person ?? undefined;
2460
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";
2461
2588
  const making = route.name === "new-listing" && administrator;
2589
+ const makingTeam = route.name === "new-team" && administrator;
2462
2590
  // An owner sees the work of the people in their projects; the console refuses the rest.
2463
2591
  const oversees = administrator || data.projects.some((project) => project.role === "owner");
2464
2592
  // The list is what the policy shows (ADR-0035): everyone, or the people one shares a project with.
2465
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;
2466
2596
  return (
2467
2597
  <>
2468
2598
  <div className="sc-page-head">
2469
2599
  <h1 className="sc-page-title">{words.people.title}</h1>
2470
- {administrator && !making && (
2600
+ {administrator && !onTeams && !making && (
2471
2601
  <LinkButton href={PATHS.newListing} variant="primary">
2472
2602
  {words.people.letIn}
2473
2603
  </LinkButton>
2474
2604
  )}
2605
+ {administrator && onTeams && !makingTeam && (
2606
+ <LinkButton href={PATHS.newTeam} variant="primary">
2607
+ {words.teams.newTeam}
2608
+ </LinkButton>
2609
+ )}
2475
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>
2476
2629
  {narrowed && <p className="sc-lead">{words.people.narrowed}</p>}
2477
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
+ )}
2478
2678
  {making && (
2479
2679
  <section className="sc-panel sc-panel-form" aria-label={words.people.letInAria}>
2480
2680
  <ListingForm
@@ -2490,30 +2690,56 @@ function WorkspacePage(props: {
2490
2690
  />
2491
2691
  </section>
2492
2692
  )}
2493
- <components.PeopleList
2494
- people={data.people}
2495
- listed={data.listed}
2496
- organization={data.organization}
2497
- me={me}
2498
- busy={busy}
2499
- onChangeRole={
2500
- administrator
2501
- ? (person, role) => void act(() => data.actions.updatePerson(person.id, { role }))
2502
- : undefined
2503
- }
2504
- onChangeStatus={
2505
- administrator
2506
- ? (person, status) => void act(() => data.actions.updatePerson(person.id, { status }))
2507
- : undefined
2508
- }
2509
- onUnlist={
2510
- administrator
2511
- ? (listing) => void act(() => data.actions.removeListing(listing.id))
2512
- : undefined
2513
- }
2514
- workHref={oversees ? (person) => personWorkHref(person.id) : undefined}
2515
- agentsHref={administrator ? (person) => personAgentsHref(person.id) : undefined}
2516
- />
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
+ )}
2517
2743
  </>
2518
2744
  );
2519
2745
  }
@@ -2592,7 +2818,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2592
2818
  const failure = signInFailureOf(location.search);
2593
2819
  const title = data.me?.workspace.name ?? fallbackTitle;
2594
2820
  const viaToken = holdsToken(data);
2595
- // The language: the person's choice, else the browser's, else the first the console speaks.
2821
+ // The language: the person's own, else the browser's, else the first the console speaks.
2596
2822
  const chosen = data.me?.language;
2597
2823
  const pack = useMemo(
2598
2824
  () =>
@@ -2605,6 +2831,17 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2605
2831
  );
2606
2832
  const words = pack.messages;
2607
2833
 
2834
+ const toKeep = languageToKeep(
2835
+ data.me,
2836
+ typeof navigator === "undefined" ? [] : navigator.languages,
2837
+ languages,
2838
+ );
2839
+ useEffect(() => {
2840
+ if (toKeep !== undefined) {
2841
+ void data.actions.updateMe({ language: toKeep }).catch(() => undefined);
2842
+ }
2843
+ }, [toKeep, data.actions]);
2844
+
2608
2845
  // An address that moved is shown at its new one, in its place.
2609
2846
  useEffect(() => {
2610
2847
  if (route.name === "moved") {
@@ -2632,7 +2869,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2632
2869
  title={words.common.couldNotReach}
2633
2870
  action={<Button onClick={data.reload}>{words.common.tryAgain}</Button>}
2634
2871
  >
2635
- {data.error}
2872
+ {errorWords(data.failure, data.error, words)}
2636
2873
  </Callout>
2637
2874
  )}
2638
2875
  </div>
@@ -2663,7 +2900,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2663
2900
  {
2664
2901
  href: PATHS.decisions,
2665
2902
  label: words.nav.myDecisions,
2666
- current: route.name === "my-decisions",
2903
+ current: route.name === "my-decisions" || route.name === "my-decision",
2667
2904
  count: data.me?.decisions?.waiting,
2668
2905
  },
2669
2906
  {
@@ -2672,6 +2909,8 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2672
2909
  current:
2673
2910
  route.name === "people" ||
2674
2911
  route.name === "new-listing" ||
2912
+ route.name === "teams" ||
2913
+ route.name === "new-team" ||
2675
2914
  route.name === "person-agents",
2676
2915
  },
2677
2916
  ...(viaToken