@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
@@ -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,18 @@ 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,
28
+ cx,
23
29
  EmptyState,
24
30
  formatInstant,
25
31
  LinkButton,
@@ -110,6 +116,9 @@ export function PluginSettingsList(props: PluginSettingsListProps) {
110
116
  {pluginWord(plugin.name, props.language)}{" "}
111
117
  <span className="sc-muted">
112
118
  {plugin.key} {plugin.version}
119
+ {plugin.console !== null && plugin.console !== undefined && (
120
+ <> · {settings.consoleRange(plugin.console)}</>
121
+ )}
113
122
  </span>
114
123
  </a>
115
124
  <span className="sc-task-row-meta">
@@ -130,6 +139,10 @@ export interface PluginSettingsViewProps {
130
139
  readonly people: readonly RestPerson[];
131
140
  readonly language: string;
132
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;
133
146
  /** Called after a change, so that the page reads the plugin again. */
134
147
  readonly onChanged: () => Promise<void>;
135
148
  /** Where the list is, for after a purge. */
@@ -150,20 +163,28 @@ function toggled(set: ReadonlySet<string>, value: string, on: boolean): Readonly
150
163
  /** A row of a list to tick: the box, a name, a word under it. */
151
164
  function TickRow(props: {
152
165
  readonly checked: boolean;
153
- readonly onChange: (checked: boolean) => void;
166
+ readonly onChange?: ((checked: boolean) => void) | undefined;
154
167
  readonly name: string;
155
168
  readonly under?: string | undefined;
169
+ /** A word beside the name: that this one is new, say. */
170
+ readonly tag?: string | undefined;
171
+ /** Whether it stays as it is: on, and not the person's to turn off. */
172
+ readonly locked?: boolean | undefined;
156
173
  }) {
157
174
  return (
158
175
  <li>
159
- <label className="sc-plugin-tick">
176
+ <label className={cx("sc-plugin-tick", props.locked === true && "sc-plugin-tick-locked")}>
160
177
  <input
161
178
  type="checkbox"
162
179
  checked={props.checked}
163
- onChange={(event) => props.onChange(event.target.checked)}
180
+ disabled={props.locked === true}
181
+ onChange={(event) => props.onChange?.(event.target.checked)}
164
182
  />
165
183
  <span className="sc-plugin-tick-words">
166
- <span className="sc-plugin-tick-name">{props.name}</span>
184
+ <span className="sc-plugin-tick-name">
185
+ {props.name}
186
+ {props.tag !== undefined && <Badge tone="warning">{props.tag}</Badge>}
187
+ </span>
167
188
  {props.under !== undefined && props.under !== "" && (
168
189
  <span className="sc-plugin-tick-under">{props.under}</span>
169
190
  )}
@@ -178,12 +199,18 @@ function Overview(props: {
178
199
  readonly detail: RestPluginDetail;
179
200
  readonly projects: readonly RestProject[];
180
201
  readonly language: string;
202
+ readonly busy: boolean;
203
+ /** Called with what the plugin holds from now on, an optional capability turned on or off. */
204
+ readonly onConsent: (consent: readonly string[]) => void;
181
205
  }) {
182
206
  const words = useWords();
183
207
  const settings = words.plugins.settings;
184
208
  const { detail, language } = props;
185
209
  const enabled = detail.state === "enabled";
186
210
  const rows: { readonly key: string; readonly name: string; readonly value: ReactNode }[] = [];
211
+ if (detail.console !== null && detail.console !== undefined) {
212
+ rows.push({ key: "console", name: settings.console, value: <code>{detail.console}</code> });
213
+ }
187
214
  if (detail.board !== null) {
188
215
  rows.push({
189
216
  key: "board",
@@ -197,6 +224,13 @@ function Overview(props: {
197
224
  } else if (enabled && detail.boardNeeds !== "none") {
198
225
  rows.push({ key: "board", name: settings.board, value: settings.nothing });
199
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
+ }
200
234
  if (enabled && detail.projects !== null) {
201
235
  const names = detail.projects;
202
236
  rows.push({
@@ -214,24 +248,66 @@ function Overview(props: {
214
248
  }
215
249
  if (enabled && detail.consent !== null) {
216
250
  const consent = detail.consent;
251
+ const optional = detail.optionalCapabilities ?? [];
252
+ const required = consent.filter((capability) => !optional.includes(capability));
217
253
  rows.push({
218
254
  key: "capabilities",
219
255
  name: settings.capabilities,
220
256
  value:
221
- consent.length === 0 ? (
257
+ consent.length === 0 && optional.length === 0 ? (
222
258
  settings.nothing
223
259
  ) : (
224
- <ul className="sc-plugin-held">
225
- {consent.map((capability) => {
226
- const text = capabilityText(words, capability);
227
- return (
228
- <li key={capability}>
229
- <span className="sc-plugin-tick-name">{text.name}</span>
230
- {text.opens !== "" && <span className="sc-plugin-tick-under"> {text.opens}</span>}
231
- </li>
232
- );
233
- })}
234
- </ul>
260
+ <>
261
+ {required.length > 0 && (
262
+ <ul className="sc-plugin-held">
263
+ {required.map((capability) => {
264
+ const text = capabilityText(words, capability);
265
+ return (
266
+ <li key={capability}>
267
+ <span className="sc-plugin-tick-name">{text.name}</span>
268
+ {text.opens !== "" && (
269
+ <span className="sc-plugin-tick-under"> {text.opens}</span>
270
+ )}
271
+ </li>
272
+ );
273
+ })}
274
+ </ul>
275
+ )}
276
+ {optional.length > 0 && (
277
+ <div className="sc-plugin-optional">
278
+ <span className="sc-field-label">{settings.optional}</span>
279
+ <ul className="sc-plugin-held">
280
+ {optional.map((capability) => {
281
+ const text = capabilityText(words, capability);
282
+ return (
283
+ <li key={capability}>
284
+ <label className="sc-check">
285
+ <input
286
+ type="checkbox"
287
+ checked={consent.includes(capability)}
288
+ disabled={props.busy}
289
+ onChange={(event) =>
290
+ props.onConsent(
291
+ event.target.checked
292
+ ? [...consent, capability]
293
+ : consent.filter((held) => held !== capability),
294
+ )
295
+ }
296
+ />
297
+ <span>
298
+ <span className="sc-plugin-tick-name">{text.name}</span>
299
+ {text.opens !== "" && (
300
+ <span className="sc-plugin-tick-under"> {text.opens}</span>
301
+ )}
302
+ </span>
303
+ </label>
304
+ </li>
305
+ );
306
+ })}
307
+ </ul>
308
+ </div>
309
+ )}
310
+ </>
235
311
  ),
236
312
  });
237
313
  }
@@ -272,9 +348,209 @@ function Overview(props: {
272
348
  );
273
349
  }
274
350
 
351
+ /** How many choices a list of an enum shows as pills before it becomes a column of boxes. */
352
+ const PILLS_AT_MOST = 8;
353
+
354
+ /** The control of one item of a list, as a text field: a number, a day, a time or words. */
355
+ function itemInputType(of: string | null): "number" | "date" | "time" | "text" {
356
+ switch (of) {
357
+ case "number":
358
+ return "number";
359
+ case "day":
360
+ return "date";
361
+ case "time":
362
+ return "time";
363
+ default:
364
+ return "text";
365
+ }
366
+ }
367
+
368
+ /** The items of a list setting as the form holds them. */
369
+ function itemsOf(value: unknown): readonly (string | number)[] {
370
+ return Array.isArray(value)
371
+ ? value.filter(
372
+ (item): item is string | number => typeof item === "string" || typeof item === "number",
373
+ )
374
+ : [];
375
+ }
376
+
377
+ /**
378
+ * A list of an enum: a pill per choice while they are few, lit while chosen, else a column of
379
+ * boxes; either way the value is the chosen values, in the options' order.
380
+ */
381
+ function EnumListControl(props: {
382
+ readonly setting: RestPluginSetting;
383
+ readonly value: unknown;
384
+ readonly onChange: (value: unknown) => void;
385
+ readonly language: string;
386
+ }) {
387
+ const options = props.setting.options ?? [];
388
+ const chosen = new Set(itemsOf(props.value).map(String));
389
+ const toggle = (option: string, on: boolean) => {
390
+ const next = options
391
+ .map((candidate) => candidate.value)
392
+ .filter((candidate) => (candidate === option ? on : chosen.has(candidate)));
393
+ props.onChange(next.length === 0 ? null : next);
394
+ };
395
+ const pills = options.length <= PILLS_AT_MOST;
396
+ const name = pluginWord(props.setting.name, props.language);
397
+ const choices = options.map((option) => (
398
+ <label key={option.value} className={pills ? "sc-toggle-chip" : "sc-check"}>
399
+ <input
400
+ type="checkbox"
401
+ checked={chosen.has(option.value)}
402
+ onChange={(event) => toggle(option.value, event.target.checked)}
403
+ />
404
+ {pluginWord(option.name, props.language)}
405
+ </label>
406
+ ));
407
+ return pills ? (
408
+ <fieldset className="sc-toggle-chips" aria-label={name}>
409
+ {choices}
410
+ </fieldset>
411
+ ) : (
412
+ <ul className="sc-check-list" aria-label={name}>
413
+ {choices.map((choice) => (
414
+ <li key={choice.key}>{choice}</li>
415
+ ))}
416
+ </ul>
417
+ );
418
+ }
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
+
423
+ /**
424
+ * A list of people or of projects: each chosen one a chip with the way to take it out, and one
425
+ * choice to add another among those not chosen yet; the value is their ids.
426
+ */
427
+ function RefListControl(props: {
428
+ readonly setting: RestPluginSetting;
429
+ readonly value: unknown;
430
+ readonly onChange: (value: unknown) => void;
431
+ readonly id: string;
432
+ readonly language: string;
433
+ readonly people: readonly RestPerson[];
434
+ readonly projects: readonly RestProject[];
435
+ }) {
436
+ const words = useWords();
437
+ const chosen = itemsOf(props.value).map(String);
438
+ const candidates: readonly Candidate[] =
439
+ props.setting.of === "person"
440
+ ? props.people
441
+ .filter((person) => person.status === "active" || chosen.includes(person.id))
442
+ .map((person) => ({ id: person.id, label: personChoice(person) }))
443
+ : props.projects.map((project) => ({
444
+ id: project.id,
445
+ label: `${project.name} (${project.key})`,
446
+ }));
447
+ const labelOf = (id: string) => candidates.find((candidate) => candidate.id === id)?.label ?? id;
448
+ const set = (next: readonly string[]) => props.onChange(next.length === 0 ? null : next);
449
+ const full = props.setting.max !== null && chosen.length >= props.setting.max;
450
+ return (
451
+ <div className="sc-chips">
452
+ {chosen.map((id) => (
453
+ <Chip
454
+ key={id}
455
+ onRemove={() => set(chosen.filter((kept) => kept !== id))}
456
+ removeLabel={`${words.common.remove}: ${labelOf(id)}`}
457
+ >
458
+ {labelOf(id)}
459
+ </Chip>
460
+ ))}
461
+ {!full && (
462
+ <Picker<Candidate>
463
+ id={props.id}
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]);
477
+ }
478
+ }}
479
+ />
480
+ )}
481
+ </div>
482
+ );
483
+ }
484
+
485
+ /**
486
+ * A list of texts, numbers, days or times: each item a chip with the way to take it out, and a
487
+ * field that adds one on Enter or with the button; `min` and `max` bound how many.
488
+ */
489
+ function ItemListControl(props: {
490
+ readonly setting: RestPluginSetting;
491
+ readonly value: unknown;
492
+ readonly onChange: (value: unknown) => void;
493
+ readonly id: string;
494
+ }) {
495
+ const words = useWords();
496
+ const [draft, setDraft] = useState("");
497
+ const items = itemsOf(props.value);
498
+ const set = (next: readonly (string | number)[]) =>
499
+ props.onChange(next.length === 0 ? null : next);
500
+ const full = props.setting.max !== null && items.length >= props.setting.max;
501
+ const add = () => {
502
+ const word = draft.trim();
503
+ if (word.length === 0 || full) {
504
+ return;
505
+ }
506
+ const item = props.setting.of === "number" ? Number(word) : word;
507
+ if (typeof item === "number" && !Number.isFinite(item)) {
508
+ return;
509
+ }
510
+ set([...items, item]);
511
+ setDraft("");
512
+ };
513
+ return (
514
+ <div className="sc-chips">
515
+ {items.map((item, index) => (
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
+ >
522
+ {String(item)}
523
+ </Chip>
524
+ ))}
525
+ {!full && (
526
+ <div className="sc-chip-input">
527
+ <input
528
+ id={props.id}
529
+ className="sc-input"
530
+ type={itemInputType(props.setting.of)}
531
+ value={draft}
532
+ placeholder={words.plugins.settings.itemPlaceholder}
533
+ onChange={(event) => setDraft(event.target.value)}
534
+ onKeyDown={(event) => {
535
+ if (event.key === "Enter") {
536
+ event.preventDefault();
537
+ add();
538
+ }
539
+ }}
540
+ />
541
+ <Button type="button" size="sm" disabled={draft.trim().length === 0} onClick={add}>
542
+ {words.plugins.settings.addItem}
543
+ </Button>
544
+ </div>
545
+ )}
546
+ </div>
547
+ );
548
+ }
549
+
275
550
  /**
276
551
  * A setting's control, by its type: a person or a project is chosen among the workspace's, by
277
- * name, and held as its id; a secret has a row of its own below.
552
+ * name, and held as its id; a time of day is read in the workspace's time zone; a list has a
553
+ * control per kind of item; a secret has a row of its own below.
278
554
  */
279
555
  function SettingControl(props: {
280
556
  readonly setting: RestPluginSetting;
@@ -285,44 +561,46 @@ function SettingControl(props: {
285
561
  readonly people: readonly RestPerson[];
286
562
  readonly projects: readonly RestProject[];
287
563
  }) {
564
+ const words = useWords();
288
565
  const { setting, value, onChange, id } = props;
289
566
  const chosen = typeof value === "string" ? value : "";
290
- const choose = (event: { readonly target: { readonly value: string } }) =>
291
- onChange(event.target.value === "" ? null : event.target.value);
292
567
  switch (setting.type) {
293
568
  case "person": {
294
569
  // The people in; the one chosen stays listed when they have left, so that the value shows.
295
570
  const people = props.people.filter(
296
571
  (person) => person.status === "active" || person.id === chosen,
297
572
  );
573
+ const current = people.find((person) => person.id === chosen);
298
574
  return (
299
- <select id={id} className="sc-input" value={chosen} onChange={choose}>
300
- <option value="">—</option>
301
- {people.map((person) => (
302
- <option key={person.id} value={person.id}>
303
- {personChoice(person)}
304
- </option>
305
- ))}
306
- {chosen !== "" && !people.some((person) => person.id === chosen) && (
307
- <option value={chosen}>{chosen}</option>
308
- )}
309
- </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
+ />
310
586
  );
311
587
  }
312
- case "project":
588
+ case "project": {
589
+ const current = props.projects.find((project) => project.id === chosen);
313
590
  return (
314
- <select id={id} className="sc-input" value={chosen} onChange={choose}>
315
- <option value="">—</option>
316
- {props.projects.map((project) => (
317
- <option key={project.id} value={project.id}>
318
- {project.name} ({project.key})
319
- </option>
320
- ))}
321
- {chosen !== "" && !props.projects.some((project) => project.id === chosen) && (
322
- <option value={chosen}>{chosen}</option>
323
- )}
324
- </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
+ />
325
602
  );
603
+ }
326
604
  case "boolean":
327
605
  return (
328
606
  <input
@@ -372,25 +650,44 @@ function SettingControl(props: {
372
650
  onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
373
651
  />
374
652
  );
375
- case "list":
653
+ case "time":
376
654
  return (
377
- <input
378
- id={id}
379
- className="sc-input"
380
- value={Array.isArray(value) ? value.join(", ") : ""}
381
- onChange={(event) =>
382
- onChange(
383
- event.target.value.trim() === ""
384
- ? null
385
- : event.target.value
386
- .split(",")
387
- .map((item) => item.trim())
388
- .filter((item) => item.length > 0)
389
- .map((item) => (setting.of === "number" ? Number(item) : item)),
390
- )
391
- }
392
- />
655
+ <>
656
+ <input
657
+ id={id}
658
+ className="sc-input"
659
+ type="time"
660
+ value={typeof value === "string" ? value : ""}
661
+ onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
662
+ />
663
+ <p className="sc-settings-hint">{words.plugins.settings.timeZoneHint}</p>
664
+ </>
393
665
  );
666
+ case "list":
667
+ if (setting.of === "enum") {
668
+ return (
669
+ <EnumListControl
670
+ setting={setting}
671
+ value={value}
672
+ onChange={onChange}
673
+ language={props.language}
674
+ />
675
+ );
676
+ }
677
+ if (setting.of === "person" || setting.of === "project") {
678
+ return (
679
+ <RefListControl
680
+ language={props.language}
681
+ setting={setting}
682
+ value={value}
683
+ onChange={onChange}
684
+ id={id}
685
+ people={props.people}
686
+ projects={props.projects}
687
+ />
688
+ );
689
+ }
690
+ return <ItemListControl setting={setting} value={value} onChange={onChange} id={id} />;
394
691
  default:
395
692
  return (
396
693
  <input
@@ -427,36 +724,57 @@ function SettingsForm(props: {
427
724
  event.preventDefault();
428
725
  props.onSave(values);
429
726
  };
727
+ /** A row per setting: the name and the hint on the left, the control on the right. */
728
+ const rows = (shown: readonly RestPluginSetting[]) =>
729
+ shown.map((setting) => (
730
+ <div key={setting.key} className="sc-settings-row">
731
+ <div className="sc-settings-words">
732
+ <label className="sc-settings-label" htmlFor={`${prefix}-${setting.key}`}>
733
+ {pluginWord(setting.name, props.language)}
734
+ {setting.required && " *"}
735
+ </label>
736
+ {setting.description !== null && (
737
+ <p className="sc-settings-hint">{pluginWord(setting.description, props.language)}</p>
738
+ )}
739
+ </div>
740
+ <div className="sc-settings-control">
741
+ <SettingControl
742
+ setting={setting}
743
+ value={values[setting.key]}
744
+ id={`${prefix}-${setting.key}`}
745
+ language={props.language}
746
+ people={props.people}
747
+ projects={props.projects}
748
+ onChange={(value) => setValues((current) => ({ ...current, [setting.key]: value }))}
749
+ />
750
+ </div>
751
+ </div>
752
+ ));
430
753
  return (
431
754
  <form
432
755
  className="sc-form sc-settings-form"
433
756
  onSubmit={submit}
434
757
  aria-label={words.plugins.settings.settingsForm}
435
758
  >
436
- {settings.map((setting) => (
437
- <div key={setting.key} className="sc-settings-row">
438
- <div className="sc-settings-words">
439
- <label className="sc-settings-label" htmlFor={`${prefix}-${setting.key}`}>
440
- {pluginWord(setting.name, props.language)}
441
- {setting.required && " *"}
442
- </label>
443
- {setting.description !== null && (
444
- <p className="sc-settings-hint">{pluginWord(setting.description, props.language)}</p>
445
- )}
446
- </div>
447
- <div className="sc-settings-control">
448
- <SettingControl
449
- setting={setting}
450
- value={values[setting.key]}
451
- id={`${prefix}-${setting.key}`}
452
- language={props.language}
453
- people={props.people}
454
- projects={props.projects}
455
- onChange={(value) => setValues((current) => ({ ...current, [setting.key]: value }))}
456
- />
457
- </div>
458
- </div>
459
- ))}
759
+ {rows(settings.filter((setting) => setting.group === null || setting.group === undefined))}
760
+ {(props.detail.settingGroups ?? []).map((group) => {
761
+ const grouped = settings.filter((setting) => setting.group === group.key);
762
+ if (grouped.length === 0) {
763
+ return null;
764
+ }
765
+ const description = pluginWord(group.description, props.language);
766
+ return (
767
+ <section
768
+ key={group.key}
769
+ className="sc-settings-group"
770
+ aria-label={pluginWord(group.name, props.language)}
771
+ >
772
+ <h3 className="sc-settings-group-title">{pluginWord(group.name, props.language)}</h3>
773
+ {description !== "" && <p className="sc-settings-hint">{description}</p>}
774
+ {rows(grouped)}
775
+ </section>
776
+ );
777
+ })}
460
778
  <div className="sc-form-actions">
461
779
  <Button type="submit" variant="primary" disabled={props.busy}>
462
780
  {words.common.save}
@@ -466,6 +784,38 @@ function SettingsForm(props: {
466
784
  );
467
785
  }
468
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
+
469
819
  /** One secret: whether and when it is set, a field to set it anew with its buttons on the line. */
470
820
  function SecretRow(props: {
471
821
  readonly setting: RestPluginSetting;
@@ -488,9 +838,12 @@ function SecretRow(props: {
488
838
  </label>
489
839
  <p className="sc-settings-hint">
490
840
  {setting.set && setting.setAt !== null
491
- ? settings.secretSet(formatInstant(setting.setAt, props.language))
841
+ ? secretStanding(settings, setting, props.language)
492
842
  : settings.secretUnset}
493
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
+ )}
494
847
  {setting.description !== null && (
495
848
  <p className="sc-settings-hint">{pluginWord(setting.description, props.language)}</p>
496
849
  )}
@@ -527,25 +880,35 @@ function SecretRow(props: {
527
880
  }
528
881
 
529
882
  /**
530
- * Enabling: the capabilities to consent to, one by one, those consented to before ticked
531
- * already; the board project, the one it had chosen already; the projects, as they were.
883
+ * Enabling: the capabilities the plugin requires, on and not the administrator's to turn off, and
884
+ * those it can go without, off until chosen, those consented to before on already (ADR-0060); the
885
+ * board project, the one it had chosen already; the projects, as they were.
532
886
  */
533
887
  function EnableForm(props: {
534
888
  readonly detail: RestPluginDetail;
535
889
  readonly projects: readonly RestProject[];
536
890
  readonly language: string;
537
891
  readonly busy: boolean;
538
- readonly onEnable: (input: RestPluginEnableInput) => void;
892
+ readonly onEnable: (
893
+ input: RestPluginEnableInput,
894
+ grants: readonly { readonly plugin: string; readonly role: string }[],
895
+ ) => void;
539
896
  }) {
540
897
  const words = useWords();
541
898
  const settings = words.plugins.settings;
542
899
  const { detail, language } = props;
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());
543
904
  const [consented, setConsented] = useState<ReadonlySet<string>>(
544
- () =>
545
- new Set(
546
- (detail.consent ?? []).filter((capability) => detail.capabilities.includes(capability)),
547
- ),
905
+ () => new Set((detail.consent ?? []).filter((capability) => optional.includes(capability))),
548
906
  );
907
+ /** A capability a new version asks and nobody has consented to yet is said to be new. */
908
+ const tagOf = (capability: string): string | undefined =>
909
+ detail.state === "needs_consent" && !(detail.consent ?? []).includes(capability)
910
+ ? settings.newCapability
911
+ : undefined;
549
912
  const needsBoard = detail.boardNeeds !== "none";
550
913
  const [boardMode, setBoardMode] = useState<"none" | "create" | "choose">(
551
914
  detail.boardNeeds === "none"
@@ -566,7 +929,6 @@ function EnableForm(props: {
566
929
  () => new Set(Array.isArray(before) ? before : []),
567
930
  );
568
931
  const ids = { key: useId(), name: useId(), description: useId(), choose: useId() };
569
- const everyCapability = detail.capabilities.every((capability) => consented.has(capability));
570
932
  const boardReady =
571
933
  boardMode === "none"
572
934
  ? detail.boardNeeds !== "required"
@@ -575,36 +937,72 @@ function EnableForm(props: {
575
937
  : chosen.length > 0;
576
938
  const submit = (event: FormEvent) => {
577
939
  event.preventDefault();
578
- props.onEnable({
579
- consent: [...consented],
580
- board:
581
- boardMode === "none"
582
- ? null
583
- : boardMode === "create"
584
- ? { create: { key: boardKey, name: boardName, description: boardDescription } }
585
- : { project: chosen },
586
- projects: allProjects ? "all" : [...onProjects],
587
- });
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
+ );
588
955
  };
589
956
  return (
590
957
  <form className="sc-form sc-plugin-enable" onSubmit={submit} aria-label={settings.enable}>
591
- {detail.capabilities.length > 0 && (
958
+ {detail.capabilities.length + optional.length > 0 && (
592
959
  <div className="sc-plugin-group">
593
960
  <h3 className="sc-plugin-group-title">{settings.capabilities}</h3>
594
- <ul className="sc-plugin-ticks">
595
- {detail.capabilities.map((capability) => {
596
- const text = capabilityText(words, capability);
597
- return (
598
- <TickRow
599
- key={capability}
600
- checked={consented.has(capability)}
601
- onChange={(on) => setConsented((current) => toggled(current, capability, on))}
602
- name={text.name}
603
- under={text.opens}
604
- />
605
- );
606
- })}
607
- </ul>
961
+ {detail.capabilities.length > 0 && (
962
+ <div className="sc-plugin-capabilities">
963
+ <p className="sc-plugin-capabilities-head">
964
+ <span className="sc-plugin-capabilities-name">{settings.required}</span>
965
+ <span className="sc-settings-hint">{settings.requiredHint}</span>
966
+ </p>
967
+ <ul className="sc-plugin-ticks">
968
+ {detail.capabilities.map((capability) => {
969
+ const text = capabilityText(words, capability);
970
+ return (
971
+ <TickRow
972
+ key={capability}
973
+ checked
974
+ locked
975
+ name={text.name}
976
+ under={text.opens}
977
+ tag={tagOf(capability)}
978
+ />
979
+ );
980
+ })}
981
+ </ul>
982
+ </div>
983
+ )}
984
+ {optional.length > 0 && (
985
+ <div className="sc-plugin-capabilities">
986
+ <p className="sc-plugin-capabilities-head">
987
+ <span className="sc-plugin-capabilities-name">{settings.optional}</span>
988
+ <span className="sc-settings-hint">{settings.optionalHint}</span>
989
+ </p>
990
+ <ul className="sc-plugin-ticks">
991
+ {optional.map((capability) => {
992
+ const text = capabilityText(words, capability);
993
+ return (
994
+ <TickRow
995
+ key={capability}
996
+ checked={consented.has(capability)}
997
+ onChange={(on) => setConsented((current) => toggled(current, capability, on))}
998
+ name={text.name}
999
+ under={text.opens}
1000
+ />
1001
+ );
1002
+ })}
1003
+ </ul>
1004
+ </div>
1005
+ )}
608
1006
  </div>
609
1007
  )}
610
1008
  {needsBoard && (
@@ -685,18 +1083,16 @@ function EnableForm(props: {
685
1083
  <label className="sc-field-label" htmlFor={ids.choose}>
686
1084
  {settings.boardChoose}
687
1085
  </label>
688
- <select
1086
+ <Picker
689
1087
  id={ids.choose}
690
- className="sc-input"
691
- value={chosen}
692
- onChange={(event) => setChosen(event.target.value)}
693
- >
694
- {props.projects.map((project) => (
695
- <option key={project.key} value={project.key}>
696
- {project.name} ({project.key})
697
- </option>
698
- ))}
699
- </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
+ />
700
1096
  </div>
701
1097
  </div>
702
1098
  )}
@@ -739,12 +1135,46 @@ function EnableForm(props: {
739
1135
  )}
740
1136
  </div>
741
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
+ )}
742
1176
  <div className="sc-form-actions">
743
- <Button
744
- type="submit"
745
- variant="primary"
746
- disabled={props.busy || !everyCapability || !boardReady}
747
- >
1177
+ <Button type="submit" variant="primary" disabled={props.busy || !boardReady}>
748
1178
  {settings.enable}
749
1179
  </Button>
750
1180
  </div>
@@ -761,6 +1191,12 @@ function holderOf(grant: RestPluginGrant, words: Messages["plugins"]["settings"]
761
1191
  if (grant.project !== null) {
762
1192
  return words.grantMembersOf(grant.project.name);
763
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
+ }
764
1200
  if (grant.person !== null) {
765
1201
  return personName(grant.person);
766
1202
  }
@@ -784,13 +1220,17 @@ function RoleRow(props: {
784
1220
  readonly grants: readonly RestPluginGrant[];
785
1221
  readonly projects: readonly RestProject[];
786
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[];
787
1227
  readonly language: string;
788
1228
  readonly busy: boolean;
789
1229
  readonly onGrant: (input: RestPluginGrantInput) => void;
790
1230
  readonly onRevoke: (id: string) => void;
791
1231
  }) {
792
1232
  const settings = useWords().plugins.settings;
793
- const [whom, setWhom] = useState("");
1233
+ const [whom, setWhom] = useState<GrantChoice | undefined>(undefined);
794
1234
  const id = useId();
795
1235
  const { role, grants } = props;
796
1236
  const everyoneHas = grants.some((grant) => grant.everyone);
@@ -800,21 +1240,76 @@ function RoleRow(props: {
800
1240
  const peopleWith = new Set(
801
1241
  grants.flatMap((grant) => (grant.person === null ? [] : [grant.person.id])),
802
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));
803
1255
  const projects = props.projects.filter((project) => !projectsWith.has(project.key));
804
1256
  const people = props.people.filter(
805
1257
  (person) => person.status === "active" && !peopleWith.has(person.id),
806
1258
  );
807
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}`;
808
1296
  const submit = (event: FormEvent) => {
809
1297
  event.preventDefault();
810
- if (whom === "everyone") {
1298
+ if (whom === undefined) {
1299
+ return;
1300
+ }
1301
+ if (whom.kind === "everyone") {
811
1302
  props.onGrant({ role: role.key, everyone: true });
812
- } else if (whom.startsWith("project:")) {
813
- props.onGrant({ role: role.key, project: whom.slice("project:".length) });
814
- } else if (whom.startsWith("person:")) {
815
- 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 });
816
1311
  }
817
- setWhom("");
1312
+ setWhom(undefined);
818
1313
  };
819
1314
  return (
820
1315
  <li className="sc-plugin-role">
@@ -823,30 +1318,28 @@ function RoleRow(props: {
823
1318
  {pluginWord(role.name, props.language)}
824
1319
  <code className="sc-plugin-role-key">{role.key}</code>
825
1320
  {props.isDefault && <Badge tone="accent">{settings.roleDefault}</Badge>}
1321
+ {role.follows === "reports" && <Badge tone="accent">{settings.roleManagers}</Badge>}
826
1322
  </p>
827
1323
  {description !== "" && <p className="sc-settings-hint">{description}</p>}
828
1324
  </div>
829
1325
  <div>
830
- <div className="sc-plugin-holders">
831
- {props.isDefault && (
832
- <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>
833
1330
  )}
834
1331
  {grants.map((grant) => {
835
1332
  const holder = holderOf(grant, settings);
836
1333
  return (
837
- <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
+ >
838
1341
  {holder}
839
- <Button
840
- size="sm"
841
- variant="ghost"
842
- disabled={props.busy}
843
- onClick={() => props.onRevoke(grant.id)}
844
- aria-label={`${settings.revoke}: ${holder}`}
845
- title={settings.revoke}
846
- >
847
- ×
848
- </Button>
849
- </span>
1342
+ </Chip>
850
1343
  );
851
1344
  })}
852
1345
  </div>
@@ -854,36 +1347,18 @@ function RoleRow(props: {
854
1347
  <label htmlFor={id} className="sc-visually-hidden">
855
1348
  {settings.grantWhom}
856
1349
  </label>
857
- <select
1350
+ <Picker
858
1351
  id={id}
859
- className="sc-input sc-plugin-grant-input"
860
1352
  value={whom}
861
- onChange={(event) => setWhom(event.target.value)}
862
- >
863
- <option value="">{settings.grantWhom}</option>
864
- {!props.isDefault && !everyoneHas && (
865
- <option value="everyone">{settings.grantEveryone}</option>
866
- )}
867
- {projects.length > 0 && (
868
- <optgroup label={settings.grantProjects}>
869
- {projects.map((project) => (
870
- <option key={project.key} value={`project:${project.key}`}>
871
- {project.name}
872
- </option>
873
- ))}
874
- </optgroup>
875
- )}
876
- {people.length > 0 && (
877
- <optgroup label={settings.grantPeople}>
878
- {people.map((person) => (
879
- <option key={person.id} value={`person:${person.id}`}>
880
- {personChoice(person)}
881
- </option>
882
- ))}
883
- </optgroup>
884
- )}
885
- </select>
886
- <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}>
887
1362
  {settings.grant}
888
1363
  </Button>
889
1364
  </form>
@@ -892,19 +1367,33 @@ function RoleRow(props: {
892
1367
  );
893
1368
  }
894
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
+
895
1376
  /** The roles the plugin declares, each with who holds it; a plugin without roles is every member's. */
896
1377
  function RolesSection(props: {
897
1378
  readonly detail: RestPluginDetail;
898
1379
  readonly projects: readonly RestProject[];
899
1380
  readonly people: readonly RestPerson[];
1381
+ readonly plugins: readonly RestPlugin[];
1382
+ readonly teams: readonly RestTeam[];
900
1383
  readonly language: string;
901
1384
  readonly busy: boolean;
902
1385
  readonly onGrant: (input: RestPluginGrantInput) => void;
903
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;
904
1389
  }) {
905
1390
  const settings = useWords().plugins.settings;
906
1391
  const { detail } = props;
907
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 ?? [];
908
1397
  return (
909
1398
  <section className="sc-panel" aria-label={settings.roles}>
910
1399
  <h2 className="sc-section-title">{settings.roles}</h2>
@@ -920,6 +1409,8 @@ function RolesSection(props: {
920
1409
  grants={grants.filter((grant) => grant.role === role.key)}
921
1410
  projects={props.projects}
922
1411
  people={props.people}
1412
+ plugins={others}
1413
+ teams={props.teams}
923
1414
  language={props.language}
924
1415
  busy={props.busy}
925
1416
  onGrant={props.onGrant}
@@ -928,6 +1419,39 @@ function RolesSection(props: {
928
1419
  ))}
929
1420
  </ul>
930
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
+ )}
931
1455
  </section>
932
1456
  );
933
1457
  }
@@ -995,7 +1519,21 @@ export function PluginSettingsView(props: PluginSettingsViewProps) {
995
1519
  </ul>
996
1520
  </Callout>
997
1521
  )}
998
- <Overview detail={detail} projects={props.projects} language={language} />
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
+ )}
1530
+ <Overview
1531
+ detail={detail}
1532
+ projects={props.projects}
1533
+ language={language}
1534
+ busy={busy}
1535
+ onConsent={(consent) => change(() => plugin.updateConsent(consent))}
1536
+ />
999
1537
  {canEnable && (
1000
1538
  <section className="sc-panel sc-panel-form" aria-label={settings.enable}>
1001
1539
  <h2 className="sc-section-title">{settings.enable}</h2>
@@ -1004,7 +1542,14 @@ export function PluginSettingsView(props: PluginSettingsViewProps) {
1004
1542
  projects={props.projects}
1005
1543
  language={language}
1006
1544
  busy={busy}
1007
- 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
+ }
1008
1553
  />
1009
1554
  </section>
1010
1555
  )}
@@ -1038,6 +1583,11 @@ export function PluginSettingsView(props: PluginSettingsViewProps) {
1038
1583
  {detail.grants !== null && (
1039
1584
  <RolesSection
1040
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
+ }
1041
1591
  projects={props.projects}
1042
1592
  people={props.people}
1043
1593
  language={language}