@skillcdn/console 0.4.3 → 0.4.4

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 (231) hide show
  1. package/README.md +4 -4
  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 +95 -20
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +4 -2
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +32 -9
  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 +17 -14
  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 +4 -0
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +2 -0
  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 +3 -3
  35. package/dist/components/board.js.map +1 -1
  36. package/dist/components/decision-form.d.ts +6 -0
  37. package/dist/components/decision-form.d.ts.map +1 -1
  38. package/dist/components/decision-form.js +23 -5
  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 +7 -1
  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/pack-connect.d.ts.map +1 -1
  55. package/dist/components/pack-connect.js +5 -4
  56. package/dist/components/pack-connect.js.map +1 -1
  57. package/dist/components/people.d.ts +2 -0
  58. package/dist/components/people.d.ts.map +1 -1
  59. package/dist/components/people.js +15 -2
  60. package/dist/components/people.js.map +1 -1
  61. package/dist/components/plugin-frame.d.ts +3 -12
  62. package/dist/components/plugin-frame.d.ts.map +1 -1
  63. package/dist/components/plugin-frame.js +53 -20
  64. package/dist/components/plugin-frame.js.map +1 -1
  65. package/dist/components/plugin-settings.d.ts.map +1 -1
  66. package/dist/components/plugin-settings.js +151 -26
  67. package/dist/components/plugin-settings.js.map +1 -1
  68. package/dist/components/projects.d.ts +0 -8
  69. package/dist/components/projects.d.ts.map +1 -1
  70. package/dist/components/projects.js +1 -13
  71. package/dist/components/projects.js.map +1 -1
  72. package/dist/components/task-view.d.ts.map +1 -1
  73. package/dist/components/task-view.js +2 -1
  74. package/dist/components/task-view.js.map +1 -1
  75. package/dist/components/ui.d.ts.map +1 -1
  76. package/dist/components/ui.js +5 -2
  77. package/dist/components/ui.js.map +1 -1
  78. package/dist/connect-page.d.ts.map +1 -1
  79. package/dist/connect-page.js +5 -6
  80. package/dist/connect-page.js.map +1 -1
  81. package/dist/console.css +761 -191
  82. package/dist/console.d.ts +10 -3
  83. package/dist/console.d.ts.map +1 -1
  84. package/dist/console.js +33 -13
  85. package/dist/console.js.map +1 -1
  86. package/dist/data.d.ts +4 -2
  87. package/dist/data.d.ts.map +1 -1
  88. package/dist/data.js +5 -0
  89. package/dist/data.js.map +1 -1
  90. package/dist/docs-page.d.ts.map +1 -1
  91. package/dist/docs-page.js +6 -5
  92. package/dist/docs-page.js.map +1 -1
  93. package/dist/home-tiles.d.ts +92 -0
  94. package/dist/home-tiles.d.ts.map +1 -0
  95. package/dist/home-tiles.js +294 -0
  96. package/dist/home-tiles.js.map +1 -0
  97. package/dist/i18n/en.d.ts +174 -0
  98. package/dist/i18n/en.d.ts.map +1 -1
  99. package/dist/i18n/en.js +175 -1
  100. package/dist/i18n/en.js.map +1 -1
  101. package/dist/i18n/index.d.ts +4 -2
  102. package/dist/i18n/index.d.ts.map +1 -1
  103. package/dist/i18n/index.js +10 -11
  104. package/dist/i18n/index.js.map +1 -1
  105. package/dist/i18n/ko.d.ts.map +1 -1
  106. package/dist/i18n/ko.js +170 -3
  107. package/dist/i18n/ko.js.map +1 -1
  108. package/dist/index.d.ts +4 -3
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +4 -3
  111. package/dist/index.js.map +1 -1
  112. package/dist/limits.d.ts +6 -0
  113. package/dist/limits.d.ts.map +1 -1
  114. package/dist/limits.js +6 -0
  115. package/dist/limits.js.map +1 -1
  116. package/dist/plugin/bridge.d.ts +44 -3
  117. package/dist/plugin/bridge.d.ts.map +1 -1
  118. package/dist/plugin/bridge.js +26 -0
  119. package/dist/plugin/bridge.js.map +1 -1
  120. package/dist/plugin/capabilities.d.ts +7 -1
  121. package/dist/plugin/capabilities.d.ts.map +1 -1
  122. package/dist/plugin/capabilities.js +14 -1
  123. package/dist/plugin/capabilities.js.map +1 -1
  124. package/dist/plugin/context.d.ts +18 -1
  125. package/dist/plugin/context.d.ts.map +1 -1
  126. package/dist/plugin/define.d.ts +1 -0
  127. package/dist/plugin/define.d.ts.map +1 -1
  128. package/dist/plugin/define.js +1 -1
  129. package/dist/plugin/define.js.map +1 -1
  130. package/dist/plugin/limits.d.ts +5 -2
  131. package/dist/plugin/limits.d.ts.map +1 -1
  132. package/dist/plugin/limits.js +5 -2
  133. package/dist/plugin/limits.js.map +1 -1
  134. package/dist/plugin/manifest.d.ts +83 -6
  135. package/dist/plugin/manifest.d.ts.map +1 -1
  136. package/dist/plugin/manifest.js +111 -7
  137. package/dist/plugin/manifest.js.map +1 -1
  138. package/dist/plugin/rest-schemas.d.ts +76 -0
  139. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  140. package/dist/plugin/rest-schemas.js +23 -2
  141. package/dist/plugin/rest-schemas.js.map +1 -1
  142. package/dist/plugin/sandbox.d.ts +4 -3
  143. package/dist/plugin/sandbox.d.ts.map +1 -1
  144. package/dist/plugin/sandbox.js +33 -6
  145. package/dist/plugin/sandbox.js.map +1 -1
  146. package/dist/plugin/semver.d.ts +26 -0
  147. package/dist/plugin/semver.d.ts.map +1 -0
  148. package/dist/plugin/semver.js +76 -0
  149. package/dist/plugin/semver.js.map +1 -0
  150. package/dist/plugin/testing.d.ts.map +1 -1
  151. package/dist/plugin/testing.js +2 -1
  152. package/dist/plugin/testing.js.map +1 -1
  153. package/dist/plugin/web.d.ts +10 -1
  154. package/dist/plugin/web.d.ts.map +1 -1
  155. package/dist/plugin/web.js +34 -0
  156. package/dist/plugin/web.js.map +1 -1
  157. package/dist/plugin.d.ts +3 -2
  158. package/dist/plugin.d.ts.map +1 -1
  159. package/dist/plugin.js +3 -2
  160. package/dist/plugin.js.map +1 -1
  161. package/dist/rules.d.ts +2 -2
  162. package/dist/rules.js +3 -3
  163. package/dist/schemas.d.ts +497 -2
  164. package/dist/schemas.d.ts.map +1 -1
  165. package/dist/schemas.js +96 -9
  166. package/dist/schemas.js.map +1 -1
  167. package/dist/use-action.d.ts +8 -2
  168. package/dist/use-action.d.ts.map +1 -1
  169. package/dist/use-action.js +13 -6
  170. package/dist/use-action.js.map +1 -1
  171. package/dist/vocabulary.d.ts +3 -1
  172. package/dist/vocabulary.d.ts.map +1 -1
  173. package/dist/vocabulary.js +3 -0
  174. package/dist/vocabulary.js.map +1 -1
  175. package/docs/specs/cli.md +4 -3
  176. package/docs/specs/plugins.md +46 -33
  177. package/docs/specs/rest.md +16 -15
  178. package/package.json +1 -1
  179. package/skills/building-a-plugin/SKILL.md +8 -7
  180. package/skills/planning-the-board/references/setting-up.md +2 -1
  181. package/skills/working-the-board/SKILL.md +3 -3
  182. package/src/api.ts +6 -0
  183. package/src/cli/cli.ts +124 -19
  184. package/src/cli/format.ts +42 -9
  185. package/src/cli/help.ts +17 -13
  186. package/src/cli/plugin-check.ts +41 -2
  187. package/src/cli/plugin-cli.ts +10 -1
  188. package/src/cli/plugin-init.ts +8 -0
  189. package/src/client.ts +8 -0
  190. package/src/compat.ts +1 -1
  191. package/src/components/board.tsx +3 -3
  192. package/src/components/decision-form.tsx +75 -19
  193. package/src/components/decision-list.tsx +45 -13
  194. package/src/components/event-feed.tsx +7 -1
  195. package/src/components/goals.tsx +4 -1
  196. package/src/components/home.tsx +615 -0
  197. package/src/components/pack-connect.tsx +5 -4
  198. package/src/components/people.tsx +136 -54
  199. package/src/components/plugin-frame.tsx +62 -63
  200. package/src/components/plugin-settings.tsx +428 -87
  201. package/src/components/projects.tsx +0 -55
  202. package/src/components/task-view.tsx +7 -0
  203. package/src/components/ui.tsx +6 -2
  204. package/src/connect-page.tsx +5 -6
  205. package/src/console.tsx +80 -30
  206. package/src/data.ts +9 -2
  207. package/src/docs-page.tsx +6 -5
  208. package/src/home-tiles.ts +438 -0
  209. package/src/i18n/en.ts +178 -0
  210. package/src/i18n/index.ts +17 -14
  211. package/src/i18n/ko.ts +174 -3
  212. package/src/index.ts +7 -5
  213. package/src/limits.ts +6 -0
  214. package/src/plugin/bridge.ts +77 -1
  215. package/src/plugin/capabilities.ts +17 -1
  216. package/src/plugin/context.ts +18 -1
  217. package/src/plugin/define.ts +4 -1
  218. package/src/plugin/limits.ts +5 -2
  219. package/src/plugin/manifest.ts +154 -10
  220. package/src/plugin/rest-schemas.ts +27 -1
  221. package/src/plugin/sandbox.ts +36 -8
  222. package/src/plugin/semver.ts +89 -0
  223. package/src/plugin/testing.ts +2 -1
  224. package/src/plugin/web.ts +49 -0
  225. package/src/plugin.ts +18 -0
  226. package/src/rules.ts +3 -3
  227. package/src/schemas.ts +118 -10
  228. package/src/styles/console.css +761 -191
  229. package/src/use-action.ts +16 -7
  230. package/src/vocabulary.ts +4 -0
  231. package/template/plugin/console-plugin.json +3 -1
@@ -20,6 +20,7 @@ import {
20
20
  Badge,
21
21
  Button,
22
22
  Callout,
23
+ cx,
23
24
  EmptyState,
24
25
  formatInstant,
25
26
  LinkButton,
@@ -110,6 +111,9 @@ export function PluginSettingsList(props: PluginSettingsListProps) {
110
111
  {pluginWord(plugin.name, props.language)}{" "}
111
112
  <span className="sc-muted">
112
113
  {plugin.key} {plugin.version}
114
+ {plugin.console !== null && plugin.console !== undefined && (
115
+ <> · {settings.consoleRange(plugin.console)}</>
116
+ )}
113
117
  </span>
114
118
  </a>
115
119
  <span className="sc-task-row-meta">
@@ -150,20 +154,28 @@ function toggled(set: ReadonlySet<string>, value: string, on: boolean): Readonly
150
154
  /** A row of a list to tick: the box, a name, a word under it. */
151
155
  function TickRow(props: {
152
156
  readonly checked: boolean;
153
- readonly onChange: (checked: boolean) => void;
157
+ readonly onChange?: ((checked: boolean) => void) | undefined;
154
158
  readonly name: string;
155
159
  readonly under?: string | undefined;
160
+ /** A word beside the name: that this one is new, say. */
161
+ readonly tag?: string | undefined;
162
+ /** Whether it stays as it is: on, and not the person's to turn off. */
163
+ readonly locked?: boolean | undefined;
156
164
  }) {
157
165
  return (
158
166
  <li>
159
- <label className="sc-plugin-tick">
167
+ <label className={cx("sc-plugin-tick", props.locked === true && "sc-plugin-tick-locked")}>
160
168
  <input
161
169
  type="checkbox"
162
170
  checked={props.checked}
163
- onChange={(event) => props.onChange(event.target.checked)}
171
+ disabled={props.locked === true}
172
+ onChange={(event) => props.onChange?.(event.target.checked)}
164
173
  />
165
174
  <span className="sc-plugin-tick-words">
166
- <span className="sc-plugin-tick-name">{props.name}</span>
175
+ <span className="sc-plugin-tick-name">
176
+ {props.name}
177
+ {props.tag !== undefined && <Badge tone="warning">{props.tag}</Badge>}
178
+ </span>
167
179
  {props.under !== undefined && props.under !== "" && (
168
180
  <span className="sc-plugin-tick-under">{props.under}</span>
169
181
  )}
@@ -178,12 +190,18 @@ function Overview(props: {
178
190
  readonly detail: RestPluginDetail;
179
191
  readonly projects: readonly RestProject[];
180
192
  readonly language: string;
193
+ readonly busy: boolean;
194
+ /** Called with what the plugin holds from now on, an optional capability turned on or off. */
195
+ readonly onConsent: (consent: readonly string[]) => void;
181
196
  }) {
182
197
  const words = useWords();
183
198
  const settings = words.plugins.settings;
184
199
  const { detail, language } = props;
185
200
  const enabled = detail.state === "enabled";
186
201
  const rows: { readonly key: string; readonly name: string; readonly value: ReactNode }[] = [];
202
+ if (detail.console !== null && detail.console !== undefined) {
203
+ rows.push({ key: "console", name: settings.console, value: <code>{detail.console}</code> });
204
+ }
187
205
  if (detail.board !== null) {
188
206
  rows.push({
189
207
  key: "board",
@@ -214,24 +232,66 @@ function Overview(props: {
214
232
  }
215
233
  if (enabled && detail.consent !== null) {
216
234
  const consent = detail.consent;
235
+ const optional = detail.optionalCapabilities ?? [];
236
+ const required = consent.filter((capability) => !optional.includes(capability));
217
237
  rows.push({
218
238
  key: "capabilities",
219
239
  name: settings.capabilities,
220
240
  value:
221
- consent.length === 0 ? (
241
+ consent.length === 0 && optional.length === 0 ? (
222
242
  settings.nothing
223
243
  ) : (
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>
244
+ <>
245
+ {required.length > 0 && (
246
+ <ul className="sc-plugin-held">
247
+ {required.map((capability) => {
248
+ const text = capabilityText(words, capability);
249
+ return (
250
+ <li key={capability}>
251
+ <span className="sc-plugin-tick-name">{text.name}</span>
252
+ {text.opens !== "" && (
253
+ <span className="sc-plugin-tick-under"> {text.opens}</span>
254
+ )}
255
+ </li>
256
+ );
257
+ })}
258
+ </ul>
259
+ )}
260
+ {optional.length > 0 && (
261
+ <div className="sc-plugin-optional">
262
+ <span className="sc-field-label">{settings.optional}</span>
263
+ <ul className="sc-plugin-held">
264
+ {optional.map((capability) => {
265
+ const text = capabilityText(words, capability);
266
+ return (
267
+ <li key={capability}>
268
+ <label className="sc-check">
269
+ <input
270
+ type="checkbox"
271
+ checked={consent.includes(capability)}
272
+ disabled={props.busy}
273
+ onChange={(event) =>
274
+ props.onConsent(
275
+ event.target.checked
276
+ ? [...consent, capability]
277
+ : consent.filter((held) => held !== capability),
278
+ )
279
+ }
280
+ />
281
+ <span>
282
+ <span className="sc-plugin-tick-name">{text.name}</span>
283
+ {text.opens !== "" && (
284
+ <span className="sc-plugin-tick-under"> {text.opens}</span>
285
+ )}
286
+ </span>
287
+ </label>
288
+ </li>
289
+ );
290
+ })}
291
+ </ul>
292
+ </div>
293
+ )}
294
+ </>
235
295
  ),
236
296
  });
237
297
  }
@@ -272,9 +332,214 @@ function Overview(props: {
272
332
  );
273
333
  }
274
334
 
335
+ /** How many choices a list of an enum shows as pills before it becomes a column of boxes. */
336
+ const PILLS_AT_MOST = 8;
337
+
338
+ /** The control of one item of a list, as a text field: a number, a day, a time or words. */
339
+ function itemInputType(of: string | null): "number" | "date" | "time" | "text" {
340
+ switch (of) {
341
+ case "number":
342
+ return "number";
343
+ case "day":
344
+ return "date";
345
+ case "time":
346
+ return "time";
347
+ default:
348
+ return "text";
349
+ }
350
+ }
351
+
352
+ /** The items of a list setting as the form holds them. */
353
+ function itemsOf(value: unknown): readonly (string | number)[] {
354
+ return Array.isArray(value)
355
+ ? value.filter(
356
+ (item): item is string | number => typeof item === "string" || typeof item === "number",
357
+ )
358
+ : [];
359
+ }
360
+
361
+ /**
362
+ * A list of an enum: a pill per choice while they are few, lit while chosen, else a column of
363
+ * boxes; either way the value is the chosen values, in the options' order.
364
+ */
365
+ function EnumListControl(props: {
366
+ readonly setting: RestPluginSetting;
367
+ readonly value: unknown;
368
+ readonly onChange: (value: unknown) => void;
369
+ readonly language: string;
370
+ }) {
371
+ const options = props.setting.options ?? [];
372
+ const chosen = new Set(itemsOf(props.value).map(String));
373
+ const toggle = (option: string, on: boolean) => {
374
+ const next = options
375
+ .map((candidate) => candidate.value)
376
+ .filter((candidate) => (candidate === option ? on : chosen.has(candidate)));
377
+ props.onChange(next.length === 0 ? null : next);
378
+ };
379
+ const pills = options.length <= PILLS_AT_MOST;
380
+ const name = pluginWord(props.setting.name, props.language);
381
+ const choices = options.map((option) => (
382
+ <label key={option.value} className={pills ? "sc-toggle-chip" : "sc-check"}>
383
+ <input
384
+ type="checkbox"
385
+ checked={chosen.has(option.value)}
386
+ onChange={(event) => toggle(option.value, event.target.checked)}
387
+ />
388
+ {pluginWord(option.name, props.language)}
389
+ </label>
390
+ ));
391
+ return pills ? (
392
+ <fieldset className="sc-toggle-chips" aria-label={name}>
393
+ {choices}
394
+ </fieldset>
395
+ ) : (
396
+ <ul className="sc-check-list" aria-label={name}>
397
+ {choices.map((choice) => (
398
+ <li key={choice.key}>{choice}</li>
399
+ ))}
400
+ </ul>
401
+ );
402
+ }
403
+
404
+ /**
405
+ * A list of people or of projects: each chosen one a chip with the way to take it out, and one
406
+ * choice to add another among those not chosen yet; the value is their ids.
407
+ */
408
+ function RefListControl(props: {
409
+ readonly setting: RestPluginSetting;
410
+ readonly value: unknown;
411
+ readonly onChange: (value: unknown) => void;
412
+ readonly id: string;
413
+ readonly people: readonly RestPerson[];
414
+ readonly projects: readonly RestProject[];
415
+ }) {
416
+ const words = useWords();
417
+ const chosen = itemsOf(props.value).map(String);
418
+ const candidates: readonly { readonly id: string; readonly label: string }[] =
419
+ props.setting.of === "person"
420
+ ? props.people
421
+ .filter((person) => person.status === "active" || chosen.includes(person.id))
422
+ .map((person) => ({ id: person.id, label: personChoice(person) }))
423
+ : props.projects.map((project) => ({
424
+ id: project.id,
425
+ label: `${project.name} (${project.key})`,
426
+ }));
427
+ const labelOf = (id: string) => candidates.find((candidate) => candidate.id === id)?.label ?? id;
428
+ const set = (next: readonly string[]) => props.onChange(next.length === 0 ? null : next);
429
+ const full = props.setting.max !== null && chosen.length >= props.setting.max;
430
+ return (
431
+ <div className="sc-chips">
432
+ {chosen.map((id) => (
433
+ <span key={id} className="sc-chip">
434
+ {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>
444
+ ))}
445
+ {!full && (
446
+ <select
447
+ 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]);
453
+ }
454
+ }}
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>
465
+ )}
466
+ </div>
467
+ );
468
+ }
469
+
470
+ /**
471
+ * A list of texts, numbers, days or times: each item a chip with the way to take it out, and a
472
+ * field that adds one on Enter or with the button; `min` and `max` bound how many.
473
+ */
474
+ function ItemListControl(props: {
475
+ readonly setting: RestPluginSetting;
476
+ readonly value: unknown;
477
+ readonly onChange: (value: unknown) => void;
478
+ readonly id: string;
479
+ }) {
480
+ const words = useWords();
481
+ const [draft, setDraft] = useState("");
482
+ const items = itemsOf(props.value);
483
+ const set = (next: readonly (string | number)[]) =>
484
+ props.onChange(next.length === 0 ? null : next);
485
+ const full = props.setting.max !== null && items.length >= props.setting.max;
486
+ const add = () => {
487
+ const word = draft.trim();
488
+ if (word.length === 0 || full) {
489
+ return;
490
+ }
491
+ const item = props.setting.of === "number" ? Number(word) : word;
492
+ if (typeof item === "number" && !Number.isFinite(item)) {
493
+ return;
494
+ }
495
+ set([...items, item]);
496
+ setDraft("");
497
+ };
498
+ return (
499
+ <div className="sc-chips">
500
+ {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">
503
+ {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>
513
+ ))}
514
+ {!full && (
515
+ <div className="sc-chip-input">
516
+ <input
517
+ id={props.id}
518
+ className="sc-input"
519
+ type={itemInputType(props.setting.of)}
520
+ value={draft}
521
+ placeholder={words.plugins.settings.itemPlaceholder}
522
+ onChange={(event) => setDraft(event.target.value)}
523
+ onKeyDown={(event) => {
524
+ if (event.key === "Enter") {
525
+ event.preventDefault();
526
+ add();
527
+ }
528
+ }}
529
+ />
530
+ <Button type="button" size="sm" disabled={draft.trim().length === 0} onClick={add}>
531
+ {words.plugins.settings.addItem}
532
+ </Button>
533
+ </div>
534
+ )}
535
+ </div>
536
+ );
537
+ }
538
+
275
539
  /**
276
540
  * 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.
541
+ * name, and held as its id; a time of day is read in the workspace's time zone; a list has a
542
+ * control per kind of item; a secret has a row of its own below.
278
543
  */
279
544
  function SettingControl(props: {
280
545
  readonly setting: RestPluginSetting;
@@ -285,6 +550,7 @@ function SettingControl(props: {
285
550
  readonly people: readonly RestPerson[];
286
551
  readonly projects: readonly RestProject[];
287
552
  }) {
553
+ const words = useWords();
288
554
  const { setting, value, onChange, id } = props;
289
555
  const chosen = typeof value === "string" ? value : "";
290
556
  const choose = (event: { readonly target: { readonly value: string } }) =>
@@ -372,25 +638,43 @@ function SettingControl(props: {
372
638
  onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
373
639
  />
374
640
  );
375
- case "list":
641
+ case "time":
376
642
  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
- />
643
+ <>
644
+ <input
645
+ id={id}
646
+ className="sc-input"
647
+ type="time"
648
+ value={typeof value === "string" ? value : ""}
649
+ onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
650
+ />
651
+ <p className="sc-settings-hint">{words.plugins.settings.timeZoneHint}</p>
652
+ </>
393
653
  );
654
+ case "list":
655
+ if (setting.of === "enum") {
656
+ return (
657
+ <EnumListControl
658
+ setting={setting}
659
+ value={value}
660
+ onChange={onChange}
661
+ language={props.language}
662
+ />
663
+ );
664
+ }
665
+ if (setting.of === "person" || setting.of === "project") {
666
+ return (
667
+ <RefListControl
668
+ setting={setting}
669
+ value={value}
670
+ onChange={onChange}
671
+ id={id}
672
+ people={props.people}
673
+ projects={props.projects}
674
+ />
675
+ );
676
+ }
677
+ return <ItemListControl setting={setting} value={value} onChange={onChange} id={id} />;
394
678
  default:
395
679
  return (
396
680
  <input
@@ -427,36 +711,57 @@ function SettingsForm(props: {
427
711
  event.preventDefault();
428
712
  props.onSave(values);
429
713
  };
714
+ /** A row per setting: the name and the hint on the left, the control on the right. */
715
+ const rows = (shown: readonly RestPluginSetting[]) =>
716
+ shown.map((setting) => (
717
+ <div key={setting.key} className="sc-settings-row">
718
+ <div className="sc-settings-words">
719
+ <label className="sc-settings-label" htmlFor={`${prefix}-${setting.key}`}>
720
+ {pluginWord(setting.name, props.language)}
721
+ {setting.required && " *"}
722
+ </label>
723
+ {setting.description !== null && (
724
+ <p className="sc-settings-hint">{pluginWord(setting.description, props.language)}</p>
725
+ )}
726
+ </div>
727
+ <div className="sc-settings-control">
728
+ <SettingControl
729
+ setting={setting}
730
+ value={values[setting.key]}
731
+ id={`${prefix}-${setting.key}`}
732
+ language={props.language}
733
+ people={props.people}
734
+ projects={props.projects}
735
+ onChange={(value) => setValues((current) => ({ ...current, [setting.key]: value }))}
736
+ />
737
+ </div>
738
+ </div>
739
+ ));
430
740
  return (
431
741
  <form
432
742
  className="sc-form sc-settings-form"
433
743
  onSubmit={submit}
434
744
  aria-label={words.plugins.settings.settingsForm}
435
745
  >
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
- ))}
746
+ {rows(settings.filter((setting) => setting.group === null || setting.group === undefined))}
747
+ {(props.detail.settingGroups ?? []).map((group) => {
748
+ const grouped = settings.filter((setting) => setting.group === group.key);
749
+ if (grouped.length === 0) {
750
+ return null;
751
+ }
752
+ const description = pluginWord(group.description, props.language);
753
+ return (
754
+ <section
755
+ key={group.key}
756
+ className="sc-settings-group"
757
+ aria-label={pluginWord(group.name, props.language)}
758
+ >
759
+ <h3 className="sc-settings-group-title">{pluginWord(group.name, props.language)}</h3>
760
+ {description !== "" && <p className="sc-settings-hint">{description}</p>}
761
+ {rows(grouped)}
762
+ </section>
763
+ );
764
+ })}
460
765
  <div className="sc-form-actions">
461
766
  <Button type="submit" variant="primary" disabled={props.busy}>
462
767
  {words.common.save}
@@ -527,8 +832,9 @@ function SecretRow(props: {
527
832
  }
528
833
 
529
834
  /**
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.
835
+ * Enabling: the capabilities the plugin requires, on and not the administrator's to turn off, and
836
+ * those it can go without, off until chosen, those consented to before on already (ADR-0060); the
837
+ * board project, the one it had chosen already; the projects, as they were.
532
838
  */
533
839
  function EnableForm(props: {
534
840
  readonly detail: RestPluginDetail;
@@ -540,12 +846,15 @@ function EnableForm(props: {
540
846
  const words = useWords();
541
847
  const settings = words.plugins.settings;
542
848
  const { detail, language } = props;
849
+ const optional = detail.optionalCapabilities ?? [];
543
850
  const [consented, setConsented] = useState<ReadonlySet<string>>(
544
- () =>
545
- new Set(
546
- (detail.consent ?? []).filter((capability) => detail.capabilities.includes(capability)),
547
- ),
851
+ () => new Set((detail.consent ?? []).filter((capability) => optional.includes(capability))),
548
852
  );
853
+ /** A capability a new version asks and nobody has consented to yet is said to be new. */
854
+ const tagOf = (capability: string): string | undefined =>
855
+ detail.state === "needs_consent" && !(detail.consent ?? []).includes(capability)
856
+ ? settings.newCapability
857
+ : undefined;
549
858
  const needsBoard = detail.boardNeeds !== "none";
550
859
  const [boardMode, setBoardMode] = useState<"none" | "create" | "choose">(
551
860
  detail.boardNeeds === "none"
@@ -566,7 +875,6 @@ function EnableForm(props: {
566
875
  () => new Set(Array.isArray(before) ? before : []),
567
876
  );
568
877
  const ids = { key: useId(), name: useId(), description: useId(), choose: useId() };
569
- const everyCapability = detail.capabilities.every((capability) => consented.has(capability));
570
878
  const boardReady =
571
879
  boardMode === "none"
572
880
  ? detail.boardNeeds !== "required"
@@ -576,7 +884,7 @@ function EnableForm(props: {
576
884
  const submit = (event: FormEvent) => {
577
885
  event.preventDefault();
578
886
  props.onEnable({
579
- consent: [...consented],
887
+ consent: [...detail.capabilities, ...consented],
580
888
  board:
581
889
  boardMode === "none"
582
890
  ? null
@@ -588,23 +896,54 @@ function EnableForm(props: {
588
896
  };
589
897
  return (
590
898
  <form className="sc-form sc-plugin-enable" onSubmit={submit} aria-label={settings.enable}>
591
- {detail.capabilities.length > 0 && (
899
+ {detail.capabilities.length + optional.length > 0 && (
592
900
  <div className="sc-plugin-group">
593
901
  <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>
902
+ {detail.capabilities.length > 0 && (
903
+ <div className="sc-plugin-capabilities">
904
+ <p className="sc-plugin-capabilities-head">
905
+ <span className="sc-plugin-capabilities-name">{settings.required}</span>
906
+ <span className="sc-settings-hint">{settings.requiredHint}</span>
907
+ </p>
908
+ <ul className="sc-plugin-ticks">
909
+ {detail.capabilities.map((capability) => {
910
+ const text = capabilityText(words, capability);
911
+ return (
912
+ <TickRow
913
+ key={capability}
914
+ checked
915
+ locked
916
+ name={text.name}
917
+ under={text.opens}
918
+ tag={tagOf(capability)}
919
+ />
920
+ );
921
+ })}
922
+ </ul>
923
+ </div>
924
+ )}
925
+ {optional.length > 0 && (
926
+ <div className="sc-plugin-capabilities">
927
+ <p className="sc-plugin-capabilities-head">
928
+ <span className="sc-plugin-capabilities-name">{settings.optional}</span>
929
+ <span className="sc-settings-hint">{settings.optionalHint}</span>
930
+ </p>
931
+ <ul className="sc-plugin-ticks">
932
+ {optional.map((capability) => {
933
+ const text = capabilityText(words, capability);
934
+ return (
935
+ <TickRow
936
+ key={capability}
937
+ checked={consented.has(capability)}
938
+ onChange={(on) => setConsented((current) => toggled(current, capability, on))}
939
+ name={text.name}
940
+ under={text.opens}
941
+ />
942
+ );
943
+ })}
944
+ </ul>
945
+ </div>
946
+ )}
608
947
  </div>
609
948
  )}
610
949
  {needsBoard && (
@@ -740,11 +1079,7 @@ function EnableForm(props: {
740
1079
  </div>
741
1080
  <div className="sc-plugin-group">
742
1081
  <div className="sc-form-actions">
743
- <Button
744
- type="submit"
745
- variant="primary"
746
- disabled={props.busy || !everyCapability || !boardReady}
747
- >
1082
+ <Button type="submit" variant="primary" disabled={props.busy || !boardReady}>
748
1083
  {settings.enable}
749
1084
  </Button>
750
1085
  </div>
@@ -995,7 +1330,13 @@ export function PluginSettingsView(props: PluginSettingsViewProps) {
995
1330
  </ul>
996
1331
  </Callout>
997
1332
  )}
998
- <Overview detail={detail} projects={props.projects} language={language} />
1333
+ <Overview
1334
+ detail={detail}
1335
+ projects={props.projects}
1336
+ language={language}
1337
+ busy={busy}
1338
+ onConsent={(consent) => change(() => plugin.updateConsent(consent))}
1339
+ />
999
1340
  {canEnable && (
1000
1341
  <section className="sc-panel sc-panel-form" aria-label={settings.enable}>
1001
1342
  <h2 className="sc-section-title">{settings.enable}</h2>