@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
@@ -3,6 +3,7 @@ import type { ConsoleClient } from "../client.js";
3
3
  import type { Messages } from "../i18n/en.js";
4
4
  import { useWords } from "../i18n/index.js";
5
5
  import { PLUGIN_CAPABILITIES, type PluginCapability } from "../plugin/capabilities.js";
6
+ import { settingsHref } from "../router.js";
6
7
  import type {
7
8
  RestEvent,
8
9
  RestPerson,
@@ -13,13 +14,17 @@ import type {
13
14
  RestPluginGrantInput,
14
15
  RestPluginSetting,
15
16
  RestProject,
17
+ RestTeam,
16
18
  } from "../schemas.js";
17
19
  import { useAction } from "../use-action.js";
18
20
  import { EventFeed } from "./event-feed.js";
21
+ import { Picker } from "./picker.js";
22
+ import { listSearch, peopleSearch, projectsSearch } from "./searches.js";
19
23
  import {
20
24
  Badge,
21
25
  Button,
22
26
  Callout,
27
+ Chip,
23
28
  cx,
24
29
  EmptyState,
25
30
  formatInstant,
@@ -134,6 +139,10 @@ export interface PluginSettingsViewProps {
134
139
  readonly people: readonly RestPerson[];
135
140
  readonly language: string;
136
141
  readonly client: ConsoleClient;
142
+ /** The plugins of the console, to grant a role of this one to another (ADR-0072); none when left out. */
143
+ readonly plugins?: readonly RestPlugin[] | undefined;
144
+ /** The teams of the workspace, to grant a role to one (ADR-0073); none when left out. */
145
+ readonly teams?: readonly RestTeam[] | undefined;
137
146
  /** Called after a change, so that the page reads the plugin again. */
138
147
  readonly onChanged: () => Promise<void>;
139
148
  /** Where the list is, for after a purge. */
@@ -215,6 +224,13 @@ function Overview(props: {
215
224
  } else if (enabled && detail.boardNeeds !== "none") {
216
225
  rows.push({ key: "board", name: settings.board, value: settings.nothing });
217
226
  }
227
+ if (detail.keeps !== undefined && detail.keeps.length > 0) {
228
+ rows.push({
229
+ key: "keeps",
230
+ name: settings.keeps,
231
+ value: detail.keeps.map((kept) => settings.keepsFor(kept.collection, kept.days)).join(", "),
232
+ });
233
+ }
218
234
  if (enabled && detail.projects !== null) {
219
235
  const names = detail.projects;
220
236
  rows.push({
@@ -401,6 +417,9 @@ function EnumListControl(props: {
401
417
  );
402
418
  }
403
419
 
420
+ /** One of a list setting's choices: a person or a project, by id, as it reads. */
421
+ type Candidate = { readonly id: string; readonly label: string };
422
+
404
423
  /**
405
424
  * A list of people or of projects: each chosen one a chip with the way to take it out, and one
406
425
  * choice to add another among those not chosen yet; the value is their ids.
@@ -410,12 +429,13 @@ function RefListControl(props: {
410
429
  readonly value: unknown;
411
430
  readonly onChange: (value: unknown) => void;
412
431
  readonly id: string;
432
+ readonly language: string;
413
433
  readonly people: readonly RestPerson[];
414
434
  readonly projects: readonly RestProject[];
415
435
  }) {
416
436
  const words = useWords();
417
437
  const chosen = itemsOf(props.value).map(String);
418
- const candidates: readonly { readonly id: string; readonly label: string }[] =
438
+ const candidates: readonly Candidate[] =
419
439
  props.setting.of === "person"
420
440
  ? props.people
421
441
  .filter((person) => person.status === "active" || chosen.includes(person.id))
@@ -430,38 +450,33 @@ function RefListControl(props: {
430
450
  return (
431
451
  <div className="sc-chips">
432
452
  {chosen.map((id) => (
433
- <span key={id} className="sc-chip">
453
+ <Chip
454
+ key={id}
455
+ onRemove={() => set(chosen.filter((kept) => kept !== id))}
456
+ removeLabel={`${words.common.remove}: ${labelOf(id)}`}
457
+ >
434
458
  {labelOf(id)}
435
- <button
436
- type="button"
437
- className="sc-chip-remove"
438
- aria-label={`${words.common.remove}: ${labelOf(id)}`}
439
- onClick={() => set(chosen.filter((kept) => kept !== id))}
440
- >
441
- <span aria-hidden="true">&times;</span>
442
- </button>
443
- </span>
459
+ </Chip>
444
460
  ))}
445
461
  {!full && (
446
- <select
462
+ <Picker<Candidate>
447
463
  id={props.id}
448
- className="sc-input sc-chip-add"
449
- value=""
450
- onChange={(event) => {
451
- if (event.target.value !== "") {
452
- set([...chosen, event.target.value]);
464
+ className="sc-picker-add"
465
+ value={undefined}
466
+ search={listSearch(
467
+ candidates.filter((candidate) => !chosen.includes(candidate.id)),
468
+ (candidate, typed) => candidate.label.toLowerCase().includes(typed),
469
+ )}
470
+ keyOf={(candidate) => candidate.id}
471
+ render={(candidate) => candidate.label}
472
+ label={pluginWord(props.setting.name, props.language)}
473
+ placeholder={words.plugins.settings.addOne}
474
+ onChange={(candidate) => {
475
+ if (candidate !== undefined) {
476
+ set([...chosen, candidate.id]);
453
477
  }
454
478
  }}
455
- >
456
- <option value="">{words.plugins.settings.choose}</option>
457
- {candidates
458
- .filter((candidate) => !chosen.includes(candidate.id))
459
- .map((candidate) => (
460
- <option key={candidate.id} value={candidate.id}>
461
- {candidate.label}
462
- </option>
463
- ))}
464
- </select>
479
+ />
465
480
  )}
466
481
  </div>
467
482
  );
@@ -498,18 +513,14 @@ function ItemListControl(props: {
498
513
  return (
499
514
  <div className="sc-chips">
500
515
  {items.map((item, index) => (
501
- // biome-ignore lint/suspicious/noArrayIndexKey: an item may be typed twice
502
- <span key={`${index}-${String(item)}`} className="sc-chip">
516
+ <Chip
517
+ // biome-ignore lint/suspicious/noArrayIndexKey: an item may be typed twice
518
+ key={`${index}-${String(item)}`}
519
+ onRemove={() => set(items.filter((_, at) => at !== index))}
520
+ removeLabel={`${words.common.remove}: ${String(item)}`}
521
+ >
503
522
  {String(item)}
504
- <button
505
- type="button"
506
- className="sc-chip-remove"
507
- aria-label={`${words.common.remove}: ${String(item)}`}
508
- onClick={() => set(items.filter((_, at) => at !== index))}
509
- >
510
- <span aria-hidden="true">&times;</span>
511
- </button>
512
- </span>
523
+ </Chip>
513
524
  ))}
514
525
  {!full && (
515
526
  <div className="sc-chip-input">
@@ -553,42 +564,43 @@ function SettingControl(props: {
553
564
  const words = useWords();
554
565
  const { setting, value, onChange, id } = props;
555
566
  const chosen = typeof value === "string" ? value : "";
556
- const choose = (event: { readonly target: { readonly value: string } }) =>
557
- onChange(event.target.value === "" ? null : event.target.value);
558
567
  switch (setting.type) {
559
568
  case "person": {
560
569
  // The people in; the one chosen stays listed when they have left, so that the value shows.
561
570
  const people = props.people.filter(
562
571
  (person) => person.status === "active" || person.id === chosen,
563
572
  );
573
+ const current = people.find((person) => person.id === chosen);
564
574
  return (
565
- <select id={id} className="sc-input" value={chosen} onChange={choose}>
566
- <option value="">—</option>
567
- {people.map((person) => (
568
- <option key={person.id} value={person.id}>
569
- {personChoice(person)}
570
- </option>
571
- ))}
572
- {chosen !== "" && !people.some((person) => person.id === chosen) && (
573
- <option value={chosen}>{chosen}</option>
574
- )}
575
- </select>
575
+ <Picker
576
+ id={id}
577
+ value={current}
578
+ held={chosen !== "" && current === undefined ? chosen : undefined}
579
+ search={peopleSearch(people)}
580
+ keyOf={(person) => person.id}
581
+ render={personChoice}
582
+ label={pluginWord(setting.name, props.language)}
583
+ placeholder={words.plugins.settings.choose}
584
+ onChange={(person) => onChange(person?.id ?? null)}
585
+ />
576
586
  );
577
587
  }
578
- case "project":
588
+ case "project": {
589
+ const current = props.projects.find((project) => project.id === chosen);
579
590
  return (
580
- <select id={id} className="sc-input" value={chosen} onChange={choose}>
581
- <option value="">—</option>
582
- {props.projects.map((project) => (
583
- <option key={project.id} value={project.id}>
584
- {project.name} ({project.key})
585
- </option>
586
- ))}
587
- {chosen !== "" && !props.projects.some((project) => project.id === chosen) && (
588
- <option value={chosen}>{chosen}</option>
589
- )}
590
- </select>
591
+ <Picker
592
+ id={id}
593
+ value={current}
594
+ held={chosen !== "" && current === undefined ? chosen : undefined}
595
+ search={projectsSearch(props.projects)}
596
+ keyOf={(project) => project.id}
597
+ render={(project) => `${project.name} (${project.key})`}
598
+ label={pluginWord(setting.name, props.language)}
599
+ placeholder={words.plugins.settings.choose}
600
+ onChange={(project) => onChange(project?.id ?? null)}
601
+ />
591
602
  );
603
+ }
592
604
  case "boolean":
593
605
  return (
594
606
  <input
@@ -665,6 +677,7 @@ function SettingControl(props: {
665
677
  if (setting.of === "person" || setting.of === "project") {
666
678
  return (
667
679
  <RefListControl
680
+ language={props.language}
668
681
  setting={setting}
669
682
  value={value}
670
683
  onChange={onChange}
@@ -771,6 +784,38 @@ function SettingsForm(props: {
771
784
  );
772
785
  }
773
786
 
787
+ /**
788
+ * How a secret stands, in a line (ADR-0070): set by an administrator, or sealed from an answer
789
+ * of a host, when; and when it expires, or that it has.
790
+ */
791
+ function secretStanding(
792
+ settings: Messages["plugins"]["settings"],
793
+ setting: RestPluginSetting,
794
+ language: string,
795
+ ): string {
796
+ const when = formatInstant(setting.setAt ?? "", language);
797
+ const source = setting.source ?? "administrator";
798
+ const set =
799
+ source === "administrator" ? settings.secretSet(when) : settings.secretSealedFrom(source, when);
800
+ if (setting.expiresAt === null || setting.expiresAt === undefined) {
801
+ return set;
802
+ }
803
+ const expires = formatInstant(setting.expiresAt, language);
804
+ return `${set} · ${
805
+ Date.parse(setting.expiresAt) <= Date.now()
806
+ ? settings.secretExpired(expires)
807
+ : settings.secretExpires(expires)
808
+ }`;
809
+ }
810
+
811
+ /** Whom a role may go to (ADR-0072): everyone, a project's members, a person, or a plugin. */
812
+ type GrantChoice =
813
+ | { readonly kind: "everyone" }
814
+ | { readonly kind: "project"; readonly project: RestProject }
815
+ | { readonly kind: "person"; readonly person: RestPerson }
816
+ | { readonly kind: "plugin"; readonly plugin: RestPlugin }
817
+ | { readonly kind: "team"; readonly team: RestTeam };
818
+
774
819
  /** One secret: whether and when it is set, a field to set it anew with its buttons on the line. */
775
820
  function SecretRow(props: {
776
821
  readonly setting: RestPluginSetting;
@@ -793,9 +838,12 @@ function SecretRow(props: {
793
838
  </label>
794
839
  <p className="sc-settings-hint">
795
840
  {setting.set && setting.setAt !== null
796
- ? settings.secretSet(formatInstant(setting.setAt, props.language))
841
+ ? secretStanding(settings, setting, props.language)
797
842
  : settings.secretUnset}
798
843
  </p>
844
+ {setting.hosts !== null && setting.hosts !== undefined && setting.hosts.length > 0 && (
845
+ <p className="sc-settings-hint">{settings.secretHosts(setting.hosts.join(", "))}</p>
846
+ )}
799
847
  {setting.description !== null && (
800
848
  <p className="sc-settings-hint">{pluginWord(setting.description, props.language)}</p>
801
849
  )}
@@ -841,12 +889,18 @@ function EnableForm(props: {
841
889
  readonly projects: readonly RestProject[];
842
890
  readonly language: string;
843
891
  readonly busy: boolean;
844
- readonly onEnable: (input: RestPluginEnableInput) => void;
892
+ readonly onEnable: (
893
+ input: RestPluginEnableInput,
894
+ grants: readonly { readonly plugin: string; readonly role: string }[],
895
+ ) => void;
845
896
  }) {
846
897
  const words = useWords();
847
898
  const settings = words.plugins.settings;
848
899
  const { detail, language } = props;
849
900
  const optional = detail.optionalCapabilities ?? [];
901
+ // The roles the plugin asks of other plugins (ADR-0072): granted on the way where ticked.
902
+ const asks = (detail.asks ?? []).filter((ask) => ask.state !== "granted");
903
+ const [askedNow, setAskedNow] = useState<ReadonlySet<string>>(() => new Set());
850
904
  const [consented, setConsented] = useState<ReadonlySet<string>>(
851
905
  () => new Set((detail.consent ?? []).filter((capability) => optional.includes(capability))),
852
906
  );
@@ -883,16 +937,21 @@ function EnableForm(props: {
883
937
  : chosen.length > 0;
884
938
  const submit = (event: FormEvent) => {
885
939
  event.preventDefault();
886
- props.onEnable({
887
- consent: [...detail.capabilities, ...consented],
888
- board:
889
- boardMode === "none"
890
- ? null
891
- : boardMode === "create"
892
- ? { create: { key: boardKey, name: boardName, description: boardDescription } }
893
- : { project: chosen },
894
- projects: allProjects ? "all" : [...onProjects],
895
- });
940
+ props.onEnable(
941
+ {
942
+ consent: [...detail.capabilities, ...consented],
943
+ board:
944
+ boardMode === "none"
945
+ ? null
946
+ : boardMode === "create"
947
+ ? { create: { key: boardKey, name: boardName, description: boardDescription } }
948
+ : { project: chosen },
949
+ projects: allProjects ? "all" : [...onProjects],
950
+ },
951
+ asks
952
+ .filter((ask) => ask.state === "ungranted" && askedNow.has(`${ask.plugin}:${ask.role}`))
953
+ .map((ask) => ({ plugin: ask.plugin, role: ask.role })),
954
+ );
896
955
  };
897
956
  return (
898
957
  <form className="sc-form sc-plugin-enable" onSubmit={submit} aria-label={settings.enable}>
@@ -1024,18 +1083,16 @@ function EnableForm(props: {
1024
1083
  <label className="sc-field-label" htmlFor={ids.choose}>
1025
1084
  {settings.boardChoose}
1026
1085
  </label>
1027
- <select
1086
+ <Picker
1028
1087
  id={ids.choose}
1029
- className="sc-input"
1030
- value={chosen}
1031
- onChange={(event) => setChosen(event.target.value)}
1032
- >
1033
- {props.projects.map((project) => (
1034
- <option key={project.key} value={project.key}>
1035
- {project.name} ({project.key})
1036
- </option>
1037
- ))}
1038
- </select>
1088
+ value={props.projects.find((project) => project.key === chosen)}
1089
+ search={projectsSearch(props.projects)}
1090
+ keyOf={(project) => project.key}
1091
+ render={(project) => `${project.name} (${project.key})`}
1092
+ label={settings.boardChoose}
1093
+ placeholder={settings.boardChoose}
1094
+ onChange={(project) => setChosen(project?.key ?? "")}
1095
+ />
1039
1096
  </div>
1040
1097
  </div>
1041
1098
  )}
@@ -1078,6 +1135,44 @@ function EnableForm(props: {
1078
1135
  )}
1079
1136
  </div>
1080
1137
  <div className="sc-plugin-group">
1138
+ {asks.length > 0 && (
1139
+ <div className="sc-plugin-group">
1140
+ <h3 className="sc-plugin-group-title">{settings.asks}</h3>
1141
+ <p className="sc-settings-hint">{settings.asksHint}</p>
1142
+ <ul className="sc-plugin-ticks">
1143
+ {asks.map((ask) => {
1144
+ const id = `${ask.plugin}:${ask.role}`;
1145
+ return (
1146
+ <TickRow
1147
+ key={id}
1148
+ checked={ask.state === "ungranted" && askedNow.has(id)}
1149
+ onChange={
1150
+ ask.state === "ungranted"
1151
+ ? (checked) =>
1152
+ setAskedNow((current) => {
1153
+ const next = new Set(current);
1154
+ if (checked) {
1155
+ next.add(id);
1156
+ } else {
1157
+ next.delete(id);
1158
+ }
1159
+ return next;
1160
+ })
1161
+ : undefined
1162
+ }
1163
+ locked={ask.state === "unavailable"}
1164
+ name={`${ask.role} (${ask.plugin})`}
1165
+ under={
1166
+ ask.state === "unavailable"
1167
+ ? settings.askUnavailable(ask.plugin)
1168
+ : pluginWord(ask.why, language)
1169
+ }
1170
+ />
1171
+ );
1172
+ })}
1173
+ </ul>
1174
+ </div>
1175
+ )}
1081
1176
  <div className="sc-form-actions">
1082
1177
  <Button type="submit" variant="primary" disabled={props.busy || !boardReady}>
1083
1178
  {settings.enable}
@@ -1096,6 +1191,12 @@ function holderOf(grant: RestPluginGrant, words: Messages["plugins"]["settings"]
1096
1191
  if (grant.project !== null) {
1097
1192
  return words.grantMembersOf(grant.project.name);
1098
1193
  }
1194
+ if (grant.plugin !== null && grant.plugin !== undefined) {
1195
+ return words.grantPlugin(grant.plugin);
1196
+ }
1197
+ if (grant.team !== null && grant.team !== undefined) {
1198
+ return words.grantTeam(grant.team.name);
1199
+ }
1099
1200
  if (grant.person !== null) {
1100
1201
  return personName(grant.person);
1101
1202
  }
@@ -1119,13 +1220,17 @@ function RoleRow(props: {
1119
1220
  readonly grants: readonly RestPluginGrant[];
1120
1221
  readonly projects: readonly RestProject[];
1121
1222
  readonly people: readonly RestPerson[];
1223
+ /** The other plugins that are enabled, to hold the role as themselves (ADR-0072). */
1224
+ readonly plugins: readonly RestPlugin[];
1225
+ /** The teams, whose members hold the role granted to one (ADR-0073). */
1226
+ readonly teams: readonly RestTeam[];
1122
1227
  readonly language: string;
1123
1228
  readonly busy: boolean;
1124
1229
  readonly onGrant: (input: RestPluginGrantInput) => void;
1125
1230
  readonly onRevoke: (id: string) => void;
1126
1231
  }) {
1127
1232
  const settings = useWords().plugins.settings;
1128
- const [whom, setWhom] = useState("");
1233
+ const [whom, setWhom] = useState<GrantChoice | undefined>(undefined);
1129
1234
  const id = useId();
1130
1235
  const { role, grants } = props;
1131
1236
  const everyoneHas = grants.some((grant) => grant.everyone);
@@ -1135,21 +1240,76 @@ function RoleRow(props: {
1135
1240
  const peopleWith = new Set(
1136
1241
  grants.flatMap((grant) => (grant.person === null ? [] : [grant.person.id])),
1137
1242
  );
1243
+ const pluginsWith = new Set(
1244
+ grants.flatMap((grant) =>
1245
+ grant.plugin === null || grant.plugin === undefined ? [] : [grant.plugin],
1246
+ ),
1247
+ );
1248
+ const plugins = props.plugins.filter((plugin) => !pluginsWith.has(plugin.key));
1249
+ const teamsWith = new Set(
1250
+ grants.flatMap((grant) =>
1251
+ grant.team === null || grant.team === undefined ? [] : [grant.team.key],
1252
+ ),
1253
+ );
1254
+ const teams = props.teams.filter((team) => !teamsWith.has(team.key));
1138
1255
  const projects = props.projects.filter((project) => !projectsWith.has(project.key));
1139
1256
  const people = props.people.filter(
1140
1257
  (person) => person.status === "active" && !peopleWith.has(person.id),
1141
1258
  );
1142
1259
  const description = pluginWord(role.description, props.language);
1260
+ // Whom the role may go to, searched for as one list (ADR-0037): everyone where it may, then
1261
+ // the projects, the people and the plugins without it.
1262
+ const choices: readonly GrantChoice[] = [
1263
+ ...(!props.isDefault && !everyoneHas ? [{ kind: "everyone" as const }] : []),
1264
+ ...projects.map((project) => ({ kind: "project" as const, project })),
1265
+ ...people.map((person) => ({ kind: "person" as const, person })),
1266
+ ...plugins.map((plugin) => ({ kind: "plugin" as const, plugin })),
1267
+ ...teams.map((team) => ({ kind: "team" as const, team })),
1268
+ ];
1269
+ const choiceWords = (choice: GrantChoice): string => {
1270
+ switch (choice.kind) {
1271
+ case "everyone":
1272
+ return settings.grantEveryone;
1273
+ case "project":
1274
+ return settings.grantMembersOf(choice.project.name);
1275
+ case "person":
1276
+ return personChoice(choice.person);
1277
+ case "team":
1278
+ return settings.grantTeam(choice.team.name);
1279
+ case "plugin":
1280
+ return `${settings.grantPlugin(choice.plugin.key)} · ${pluginWord(choice.plugin.name, props.language)}`;
1281
+ }
1282
+ };
1283
+ const findWhom = listSearch(choices, (choice, typed) =>
1284
+ choiceWords(choice).toLowerCase().includes(typed),
1285
+ );
1286
+ const keyOfChoice = (choice: GrantChoice): string =>
1287
+ choice.kind === "everyone"
1288
+ ? "everyone"
1289
+ : choice.kind === "project"
1290
+ ? `project:${choice.project.key}`
1291
+ : choice.kind === "person"
1292
+ ? `person:${choice.person.id}`
1293
+ : choice.kind === "team"
1294
+ ? `team:${choice.team.key}`
1295
+ : `plugin:${choice.plugin.key}`;
1143
1296
  const submit = (event: FormEvent) => {
1144
1297
  event.preventDefault();
1145
- if (whom === "everyone") {
1298
+ if (whom === undefined) {
1299
+ return;
1300
+ }
1301
+ if (whom.kind === "everyone") {
1146
1302
  props.onGrant({ role: role.key, everyone: true });
1147
- } else if (whom.startsWith("project:")) {
1148
- props.onGrant({ role: role.key, project: whom.slice("project:".length) });
1149
- } else if (whom.startsWith("person:")) {
1150
- props.onGrant({ role: role.key, person: whom.slice("person:".length) });
1303
+ } else if (whom.kind === "project") {
1304
+ props.onGrant({ role: role.key, project: whom.project.key });
1305
+ } else if (whom.kind === "person") {
1306
+ props.onGrant({ role: role.key, person: whom.person.id });
1307
+ } else if (whom.kind === "team") {
1308
+ props.onGrant({ role: role.key, team: whom.team.key });
1309
+ } else {
1310
+ props.onGrant({ role: role.key, plugin: whom.plugin.key });
1151
1311
  }
1152
- setWhom("");
1312
+ setWhom(undefined);
1153
1313
  };
1154
1314
  return (
1155
1315
  <li className="sc-plugin-role">
@@ -1158,30 +1318,28 @@ function RoleRow(props: {
1158
1318
  {pluginWord(role.name, props.language)}
1159
1319
  <code className="sc-plugin-role-key">{role.key}</code>
1160
1320
  {props.isDefault && <Badge tone="accent">{settings.roleDefault}</Badge>}
1321
+ {role.follows === "reports" && <Badge tone="accent">{settings.roleManagers}</Badge>}
1161
1322
  </p>
1162
1323
  {description !== "" && <p className="sc-settings-hint">{description}</p>}
1163
1324
  </div>
1164
1325
  <div>
1165
- <div className="sc-plugin-holders">
1166
- {props.isDefault && (
1167
- <span className="sc-plugin-holder sc-plugin-holder-fixed">{settings.everyMember}</span>
1326
+ <div className="sc-chips sc-plugin-holders">
1327
+ {props.isDefault && <Chip className="sc-plugin-holder">{settings.everyMember}</Chip>}
1328
+ {role.follows === "reports" && (
1329
+ <Chip className="sc-plugin-holder">{settings.whoeverManages}</Chip>
1168
1330
  )}
1169
1331
  {grants.map((grant) => {
1170
1332
  const holder = holderOf(grant, settings);
1171
1333
  return (
1172
- <span key={grant.id} className="sc-plugin-holder">
1334
+ <Chip
1335
+ key={grant.id}
1336
+ className="sc-plugin-holder"
1337
+ onRemove={() => props.onRevoke(grant.id)}
1338
+ removeLabel={`${settings.revoke}: ${holder}`}
1339
+ disabled={props.busy}
1340
+ >
1173
1341
  {holder}
1174
- <Button
1175
- size="sm"
1176
- variant="ghost"
1177
- disabled={props.busy}
1178
- onClick={() => props.onRevoke(grant.id)}
1179
- aria-label={`${settings.revoke}: ${holder}`}
1180
- title={settings.revoke}
1181
- >
1182
- ×
1183
- </Button>
1184
- </span>
1342
+ </Chip>
1185
1343
  );
1186
1344
  })}
1187
1345
  </div>
@@ -1189,36 +1347,18 @@ function RoleRow(props: {
1189
1347
  <label htmlFor={id} className="sc-visually-hidden">
1190
1348
  {settings.grantWhom}
1191
1349
  </label>
1192
- <select
1350
+ <Picker
1193
1351
  id={id}
1194
- className="sc-input sc-plugin-grant-input"
1195
1352
  value={whom}
1196
- onChange={(event) => setWhom(event.target.value)}
1197
- >
1198
- <option value="">{settings.grantWhom}</option>
1199
- {!props.isDefault && !everyoneHas && (
1200
- <option value="everyone">{settings.grantEveryone}</option>
1201
- )}
1202
- {projects.length > 0 && (
1203
- <optgroup label={settings.grantProjects}>
1204
- {projects.map((project) => (
1205
- <option key={project.key} value={`project:${project.key}`}>
1206
- {project.name}
1207
- </option>
1208
- ))}
1209
- </optgroup>
1210
- )}
1211
- {people.length > 0 && (
1212
- <optgroup label={settings.grantPeople}>
1213
- {people.map((person) => (
1214
- <option key={person.id} value={`person:${person.id}`}>
1215
- {personChoice(person)}
1216
- </option>
1217
- ))}
1218
- </optgroup>
1219
- )}
1220
- </select>
1221
- <Button type="submit" disabled={props.busy || whom === ""}>
1353
+ search={findWhom}
1354
+ keyOf={keyOfChoice}
1355
+ render={choiceWords}
1356
+ label={settings.grantWhom}
1357
+ placeholder={settings.grantWhom}
1358
+ disabled={props.busy}
1359
+ onChange={setWhom}
1360
+ />
1361
+ <Button type="submit" disabled={props.busy || whom === undefined}>
1222
1362
  {settings.grant}
1223
1363
  </Button>
1224
1364
  </form>
@@ -1227,19 +1367,33 @@ function RoleRow(props: {
1227
1367
  );
1228
1368
  }
1229
1369
 
1370
+ /** Another plugin by the name it gives itself in the person's language, or its key where the page does not know it. */
1371
+ function pluginNameOf(plugins: readonly RestPlugin[], key: string, language: string): string {
1372
+ const found = plugins.find((plugin) => plugin.key === key);
1373
+ return found === undefined ? key : pluginWord(found.name, language);
1374
+ }
1375
+
1230
1376
  /** The roles the plugin declares, each with who holds it; a plugin without roles is every member's. */
1231
1377
  function RolesSection(props: {
1232
1378
  readonly detail: RestPluginDetail;
1233
1379
  readonly projects: readonly RestProject[];
1234
1380
  readonly people: readonly RestPerson[];
1381
+ readonly plugins: readonly RestPlugin[];
1382
+ readonly teams: readonly RestTeam[];
1235
1383
  readonly language: string;
1236
1384
  readonly busy: boolean;
1237
1385
  readonly onGrant: (input: RestPluginGrantInput) => void;
1238
1386
  readonly onRevoke: (id: string) => void;
1387
+ /** Grants a role this plugin asks of another plugin (ADR-0072): the other plugin's key, and the role. */
1388
+ readonly onGrantAsked: (plugin: string, role: string) => void;
1239
1389
  }) {
1240
1390
  const settings = useWords().plugins.settings;
1241
1391
  const { detail } = props;
1242
1392
  const grants = detail.grants ?? [];
1393
+ const others = props.plugins.filter(
1394
+ (plugin) => plugin.state === "enabled" && plugin.key !== detail.key,
1395
+ );
1396
+ const asks = detail.asks ?? [];
1243
1397
  return (
1244
1398
  <section className="sc-panel" aria-label={settings.roles}>
1245
1399
  <h2 className="sc-section-title">{settings.roles}</h2>
@@ -1255,6 +1409,8 @@ function RolesSection(props: {
1255
1409
  grants={grants.filter((grant) => grant.role === role.key)}
1256
1410
  projects={props.projects}
1257
1411
  people={props.people}
1412
+ plugins={others}
1413
+ teams={props.teams}
1258
1414
  language={props.language}
1259
1415
  busy={props.busy}
1260
1416
  onGrant={props.onGrant}
@@ -1263,6 +1419,39 @@ function RolesSection(props: {
1263
1419
  ))}
1264
1420
  </ul>
1265
1421
  )}
1422
+ {asks.length > 0 && (
1423
+ <div className="sc-plugin-group">
1424
+ <h3 className="sc-plugin-group-title">{settings.asks}</h3>
1425
+ <p className="sc-settings-hint">{settings.asksHint}</p>
1426
+ <ul className="sc-plugin-roles">
1427
+ {asks.map((ask) => (
1428
+ <li key={`${ask.plugin}:${ask.role}`} className="sc-plugin-role">
1429
+ <div>
1430
+ <p className="sc-plugin-role-name">
1431
+ {pluginNameOf(props.plugins, ask.plugin, props.language)}
1432
+ <code className="sc-plugin-role-key">{ask.role}</code>
1433
+ {ask.state === "granted" && <Badge tone="accent">{settings.askGranted}</Badge>}
1434
+ </p>
1435
+ <p className="sc-settings-hint">{pluginWord(ask.why, props.language)}</p>
1436
+ </div>
1437
+ <div>
1438
+ {ask.state === "unavailable" ? (
1439
+ <span className="sc-settings-hint">{settings.askUnavailable(ask.plugin)}</span>
1440
+ ) : ask.state === "ungranted" ? (
1441
+ <Button
1442
+ size="sm"
1443
+ disabled={props.busy}
1444
+ onClick={() => props.onGrantAsked(ask.plugin, ask.role)}
1445
+ >
1446
+ {settings.askGrantNow}
1447
+ </Button>
1448
+ ) : null}
1449
+ </div>
1450
+ </li>
1451
+ ))}
1452
+ </ul>
1453
+ </div>
1454
+ )}
1266
1455
  </section>
1267
1456
  );
1268
1457
  }
@@ -1330,6 +1519,14 @@ export function PluginSettingsView(props: PluginSettingsViewProps) {
1330
1519
  </ul>
1331
1520
  </Callout>
1332
1521
  )}
1522
+ {detail.needsTimeZone === true && (
1523
+ <Callout
1524
+ tone="warning"
1525
+ action={<LinkButton href={settingsHref("workspace")}>{settings.setTimeZone}</LinkButton>}
1526
+ >
1527
+ {settings.needsTimeZone}
1528
+ </Callout>
1529
+ )}
1333
1530
  <Overview
1334
1531
  detail={detail}
1335
1532
  projects={props.projects}
@@ -1345,7 +1542,14 @@ export function PluginSettingsView(props: PluginSettingsViewProps) {
1345
1542
  projects={props.projects}
1346
1543
  language={language}
1347
1544
  busy={busy}
1348
- onEnable={(input) => change(() => plugin.enable(input))}
1545
+ onEnable={(input, grants) =>
1546
+ change(async () => {
1547
+ await plugin.enable(input);
1548
+ for (const grant of grants) {
1549
+ await client.plugin(grant.plugin).grant({ role: grant.role, plugin: detail.key });
1550
+ }
1551
+ })
1552
+ }
1349
1553
  />
1350
1554
  </section>
1351
1555
  )}
@@ -1379,6 +1583,11 @@ export function PluginSettingsView(props: PluginSettingsViewProps) {
1379
1583
  {detail.grants !== null && (
1380
1584
  <RolesSection
1381
1585
  detail={detail}
1586
+ plugins={props.plugins ?? []}
1587
+ teams={props.teams ?? []}
1588
+ onGrantAsked={(other, role) =>
1589
+ change(() => client.plugin(other).grant({ role, plugin: detail.key }))
1590
+ }
1382
1591
  projects={props.projects}
1383
1592
  people={props.people}
1384
1593
  language={language}