@skillcdn/console 0.4.2 → 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 (243) 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 +120 -33
  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 +38 -10
  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 +22 -15
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/plugin-check.d.ts +10 -1
  17. package/dist/cli/plugin-check.d.ts.map +1 -1
  18. package/dist/cli/plugin-check.js +45 -4
  19. package/dist/cli/plugin-check.js.map +1 -1
  20. package/dist/cli/plugin-cli.d.ts +1 -1
  21. package/dist/cli/plugin-cli.d.ts.map +1 -1
  22. package/dist/cli/plugin-cli.js +80 -5
  23. package/dist/cli/plugin-cli.js.map +1 -1
  24. package/dist/cli/plugin-init.d.ts +19 -0
  25. package/dist/cli/plugin-init.d.ts.map +1 -1
  26. package/dist/cli/plugin-init.js +141 -3
  27. package/dist/cli/plugin-init.js.map +1 -1
  28. package/dist/client.d.ts +7 -2
  29. package/dist/client.d.ts.map +1 -1
  30. package/dist/client.js +18 -5
  31. package/dist/client.js.map +1 -1
  32. package/dist/compat.js +1 -1
  33. package/dist/components/board.d.ts +8 -0
  34. package/dist/components/board.d.ts.map +1 -1
  35. package/dist/components/board.js +3 -3
  36. package/dist/components/board.js.map +1 -1
  37. package/dist/components/comments.d.ts.map +1 -1
  38. package/dist/components/comments.js +1 -1
  39. package/dist/components/comments.js.map +1 -1
  40. package/dist/components/decision-form.d.ts +8 -1
  41. package/dist/components/decision-form.d.ts.map +1 -1
  42. package/dist/components/decision-form.js +57 -7
  43. package/dist/components/decision-form.js.map +1 -1
  44. package/dist/components/decision-list.d.ts +9 -1
  45. package/dist/components/decision-list.d.ts.map +1 -1
  46. package/dist/components/decision-list.js +53 -7
  47. package/dist/components/decision-list.js.map +1 -1
  48. package/dist/components/event-feed.d.ts.map +1 -1
  49. package/dist/components/event-feed.js +7 -1
  50. package/dist/components/event-feed.js.map +1 -1
  51. package/dist/components/goals.d.ts.map +1 -1
  52. package/dist/components/goals.js +2 -2
  53. package/dist/components/goals.js.map +1 -1
  54. package/dist/components/home.d.ts +35 -0
  55. package/dist/components/home.d.ts.map +1 -0
  56. package/dist/components/home.js +326 -0
  57. package/dist/components/home.js.map +1 -0
  58. package/dist/components/my-decisions.d.ts +2 -0
  59. package/dist/components/my-decisions.d.ts.map +1 -1
  60. package/dist/components/my-decisions.js +2 -2
  61. package/dist/components/my-decisions.js.map +1 -1
  62. package/dist/components/pack-connect.d.ts.map +1 -1
  63. package/dist/components/pack-connect.js +5 -4
  64. package/dist/components/pack-connect.js.map +1 -1
  65. package/dist/components/people.d.ts +2 -0
  66. package/dist/components/people.d.ts.map +1 -1
  67. package/dist/components/people.js +15 -2
  68. package/dist/components/people.js.map +1 -1
  69. package/dist/components/plugin-frame.d.ts +3 -12
  70. package/dist/components/plugin-frame.d.ts.map +1 -1
  71. package/dist/components/plugin-frame.js +53 -20
  72. package/dist/components/plugin-frame.js.map +1 -1
  73. package/dist/components/plugin-settings.d.ts.map +1 -1
  74. package/dist/components/plugin-settings.js +151 -26
  75. package/dist/components/plugin-settings.js.map +1 -1
  76. package/dist/components/projects.d.ts +0 -8
  77. package/dist/components/projects.d.ts.map +1 -1
  78. package/dist/components/projects.js +1 -13
  79. package/dist/components/projects.js.map +1 -1
  80. package/dist/components/task-view.d.ts +2 -1
  81. package/dist/components/task-view.d.ts.map +1 -1
  82. package/dist/components/task-view.js +2 -1
  83. package/dist/components/task-view.js.map +1 -1
  84. package/dist/components/ui.d.ts.map +1 -1
  85. package/dist/components/ui.js +5 -2
  86. package/dist/components/ui.js.map +1 -1
  87. package/dist/connect-page.d.ts.map +1 -1
  88. package/dist/connect-page.js +5 -6
  89. package/dist/connect-page.js.map +1 -1
  90. package/dist/console.css +764 -192
  91. package/dist/console.d.ts +10 -3
  92. package/dist/console.d.ts.map +1 -1
  93. package/dist/console.js +49 -28
  94. package/dist/console.js.map +1 -1
  95. package/dist/data.d.ts +6 -3
  96. package/dist/data.d.ts.map +1 -1
  97. package/dist/data.js +31 -7
  98. package/dist/data.js.map +1 -1
  99. package/dist/docs-page.d.ts.map +1 -1
  100. package/dist/docs-page.js +6 -5
  101. package/dist/docs-page.js.map +1 -1
  102. package/dist/home-tiles.d.ts +92 -0
  103. package/dist/home-tiles.d.ts.map +1 -0
  104. package/dist/home-tiles.js +294 -0
  105. package/dist/home-tiles.js.map +1 -0
  106. package/dist/i18n/en.d.ts +180 -0
  107. package/dist/i18n/en.d.ts.map +1 -1
  108. package/dist/i18n/en.js +181 -1
  109. package/dist/i18n/en.js.map +1 -1
  110. package/dist/i18n/index.d.ts +4 -2
  111. package/dist/i18n/index.d.ts.map +1 -1
  112. package/dist/i18n/index.js +10 -11
  113. package/dist/i18n/index.js.map +1 -1
  114. package/dist/i18n/ko.d.ts.map +1 -1
  115. package/dist/i18n/ko.js +176 -3
  116. package/dist/i18n/ko.js.map +1 -1
  117. package/dist/index.d.ts +4 -3
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +4 -3
  120. package/dist/index.js.map +1 -1
  121. package/dist/limits.d.ts +8 -0
  122. package/dist/limits.d.ts.map +1 -1
  123. package/dist/limits.js +8 -0
  124. package/dist/limits.js.map +1 -1
  125. package/dist/plugin/bridge.d.ts +44 -3
  126. package/dist/plugin/bridge.d.ts.map +1 -1
  127. package/dist/plugin/bridge.js +26 -0
  128. package/dist/plugin/bridge.js.map +1 -1
  129. package/dist/plugin/capabilities.d.ts +7 -1
  130. package/dist/plugin/capabilities.d.ts.map +1 -1
  131. package/dist/plugin/capabilities.js +14 -1
  132. package/dist/plugin/capabilities.js.map +1 -1
  133. package/dist/plugin/context.d.ts +24 -1
  134. package/dist/plugin/context.d.ts.map +1 -1
  135. package/dist/plugin/define.d.ts +1 -0
  136. package/dist/plugin/define.d.ts.map +1 -1
  137. package/dist/plugin/define.js +1 -1
  138. package/dist/plugin/define.js.map +1 -1
  139. package/dist/plugin/limits.d.ts +5 -2
  140. package/dist/plugin/limits.d.ts.map +1 -1
  141. package/dist/plugin/limits.js +5 -2
  142. package/dist/plugin/limits.js.map +1 -1
  143. package/dist/plugin/manifest.d.ts +83 -6
  144. package/dist/plugin/manifest.d.ts.map +1 -1
  145. package/dist/plugin/manifest.js +111 -7
  146. package/dist/plugin/manifest.js.map +1 -1
  147. package/dist/plugin/rest-schemas.d.ts +76 -0
  148. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  149. package/dist/plugin/rest-schemas.js +23 -2
  150. package/dist/plugin/rest-schemas.js.map +1 -1
  151. package/dist/plugin/sandbox.d.ts +4 -3
  152. package/dist/plugin/sandbox.d.ts.map +1 -1
  153. package/dist/plugin/sandbox.js +33 -6
  154. package/dist/plugin/sandbox.js.map +1 -1
  155. package/dist/plugin/semver.d.ts +26 -0
  156. package/dist/plugin/semver.d.ts.map +1 -0
  157. package/dist/plugin/semver.js +76 -0
  158. package/dist/plugin/semver.js.map +1 -0
  159. package/dist/plugin/testing.d.ts.map +1 -1
  160. package/dist/plugin/testing.js +2 -1
  161. package/dist/plugin/testing.js.map +1 -1
  162. package/dist/plugin/web.d.ts +10 -1
  163. package/dist/plugin/web.d.ts.map +1 -1
  164. package/dist/plugin/web.js +34 -0
  165. package/dist/plugin/web.js.map +1 -1
  166. package/dist/plugin.d.ts +3 -2
  167. package/dist/plugin.d.ts.map +1 -1
  168. package/dist/plugin.js +3 -2
  169. package/dist/plugin.js.map +1 -1
  170. package/dist/rules.d.ts +2 -2
  171. package/dist/rules.js +3 -3
  172. package/dist/schemas.d.ts +574 -11
  173. package/dist/schemas.d.ts.map +1 -1
  174. package/dist/schemas.js +154 -25
  175. package/dist/schemas.js.map +1 -1
  176. package/dist/use-action.d.ts +8 -2
  177. package/dist/use-action.d.ts.map +1 -1
  178. package/dist/use-action.js +13 -6
  179. package/dist/use-action.js.map +1 -1
  180. package/dist/vocabulary.d.ts +3 -1
  181. package/dist/vocabulary.d.ts.map +1 -1
  182. package/dist/vocabulary.js +3 -0
  183. package/dist/vocabulary.js.map +1 -1
  184. package/docs/specs/cli.md +4 -3
  185. package/docs/specs/plugins.md +49 -35
  186. package/docs/specs/rest.md +20 -18
  187. package/package.json +7 -7
  188. package/skills/building-a-plugin/SKILL.md +10 -9
  189. package/skills/planning-the-board/references/setting-up.md +2 -1
  190. package/skills/working-the-board/SKILL.md +3 -3
  191. package/src/api.ts +10 -0
  192. package/src/cli/cli.ts +162 -39
  193. package/src/cli/format.ts +54 -10
  194. package/src/cli/help.ts +23 -14
  195. package/src/cli/plugin-check.ts +68 -2
  196. package/src/cli/plugin-cli.ts +104 -6
  197. package/src/cli/plugin-init.ts +178 -3
  198. package/src/client.ts +36 -9
  199. package/src/compat.ts +1 -1
  200. package/src/components/board.tsx +3 -3
  201. package/src/components/comments.tsx +1 -0
  202. package/src/components/decision-form.tsx +166 -29
  203. package/src/components/decision-list.tsx +116 -15
  204. package/src/components/event-feed.tsx +7 -1
  205. package/src/components/goals.tsx +4 -1
  206. package/src/components/home.tsx +615 -0
  207. package/src/components/my-decisions.tsx +4 -1
  208. package/src/components/pack-connect.tsx +5 -4
  209. package/src/components/people.tsx +136 -54
  210. package/src/components/plugin-frame.tsx +62 -63
  211. package/src/components/plugin-settings.tsx +428 -87
  212. package/src/components/projects.tsx +0 -55
  213. package/src/components/task-view.tsx +9 -1
  214. package/src/components/ui.tsx +6 -2
  215. package/src/connect-page.tsx +5 -6
  216. package/src/console.tsx +98 -42
  217. package/src/data.ts +41 -11
  218. package/src/docs-page.tsx +6 -5
  219. package/src/home-tiles.ts +438 -0
  220. package/src/i18n/en.ts +185 -0
  221. package/src/i18n/index.ts +17 -14
  222. package/src/i18n/ko.ts +181 -3
  223. package/src/index.ts +7 -5
  224. package/src/limits.ts +8 -0
  225. package/src/plugin/bridge.ts +77 -1
  226. package/src/plugin/capabilities.ts +17 -1
  227. package/src/plugin/context.ts +24 -2
  228. package/src/plugin/define.ts +4 -1
  229. package/src/plugin/limits.ts +5 -2
  230. package/src/plugin/manifest.ts +154 -10
  231. package/src/plugin/rest-schemas.ts +27 -1
  232. package/src/plugin/sandbox.ts +36 -8
  233. package/src/plugin/semver.ts +89 -0
  234. package/src/plugin/testing.ts +2 -1
  235. package/src/plugin/web.ts +49 -0
  236. package/src/plugin.ts +18 -0
  237. package/src/rules.ts +3 -3
  238. package/src/schemas.ts +190 -28
  239. package/src/styles/console.css +764 -192
  240. package/src/use-action.ts +16 -7
  241. package/src/vocabulary.ts +4 -0
  242. package/template/plugin/console-plugin.json +3 -1
  243. package/template/plugin/package.json +3 -3
@@ -1,6 +1,8 @@
1
- import { type FormEvent, useMemo, useState } from "react";
1
+ import { type ClipboardEvent, type FormEvent, useMemo, useState } from "react";
2
2
  import {
3
3
  MAX_BODY_LENGTH,
4
+ MAX_DECIDERS,
5
+ MAX_FILES_PER_DECISION,
4
6
  MAX_OPTION_LABEL_LENGTH,
5
7
  MAX_OPTIONS,
6
8
  MAX_QUESTION_LENGTH,
@@ -10,14 +12,17 @@ import {
10
12
  type RestTask,
11
13
  } from "../api.js";
12
14
  import { useWords } from "../i18n/index.js";
15
+ import { isPicture, placing, unusedName } from "./files.js";
13
16
  import { Picker, type PickerSearch } from "./picker.js";
14
17
  import { type TaskSearch, taskLabel } from "./task-form.js";
15
- import { Button, PersonChip, personName } from "./ui.js";
18
+ import { Button, formatBytes, PersonChip, personName } from "./ui.js";
16
19
 
17
20
  // Raising a decision: the question, what a person needs to know, the options to choose from,
18
21
  // and who is to answer (ADR-0042): the person raising it, unless they name another of the
19
22
  // project. The task it is about is the page's, when raised from a task; otherwise it is found
20
- // by searching the board a page at a time (ADR-0037), never picked from the board whole.
23
+ // by searching the board a page at a time (ADR-0037), never picked from the board whole. The
24
+ // files put on the decision (ADR-0055) are chosen or pasted here, a pasted picture placed in
25
+ // the context where pictures are read, as a comment's are.
21
26
 
22
27
  export interface DecisionFormProps {
23
28
  /** The task it is about from the start, when raised from a task's page; the field is not shown then. */
@@ -30,10 +35,28 @@ export interface DecisionFormProps {
30
35
  readonly me?: RestPerson | undefined;
31
36
  readonly busy?: boolean | undefined;
32
37
  readonly error?: string | undefined;
33
- readonly onSubmit: (input: RestDecisionInput) => void;
38
+ /** Called with the decision as the API takes it, and the files put on it (ADR-0055), none when none were chosen. */
39
+ readonly onSubmit: (input: RestDecisionInput, files: readonly File[]) => void;
34
40
  readonly onCancel?: (() => void) | undefined;
35
41
  }
36
42
 
43
+ /**
44
+ * What the form sends of whom the decision is for: nothing for the raiser alone, `deciderId` for
45
+ * one other person, `deciderIds` for several (ADR-0057), the first among them repeated as
46
+ * `deciderId` for a console of before.
47
+ */
48
+ export function decidersInput(
49
+ deciders: readonly RestPerson[],
50
+ me: RestPerson | undefined,
51
+ ): Pick<RestDecisionInput, "deciderId" | "deciderIds"> {
52
+ const ids = deciders.map((person) => person.id);
53
+ if (ids.length === 0 || (ids.length === 1 && ids[0] === me?.id)) {
54
+ return {};
55
+ }
56
+ const first = ids[0] ?? "";
57
+ return ids.length === 1 ? { deciderId: first } : { deciderId: first, deciderIds: ids };
58
+ }
59
+
37
60
  /** The people of the project found by the words typed, a page at a time, as a picker searches. */
38
61
  export function peopleSearch(people: readonly RestPerson[]): PickerSearch<RestPerson> {
39
62
  return async (query, page) => {
@@ -58,21 +81,73 @@ export function DecisionForm(props: DecisionFormProps) {
58
81
  const [body, setBody] = useState("");
59
82
  const [about, setAbout] = useState<RestTask | undefined>(undefined);
60
83
  const [options, setOptions] = useState<readonly string[]>(["", ""]);
61
- const [decider, setDecider] = useState<RestPerson | undefined>(props.me);
62
- const findDeciders = useMemo(() => peopleSearch(props.deciders ?? []), [props.deciders]);
84
+ // Whom it is for (ADR-0042, ADR-0057): the raiser, until others are named in their place.
85
+ const [deciders, setDeciders] = useState<readonly RestPerson[]>(
86
+ props.me === undefined ? [] : [props.me],
87
+ );
88
+ const [files, setFiles] = useState<readonly File[]>([]);
89
+ const findDeciders = useMemo(
90
+ () =>
91
+ peopleSearch(
92
+ (props.deciders ?? []).filter(
93
+ (person) => !deciders.some((chosen) => chosen.id === person.id),
94
+ ),
95
+ ),
96
+ [props.deciders, deciders],
97
+ );
63
98
 
64
99
  const labels = options.map((option) => option.trim()).filter((option) => option.length > 0);
65
100
  const ready = question.trim().length > 0 && labels.length >= MIN_OPTIONS;
66
101
 
102
+ const add = (chosen: readonly File[]): File[] => {
103
+ const added: File[] = [];
104
+ let held = files;
105
+ for (const file of chosen) {
106
+ if (held.length >= MAX_FILES_PER_DECISION) {
107
+ break;
108
+ }
109
+ const name = unusedName(
110
+ file.name,
111
+ held.map((kept) => kept.name),
112
+ );
113
+ const kept = name === file.name ? file : new File([file], name, { type: file.type });
114
+ held = [...held, kept];
115
+ added.push(kept);
116
+ }
117
+ setFiles(held);
118
+ return added;
119
+ };
120
+
121
+ // A picture pasted into the context goes with the decision, placed where pictures are read.
122
+ const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
123
+ const pasted = [...event.clipboardData.files].filter((file) => isPicture(file.type));
124
+ if (pasted.length === 0) {
125
+ return;
126
+ }
127
+ event.preventDefault();
128
+ const added = add(pasted);
129
+ if (added.length > 0) {
130
+ setBody((current) =>
131
+ placing(
132
+ current,
133
+ added.map((file) => file.name),
134
+ ),
135
+ );
136
+ }
137
+ };
138
+
67
139
  const submit = (event: FormEvent) => {
68
140
  event.preventDefault();
69
- props.onSubmit({
70
- question: question.trim(),
71
- body,
72
- options: labels.map((label) => ({ label })),
73
- taskId: props.taskId ?? about?.id ?? null,
74
- ...(decider === undefined || decider.id === props.me?.id ? {} : { deciderId: decider.id }),
75
- });
141
+ props.onSubmit(
142
+ {
143
+ question: question.trim(),
144
+ body,
145
+ options: labels.map((label) => ({ label })),
146
+ taskId: props.taskId ?? about?.id ?? null,
147
+ ...decidersInput(deciders, props.me),
148
+ },
149
+ files,
150
+ );
76
151
  };
77
152
 
78
153
  return (
@@ -96,8 +171,43 @@ export function DecisionForm(props: DecisionFormProps) {
96
171
  rows={4}
97
172
  placeholder={form.contextPlaceholder}
98
173
  onChange={(event) => setBody(event.target.value)}
174
+ onPaste={paste}
99
175
  />
100
176
  </label>
177
+ <div className="sc-field">
178
+ <span className="sc-field-label">{form.files}</span>
179
+ {files.length > 0 && (
180
+ <ul className="sc-chosen-files" aria-label={form.files}>
181
+ {files.map((file) => (
182
+ <li key={file.name} className="sc-chosen-file">
183
+ <span>{file.name}</span> <span className="sc-muted">{formatBytes(file.size)}</span>{" "}
184
+ <Button
185
+ variant="ghost"
186
+ size="sm"
187
+ aria-label={form.removeFile(file.name)}
188
+ onClick={() => setFiles((current) => current.filter((held) => held !== file))}
189
+ >
190
+ ×
191
+ </Button>
192
+ </li>
193
+ ))}
194
+ </ul>
195
+ )}
196
+ <label className="sc-button sc-button-ghost sc-button-sm sc-file-choice">
197
+ {form.chooseFiles}
198
+ <input
199
+ className="sc-file-input"
200
+ type="file"
201
+ multiple
202
+ disabled={props.busy === true || files.length >= MAX_FILES_PER_DECISION}
203
+ onChange={(event) => {
204
+ add([...(event.target.files ?? [])]);
205
+ event.target.value = "";
206
+ }}
207
+ />
208
+ </label>
209
+ <p className="sc-field-hint">{form.filesHint}</p>
210
+ </div>
101
211
  {props.taskId === undefined && props.findTasks !== undefined && (
102
212
  <div className="sc-field">
103
213
  <span className="sc-field-label">{form.about}</span>
@@ -114,24 +224,51 @@ export function DecisionForm(props: DecisionFormProps) {
114
224
  </div>
115
225
  )}
116
226
  {props.deciders !== undefined && (
117
- <div className="sc-field">
118
- <span className="sc-field-label">{form.decider}</span>
119
- <Picker
120
- value={decider}
121
- search={findDeciders}
122
- keyOf={(person) => person.id}
123
- render={(person) => (
124
- <>
125
- <PersonChip person={person} size="sm" />
227
+ <fieldset className="sc-field sc-workers-field">
228
+ <legend className="sc-field-label">{form.decider}</legend>
229
+ <div className="sc-chips">
230
+ {deciders.map((person) => (
231
+ <span key={person.id} className="sc-chip">
232
+ <PersonChip person={person} />
126
233
  {person.id === props.me?.id && <span className="sc-muted"> {form.me}</span>}
127
- </>
234
+ <button
235
+ type="button"
236
+ className="sc-chip-remove"
237
+ aria-label={`${words.common.remove}: ${personName(person)}`}
238
+ disabled={props.busy}
239
+ onClick={() =>
240
+ setDeciders((current) => current.filter((chosen) => chosen.id !== person.id))
241
+ }
242
+ >
243
+ <span aria-hidden="true">&times;</span>
244
+ </button>
245
+ </span>
246
+ ))}
247
+ {deciders.length === 0 && <span className="sc-muted">{form.deciderPlaceholder}</span>}
248
+ {deciders.length < MAX_DECIDERS && (
249
+ <Picker
250
+ value={undefined}
251
+ search={findDeciders}
252
+ keyOf={(person) => person.id}
253
+ render={(person) => (
254
+ <>
255
+ <PersonChip person={person} size="sm" />
256
+ {person.id === props.me?.id && <span className="sc-muted"> {form.me}</span>}
257
+ </>
258
+ )}
259
+ label={form.addDecider}
260
+ placeholder={form.addDecider}
261
+ disabled={props.busy}
262
+ onChange={(person) => {
263
+ if (person !== undefined) {
264
+ setDeciders((current) => [...current, person]);
265
+ }
266
+ }}
267
+ />
128
268
  )}
129
- label={form.decider}
130
- placeholder={form.deciderPlaceholder}
131
- disabled={props.busy}
132
- onChange={(person) => setDecider(person ?? props.me)}
133
- />
134
- </div>
269
+ </div>
270
+ {deciders.length > 1 && <p className="sc-field-hint">{form.decidersHint}</p>}
271
+ </fieldset>
135
272
  )}
136
273
  <fieldset className="sc-field sc-options">
137
274
  <legend className="sc-field-label">{form.options}</legend>
@@ -6,13 +6,17 @@ import {
6
6
  MAX_OPTION_LABEL_LENGTH,
7
7
  type RestAnswerInput,
8
8
  type RestDecision,
9
+ type RestDecisionFile,
9
10
  type RestDecisionPatch,
11
+ type RestPerson,
10
12
  type RestTask,
11
13
  } from "../api.js";
12
14
  import { useWords } from "../i18n/index.js";
15
+ import { isPicture, namedFile, placed } from "./files.js";
13
16
  import { Markdown } from "./markdown.js";
14
17
  import { Pager, pageOf, RECORD_PAGE_SIZE } from "./paged.js";
15
- import { Badge, Button, cx, PersonChip, personName, Time } from "./ui.js";
18
+ import { Lightbox, PictureThumb, usePicture } from "./pictures.js";
19
+ import { Badge, Button, cx, formatBytes, PersonChip, personName, Time } from "./ui.js";
16
20
 
17
21
  // The decisions: the ones that wait, each a card with the way to answer it, since they are what
18
22
  // a person came for; and the ones answered, the record, a row each, since they are read back
@@ -31,6 +35,8 @@ export interface DecisionCardProps {
31
35
  readonly href?: string | undefined;
32
36
  /** The project the decision is in, named where a page shows the decisions of several. */
33
37
  readonly project?: React.ReactNode;
38
+ /** Where a file put on the decision is read, by its id (ADR-0055); left out, the files are named and not linked. */
39
+ readonly fileHref?: ((fileId: string) => string) | undefined;
34
40
  /** Called with the person's answer. Left out, a waiting decision cannot be answered here. */
35
41
  readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
36
42
  /** Called to grow the record, with what followed. Left out, it cannot be written here. */
@@ -38,13 +44,34 @@ export interface DecisionCardProps {
38
44
  readonly busy?: boolean | undefined;
39
45
  }
40
46
 
47
+ /** The word beside who answered: through their agent, when the answer came that way (ADR-0056). */
48
+ function answeredWord(words: ReturnType<typeof useWords>, agent: string | null | undefined) {
49
+ return agent === null || agent === undefined
50
+ ? words.decisions.answeredBy
51
+ : words.decisions.answeredThrough(agentLabel(agent));
52
+ }
53
+
54
+ /** Everyone a decision is addressed to (ADR-0057): `deciders` where the console answers it, else the one decider. */
55
+ export function decidersOf(decision: RestDecision): readonly RestPerson[] {
56
+ return decision.deciders !== undefined && decision.deciders.length > 0
57
+ ? decision.deciders
58
+ : [decision.decider];
59
+ }
60
+
61
+ /** The names of everyone a decision is addressed to, one after another. */
62
+ export function deciderNames(decision: RestDecision): string {
63
+ return decidersOf(decision).map(personName).join(", ");
64
+ }
65
+
41
66
  /**
42
67
  * Who asked, and when: the person, through their agent when a run asked; and whom the decision
43
- * is for, where that is someone else (ADR-0042).
68
+ * is for, where that is not the asker alone (ADR-0042, ADR-0057).
44
69
  */
45
70
  function Asked(props: { readonly decision: RestDecision; readonly day?: boolean }) {
46
71
  const words = useWords();
47
72
  const { decision } = props;
73
+ const deciders = decidersOf(decision);
74
+ const addressed = deciders.some((person) => person.id !== decision.raisedBy.id);
48
75
  return (
49
76
  <>
50
77
  <PersonChip person={decision.raisedBy} />{" "}
@@ -52,18 +79,17 @@ function Asked(props: { readonly decision: RestDecision; readonly day?: boolean
52
79
  ? words.decisions.asked
53
80
  : words.decisions.askedThrough(agentLabel(decision.run.agent))}{" "}
54
81
  <Time iso={decision.createdAt} precision={props.day === true ? "day" : "minute"} />
55
- {decision.decider.id !== decision.raisedBy.id && (
82
+ {addressed && (
56
83
  <span className="sc-decision-for">
57
84
  <span className="sc-visually-hidden">
58
- {words.decisions.peopleLabel(
59
- personName(decision.raisedBy),
60
- personName(decision.decider),
61
- )}
85
+ {words.decisions.peopleLabel(personName(decision.raisedBy), deciderNames(decision))}
62
86
  </span>
63
87
  <span className="sc-decision-arrow" aria-hidden="true">
64
88
  →
65
89
  </span>{" "}
66
- <PersonChip person={decision.decider} />
90
+ {deciders.map((person) => (
91
+ <PersonChip key={person.id} person={person} />
92
+ ))}
67
93
  </span>
68
94
  )}
69
95
  </>
@@ -228,7 +254,20 @@ function Outcome(props: {
228
254
  {decision.outcome === null ? (
229
255
  <p className="sc-muted">{words.decisions.nothingWritten}</p>
230
256
  ) : (
231
- <Markdown source={decision.outcome} docHref={props.docHref} />
257
+ <>
258
+ <Markdown source={decision.outcome} docHref={props.docHref} />
259
+ {decision.outcomeBy != null && decision.outcomeAt != null && (
260
+ <p className="sc-decision-meta">
261
+ <PersonChip person={decision.outcomeBy} />{" "}
262
+ {decision.outcomeAgent == null
263
+ ? words.decisions.wroteWhatFollowed
264
+ : words.decisions.wroteWhatFollowedThrough(
265
+ agentLabel(decision.outcomeAgent),
266
+ )}{" "}
267
+ <Time iso={decision.outcomeAt} />
268
+ </p>
269
+ )}
270
+ </>
232
271
  )}
233
272
  {props.onUpdate !== undefined && (
234
273
  <Button
@@ -255,6 +294,20 @@ export function DecisionCard(props: DecisionCardProps) {
255
294
  const words = useWords();
256
295
  const { decision, task } = props;
257
296
  const answered = decision.answer !== null;
297
+ // The files put on the decision (ADR-0055): a picture the context names by its file name is
298
+ // shown where the text puts it; the rest follow the context, pictures in place and
299
+ // everything else as a link.
300
+ const files = decision.files ?? [];
301
+ const pictures = usePicture();
302
+ const hrefOf = (file: RestDecisionFile) => props.fileHref?.(file.id);
303
+ const named = (source: string): string | undefined => {
304
+ const name = namedFile(source);
305
+ const file = files.find(
306
+ (candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
307
+ );
308
+ return file === undefined ? undefined : hrefOf(file);
309
+ };
310
+ const following = files.filter((file) => !placed(decision.body, file.file.name));
258
311
 
259
312
  return (
260
313
  <article id={decision.id} className={cx("sc-decision", answered && "sc-decision-answered")}>
@@ -289,7 +342,53 @@ export function DecisionCard(props: DecisionCardProps) {
289
342
  {decision.body.length > 0 && (
290
343
  <section className="sc-decision-section" aria-label={words.decisions.context}>
291
344
  <h4 className="sc-decision-label">{words.decisions.context}</h4>
292
- <Markdown source={decision.body} docHref={props.docHref} />
345
+ <Markdown
346
+ source={decision.body}
347
+ docHref={props.docHref}
348
+ fileSrc={named}
349
+ onOpenPicture={pictures.open}
350
+ />
351
+ </section>
352
+ )}
353
+ {following.length > 0 && (
354
+ <section className="sc-decision-section" aria-label={words.decisions.files}>
355
+ <h4 className="sc-decision-label">{words.decisions.files}</h4>
356
+ <ul className="sc-decision-files">
357
+ {following.map((attached) => {
358
+ const href = hrefOf(attached);
359
+ const { file } = attached;
360
+ if (href !== undefined && isPicture(file.contentType)) {
361
+ return (
362
+ <li key={attached.id} className="sc-decision-picture">
363
+ <PictureThumb
364
+ picture={{
365
+ src: href,
366
+ alt: words.decisions.picture(file.name),
367
+ name: file.name,
368
+ size: file.size,
369
+ }}
370
+ onOpen={pictures.open}
371
+ />
372
+ <span className="sc-muted">
373
+ {file.name}, {formatBytes(file.size)}
374
+ </span>
375
+ </li>
376
+ );
377
+ }
378
+ return (
379
+ <li key={attached.id}>
380
+ {href === undefined ? (
381
+ <span>{file.name}</span>
382
+ ) : (
383
+ <a href={href} rel="nofollow ugc">
384
+ {file.name}
385
+ </a>
386
+ )}{" "}
387
+ <span className="sc-muted">{formatBytes(file.size)}</span>
388
+ </li>
389
+ );
390
+ })}
391
+ </ul>
293
392
  </section>
294
393
  )}
295
394
  {decision.answer !== null ? (
@@ -303,14 +402,12 @@ export function DecisionCard(props: DecisionCardProps) {
303
402
  </div>
304
403
  )}
305
404
  <p className="sc-decision-meta">
306
- <PersonChip person={decision.answer.by} /> {words.decisions.answeredBy}{" "}
405
+ <PersonChip person={decision.answer.by} /> {answeredWord(words, decision.answer.agent)}{" "}
307
406
  <Time iso={decision.answer.at} />
308
407
  </p>
309
408
  </div>
310
409
  ) : props.onAnswer === undefined ? (
311
- <p className="sc-decision-waiting">
312
- {words.decisions.waitingFor(personName(decision.decider))}
313
- </p>
410
+ <p className="sc-decision-waiting">{words.decisions.waitingFor(deciderNames(decision))}</p>
314
411
  ) : (
315
412
  <AnswerForm decision={decision} onAnswer={props.onAnswer} busy={props.busy} />
316
413
  )}
@@ -322,6 +419,7 @@ export function DecisionCard(props: DecisionCardProps) {
322
419
  busy={props.busy}
323
420
  />
324
421
  )}
422
+ <Lightbox picture={pictures.shown} onClose={pictures.close} />
325
423
  </article>
326
424
  );
327
425
  }
@@ -332,6 +430,8 @@ export interface DecisionListProps {
332
430
  readonly tasks: ReadonlyMap<string, RestTask>;
333
431
  readonly taskHref?: ((task: RestTask) => string) | undefined;
334
432
  readonly docHref?: ((path: string) => string) | undefined;
433
+ /** Where a file put on a decision is read, by its id (ADR-0055). */
434
+ readonly fileHref?: ((fileId: string) => string) | undefined;
335
435
  /** Where each decision's own page is. */
336
436
  readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
337
437
  /** The page of the record shown, from 1, and where each page is (ADR-0010); left out, the record turns its pages in place. */
@@ -406,7 +506,7 @@ function RecordRow(props: {
406
506
  </div>
407
507
  )}
408
508
  <p className="sc-decision-record-meta">
409
- <PersonChip person={answer.by} /> {words.decisions.answeredBy}{" "}
509
+ <PersonChip person={answer.by} /> {answeredWord(words, answer.agent)}{" "}
410
510
  <Time iso={answer.at} precision="day" />
411
511
  </p>
412
512
  </div>
@@ -465,6 +565,7 @@ export function DecisionList(props: DecisionListProps) {
465
565
  task={decision.taskId === null ? undefined : props.tasks.get(decision.taskId)}
466
566
  taskHref={props.taskHref}
467
567
  docHref={props.docHref}
568
+ fileHref={props.fileHref}
468
569
  href={props.decisionHref?.(decision)}
469
570
  onAnswer={props.onAnswer}
470
571
  onUpdate={props.onUpdate}
@@ -63,6 +63,10 @@ function sentenceOf(event: RestEvent, words: Messages): string {
63
63
  return feed.event.personDeactivated(login);
64
64
  case "person.reactivated":
65
65
  return feed.event.personReactivated(login);
66
+ case "person.manager_changed":
67
+ return data.manager === null || data.manager === undefined
68
+ ? feed.event.managerCleared(login)
69
+ : feed.event.managerChanged(login, data.manager);
66
70
  case "workspace.updated":
67
71
  return feed.event.workspaceUpdated(fields);
68
72
  case "project.created":
@@ -136,7 +140,9 @@ function sentenceOf(event: RestEvent, words: Messages): string {
136
140
  case "plugin.disabled":
137
141
  return feed.event.pluginDisabled(plugin);
138
142
  case "plugin.configured":
139
- return feed.event.pluginConfigured(fields, plugin);
143
+ return data.capabilities === undefined
144
+ ? feed.event.pluginConfigured(fields, plugin)
145
+ : feed.event.pluginConsented(plugin);
140
146
  case "plugin.granted":
141
147
  return feed.event.pluginGranted(data.role ?? feed.role.other, grantee, plugin);
142
148
  case "plugin.revoked":
@@ -1,5 +1,6 @@
1
1
  import { type FormEvent, type ReactNode, useState } from "react";
2
2
  import {
3
+ agentLabel,
3
4
  GOAL_STATES,
4
5
  type GoalState,
5
6
  MAX_BODY_LENGTH,
@@ -355,7 +356,9 @@ export function GoalView(props: GoalViewProps) {
355
356
  <div>
356
357
  <dt>{roadmap.setBy}</dt>
357
358
  <dd>
358
- <PersonChip person={goal.createdBy} /> <Time iso={goal.createdAt} />
359
+ <PersonChip person={goal.createdBy} />{" "}
360
+ {goal.agent != null && <>{roadmap.setThrough(agentLabel(goal.agent))} </>}
361
+ <Time iso={goal.createdAt} />
359
362
  </dd>
360
363
  </div>
361
364
  {props.editHref !== undefined && (