@skillcdn/console 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +3 -3
  2. package/dist/api.d.ts +3 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -3
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +162 -15
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +16 -3
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +68 -19
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +26 -5
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +28 -1
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +12 -2
  19. package/dist/client.js.map +1 -1
  20. package/dist/compat.js +1 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +46 -15
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/decision-form.d.ts +0 -3
  25. package/dist/components/decision-form.d.ts.map +1 -1
  26. package/dist/components/decision-form.js +3 -15
  27. package/dist/components/decision-form.js.map +1 -1
  28. package/dist/components/event-feed.d.ts.map +1 -1
  29. package/dist/components/event-feed.js +30 -10
  30. package/dist/components/event-feed.js.map +1 -1
  31. package/dist/components/my-decisions.d.ts +20 -3
  32. package/dist/components/my-decisions.d.ts.map +1 -1
  33. package/dist/components/my-decisions.js +14 -1
  34. package/dist/components/my-decisions.js.map +1 -1
  35. package/dist/components/people.d.ts +43 -1
  36. package/dist/components/people.d.ts.map +1 -1
  37. package/dist/components/people.js +117 -9
  38. package/dist/components/people.js.map +1 -1
  39. package/dist/components/picker.d.ts +8 -0
  40. package/dist/components/picker.d.ts.map +1 -1
  41. package/dist/components/picker.js +2 -2
  42. package/dist/components/picker.js.map +1 -1
  43. package/dist/components/plugin-frame.d.ts +0 -1
  44. package/dist/components/plugin-frame.d.ts.map +1 -1
  45. package/dist/components/plugin-frame.js +22 -1
  46. package/dist/components/plugin-frame.js.map +1 -1
  47. package/dist/components/plugin-settings.d.ts +5 -1
  48. package/dist/components/plugin-settings.d.ts.map +1 -1
  49. package/dist/components/plugin-settings.js +143 -30
  50. package/dist/components/plugin-settings.js.map +1 -1
  51. package/dist/components/projects.d.ts.map +1 -1
  52. package/dist/components/projects.js +3 -1
  53. package/dist/components/projects.js.map +1 -1
  54. package/dist/components/searches.d.ts +11 -0
  55. package/dist/components/searches.d.ts.map +1 -0
  56. package/dist/components/searches.js +30 -0
  57. package/dist/components/searches.js.map +1 -0
  58. package/dist/components/task-form.d.ts.map +1 -1
  59. package/dist/components/task-form.js +6 -5
  60. package/dist/components/task-form.js.map +1 -1
  61. package/dist/components/ui.d.ts +16 -0
  62. package/dist/components/ui.d.ts.map +1 -1
  63. package/dist/components/ui.js +10 -0
  64. package/dist/components/ui.js.map +1 -1
  65. package/dist/console.css +362 -67
  66. package/dist/console.d.ts +2 -1
  67. package/dist/console.d.ts.map +1 -1
  68. package/dist/console.js +90 -14
  69. package/dist/console.js.map +1 -1
  70. package/dist/data.d.ts +14 -1
  71. package/dist/data.d.ts.map +1 -1
  72. package/dist/data.js +36 -1
  73. package/dist/data.js.map +1 -1
  74. package/dist/i18n/en.d.ts +89 -0
  75. package/dist/i18n/en.d.ts.map +1 -1
  76. package/dist/i18n/en.js +87 -2
  77. package/dist/i18n/en.js.map +1 -1
  78. package/dist/i18n/ko.d.ts.map +1 -1
  79. package/dist/i18n/ko.js +87 -3
  80. package/dist/i18n/ko.js.map +1 -1
  81. package/dist/index.d.ts +3 -2
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +3 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/limits.d.ts +6 -0
  86. package/dist/limits.d.ts.map +1 -1
  87. package/dist/limits.js +6 -0
  88. package/dist/limits.js.map +1 -1
  89. package/dist/plugin/args.d.ts +6 -1
  90. package/dist/plugin/args.d.ts.map +1 -1
  91. package/dist/plugin/args.js +16 -11
  92. package/dist/plugin/args.js.map +1 -1
  93. package/dist/plugin/bridge.d.ts +20 -1
  94. package/dist/plugin/bridge.d.ts.map +1 -1
  95. package/dist/plugin/bridge.js +28 -0
  96. package/dist/plugin/bridge.js.map +1 -1
  97. package/dist/plugin/capabilities.d.ts +1 -1
  98. package/dist/plugin/capabilities.d.ts.map +1 -1
  99. package/dist/plugin/capabilities.js +10 -0
  100. package/dist/plugin/capabilities.js.map +1 -1
  101. package/dist/plugin/context.d.ts +158 -13
  102. package/dist/plugin/context.d.ts.map +1 -1
  103. package/dist/plugin/define.d.ts +13 -1
  104. package/dist/plugin/define.d.ts.map +1 -1
  105. package/dist/plugin/define.js +17 -1
  106. package/dist/plugin/define.js.map +1 -1
  107. package/dist/plugin/limits.d.ts +4 -0
  108. package/dist/plugin/limits.d.ts.map +1 -1
  109. package/dist/plugin/limits.js +4 -0
  110. package/dist/plugin/limits.js.map +1 -1
  111. package/dist/plugin/manifest.d.ts +15 -0
  112. package/dist/plugin/manifest.d.ts.map +1 -1
  113. package/dist/plugin/manifest.js +61 -4
  114. package/dist/plugin/manifest.js.map +1 -1
  115. package/dist/plugin/rest-schemas.d.ts +44 -0
  116. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  117. package/dist/plugin/rest-schemas.js +55 -3
  118. package/dist/plugin/rest-schemas.js.map +1 -1
  119. package/dist/plugin/testing.d.ts +15 -1
  120. package/dist/plugin/testing.d.ts.map +1 -1
  121. package/dist/plugin/testing.js +40 -4
  122. package/dist/plugin/testing.js.map +1 -1
  123. package/dist/plugin/web.d.ts +6 -0
  124. package/dist/plugin/web.d.ts.map +1 -1
  125. package/dist/plugin/web.js +10 -1
  126. package/dist/plugin/web.js.map +1 -1
  127. package/dist/plugin.d.ts +1 -1
  128. package/dist/plugin.d.ts.map +1 -1
  129. package/dist/plugin.js +1 -1
  130. package/dist/plugin.js.map +1 -1
  131. package/dist/router.d.ts +19 -0
  132. package/dist/router.d.ts.map +1 -1
  133. package/dist/router.js +18 -0
  134. package/dist/router.js.map +1 -1
  135. package/dist/routes.d.ts +11 -2
  136. package/dist/routes.d.ts.map +1 -1
  137. package/dist/routes.js +14 -1
  138. package/dist/routes.js.map +1 -1
  139. package/dist/schemas.d.ts +367 -0
  140. package/dist/schemas.d.ts.map +1 -1
  141. package/dist/schemas.js +62 -2
  142. package/dist/schemas.js.map +1 -1
  143. package/dist/vocabulary.d.ts +1 -1
  144. package/dist/vocabulary.d.ts.map +1 -1
  145. package/dist/vocabulary.js +6 -0
  146. package/dist/vocabulary.js.map +1 -1
  147. package/docs/specs/cli.md +7 -4
  148. package/docs/specs/plugins.md +47 -39
  149. package/docs/specs/rest.md +22 -5
  150. package/package.json +1 -1
  151. package/skills/building-a-plugin/SKILL.md +8 -6
  152. package/skills/working-the-board/SKILL.md +1 -1
  153. package/src/api.ts +17 -0
  154. package/src/cli/cli.ts +200 -12
  155. package/src/cli/format.ts +82 -19
  156. package/src/cli/help.ts +30 -5
  157. package/src/client.ts +70 -0
  158. package/src/compat.ts +1 -1
  159. package/src/components/board.tsx +83 -42
  160. package/src/components/decision-form.tsx +13 -32
  161. package/src/components/event-feed.tsx +36 -9
  162. package/src/components/my-decisions.tsx +50 -5
  163. package/src/components/people.tsx +394 -37
  164. package/src/components/picker.tsx +11 -2
  165. package/src/components/plugin-frame.tsx +23 -0
  166. package/src/components/plugin-settings.tsx +346 -137
  167. package/src/components/projects.tsx +14 -14
  168. package/src/components/searches.ts +47 -0
  169. package/src/components/task-form.tsx +23 -28
  170. package/src/components/ui.tsx +36 -0
  171. package/src/console.tsx +229 -40
  172. package/src/data.ts +53 -1
  173. package/src/i18n/en.ts +98 -2
  174. package/src/i18n/ko.ts +95 -3
  175. package/src/index.ts +11 -0
  176. package/src/limits.ts +6 -0
  177. package/src/plugin/args.ts +16 -11
  178. package/src/plugin/bridge.ts +45 -0
  179. package/src/plugin/capabilities.ts +11 -0
  180. package/src/plugin/context.ts +170 -10
  181. package/src/plugin/define.ts +34 -0
  182. package/src/plugin/limits.ts +4 -0
  183. package/src/plugin/manifest.ts +74 -3
  184. package/src/plugin/rest-schemas.ts +62 -5
  185. package/src/plugin/testing.ts +61 -5
  186. package/src/plugin/web.ts +17 -0
  187. package/src/plugin.ts +1 -0
  188. package/src/router.ts +25 -0
  189. package/src/routes.ts +17 -2
  190. package/src/schemas.ts +81 -2
  191. package/src/styles/console.css +362 -67
  192. package/src/vocabulary.ts +6 -0
@@ -1,4 +1,4 @@
1
- import { type FormEvent, useState } from "react";
1
+ import { type FormEvent, useEffect, useState } from "react";
2
2
  import {
3
3
  hostOfLogin,
4
4
  isLoginAt,
@@ -10,16 +10,33 @@ import {
10
10
  type RestOrganization,
11
11
  type RestPerson,
12
12
  type RestProvider,
13
+ type RestTeam,
14
+ type RestTeamInput,
13
15
  } from "../api.js";
14
16
  import { useWords } from "../i18n/index.js";
15
- import { MAX_LOGIN_LENGTH } from "../limits.js";
17
+ import { MAX_LOGIN_LENGTH, MAX_TEAM_KEY_LENGTH, MAX_TEAM_NAME_LENGTH } from "../limits.js";
16
18
  import { PERSON_ROLES } from "../vocabulary.js";
17
- import { Avatar, Badge, Button, PersonChip, personName, RoleBadge, Time, Workers } from "./ui.js";
19
+ import { Picker } from "./picker.js";
20
+ import { keyOf } from "./projects.js";
21
+ import { listSearch, peopleSearch } from "./searches.js";
22
+ import {
23
+ Avatar,
24
+ Badge,
25
+ Button,
26
+ Chip,
27
+ cx,
28
+ EmptyState,
29
+ PersonChip,
30
+ personName,
31
+ RoleBadge,
32
+ Time,
33
+ } from "./ui.js";
18
34
 
19
35
  // The people of the board, with what each may do and whether they are in (ADR-0022): those in,
20
- // the logins let in that nobody has signed in with yet, and those deactivated. An administrator
21
- // changes roles here, takes people out and back in, and takes a listing away; a member reads.
22
- // Takes its data as props and nothing from the network.
36
+ // the logins let in that nobody has signed in with yet, and those deactivated; and the teams
37
+ // (ADR-0073), a view of their own. An administrator changes a person's facts in place, takes
38
+ // people out and back in, takes a listing away, and keeps the teams; a member reads. Takes its
39
+ // data as props and nothing from the network.
23
40
 
24
41
  export interface PeopleListProps {
25
42
  readonly people: readonly RestPerson[];
@@ -39,8 +56,16 @@ export interface PeopleListProps {
39
56
  readonly onChangeStatus?: ((person: RestPerson, status: PersonStatus) => void) | undefined;
40
57
  /** Called with the manager an administrator chose for the person, or null for none. Left out, managers are read here, not changed. */
41
58
  readonly onChangeManager?: ((person: RestPerson, managerId: string | null) => void) | undefined;
59
+ /** Called with the work email an administrator wrote for the person, or null for none. Left out, an address is read here where the console answers one. */
60
+ readonly onChangeEmail?: ((person: RestPerson, email: string | null) => void) | undefined;
42
61
  /** Called when an administrator takes a listing away. Left out, listings are read here. */
43
62
  readonly onUnlist?: ((listing: RestListing) => void) | undefined;
63
+ /** The teams of the workspace (ADR-0073), so that each person's card names theirs; left out, none are named. */
64
+ readonly teams?: readonly RestTeam[] | undefined;
65
+ /** Called with a team an administrator puts the person in, from the person's card. Left out, a person's teams are read here. */
66
+ readonly onJoinTeam?: ((person: RestPerson, team: RestTeam) => void) | undefined;
67
+ /** Called with a team an administrator takes the person out of, from the person's card. */
68
+ readonly onLeaveTeam?: ((person: RestPerson, team: RestTeam) => void) | undefined;
44
69
  /** Where a person's work is seen; given to an administrator, or an owner of a project. */
45
70
  readonly workHref?: ((person: RestPerson) => string) | undefined;
46
71
  /** Where a person's agents are seen; given to an administrator. */
@@ -48,18 +73,80 @@ export interface PeopleListProps {
48
73
  readonly busy?: boolean | undefined;
49
74
  }
50
75
 
76
+ /** How many of a manager's reports are shown as pictures before the rest are a count. */
77
+ const REPORTS_SHOWN = 5;
78
+
79
+ /**
80
+ * A line an administrator writes in place, quiet until the pointer or the focus is on it: kept
81
+ * while typed, written when the field is left or Enter is pressed, and only where it changed.
82
+ * `normalize` says what is written; what it makes empty is `onEmpty`'s to answer: written as
83
+ * nothing, or the line put back as it was.
84
+ */
85
+ function QuietField(props: {
86
+ readonly value: string;
87
+ /** What the field is, for a screen reader. */
88
+ readonly label: string;
89
+ readonly placeholder?: string | undefined;
90
+ readonly title?: string | undefined;
91
+ readonly type?: "text" | "email";
92
+ readonly maxLength?: number | undefined;
93
+ readonly className?: string | undefined;
94
+ readonly busy: boolean;
95
+ readonly normalize: (typed: string) => string;
96
+ /** Empty, the field writes `null` where this is `clear`, and goes back to the value where it is `keep`. */
97
+ readonly onEmpty: "clear" | "keep";
98
+ readonly onChange: (value: string | null) => void;
99
+ }) {
100
+ const current = props.value;
101
+ const [value, setValue] = useState(current);
102
+ // The field follows what the console answers.
103
+ useEffect(() => setValue(current), [current]);
104
+ const commit = () => {
105
+ const next = props.normalize(value);
106
+ if (next === "" && props.onEmpty === "keep") {
107
+ setValue(current);
108
+ } else if (next !== current) {
109
+ props.onChange(next === "" ? null : next);
110
+ }
111
+ };
112
+ return (
113
+ <input
114
+ type={props.type ?? "text"}
115
+ className={cx("sc-input", "sc-input-quiet", props.className)}
116
+ aria-label={props.label}
117
+ title={props.title}
118
+ placeholder={props.placeholder}
119
+ value={value}
120
+ maxLength={props.maxLength}
121
+ disabled={props.busy}
122
+ onChange={(event) => setValue(event.target.value)}
123
+ onBlur={commit}
124
+ onKeyDown={(event) => {
125
+ if (event.key === "Enter") {
126
+ event.preventDefault();
127
+ commit();
128
+ } else if (event.key === "Escape") {
129
+ setValue(current);
130
+ }
131
+ }}
132
+ />
133
+ );
134
+ }
135
+
51
136
  export function PeopleList(props: PeopleListProps) {
52
137
  const words = useWords();
53
138
  const people = words.people;
54
139
  const active = props.people.filter((person) => person.status === "active");
55
140
  const deactivated = props.people.filter((person) => person.status === "deactivated");
56
141
  const listed = props.listed ?? [];
142
+ const busy = props.busy === true;
57
143
  const mayChange = (person: RestPerson): boolean =>
58
144
  props.onChangeStatus !== undefined &&
59
145
  person.source !== "organization" &&
60
146
  props.me?.id !== person.id;
61
147
  const organization = props.organization ?? undefined;
62
148
  const byId = new Map(props.people.map((person) => [person.id, person]));
149
+ const teamList = props.teams ?? [];
63
150
  /** The person's manager, where they have one that can be named here. */
64
151
  const managerOf = (person: RestPerson): RestPerson | undefined =>
65
152
  person.managerId === null || person.managerId === undefined
@@ -95,7 +182,10 @@ export function PeopleList(props: PeopleListProps) {
95
182
  <ul className="sc-people" aria-label={people.list}>
96
183
  {active.map((person) => {
97
184
  const manager = managerOf(person);
98
- const team = active.filter((other) => other.managerId === person.id);
185
+ const reports = active.filter((other) => other.managerId === person.id);
186
+ const joined = teamList.filter((team) => team.memberIds.includes(person.id));
187
+ const toJoin = teamList.filter((team) => !team.memberIds.includes(person.id));
188
+ const name = personName(person);
99
189
  const hasWays =
100
190
  props.workHref !== undefined || props.agentsHref !== undefined || mayChange(person);
101
191
  return (
@@ -104,7 +194,7 @@ export function PeopleList(props: PeopleListProps) {
104
194
  <Avatar person={person} size="lg" />
105
195
  <div className="sc-person-card-who">
106
196
  <span className="sc-person-card-name">
107
- {personName(person)}
197
+ {name}
108
198
  {props.me?.id === person.id && (
109
199
  <span className="sc-person-you">{words.common.you}</span>
110
200
  )}
@@ -124,9 +214,9 @@ export function PeopleList(props: PeopleListProps) {
124
214
  ) : (
125
215
  <select
126
216
  className="sc-input sc-input-quiet"
127
- aria-label={people.roleOf(personName(person))}
217
+ aria-label={people.roleOf(name)}
128
218
  value={person.role}
129
- disabled={props.busy === true}
219
+ disabled={busy}
130
220
  onChange={(event) =>
131
221
  props.onChangeRole?.(person, event.target.value as PersonRole)
132
222
  }
@@ -150,39 +240,107 @@ export function PeopleList(props: PeopleListProps) {
150
240
  <PersonChip person={manager} />
151
241
  )
152
242
  ) : (
153
- <select
154
- className="sc-input sc-input-quiet"
155
- aria-label={people.managerOf(personName(person))}
156
- value={person.managerId ?? ""}
157
- disabled={props.busy === true}
158
- onChange={(event) =>
159
- props.onChangeManager?.(
160
- person,
161
- event.target.value === "" ? null : event.target.value,
162
- )
163
- }
164
- >
165
- <option value="">{people.nobody}</option>
166
- {props.people
167
- .filter(
243
+ <Picker
244
+ className="sc-picker-quiet"
245
+ value={manager}
246
+ search={peopleSearch(
247
+ props.people.filter(
168
248
  (candidate) =>
169
249
  candidate.id !== person.id &&
170
250
  (candidate.status === "active" || candidate.id === person.managerId),
171
- )
172
- .map((candidate) => (
173
- <option key={candidate.id} value={candidate.id}>
174
- {personName(candidate)}
175
- </option>
176
- ))}
177
- </select>
251
+ ),
252
+ )}
253
+ keyOf={(candidate) => candidate.id}
254
+ render={(candidate) => <PersonChip person={candidate} />}
255
+ label={people.managerOf(name)}
256
+ placeholder={people.nobody}
257
+ disabled={busy}
258
+ onChange={(candidate) =>
259
+ props.onChangeManager?.(person, candidate?.id ?? null)
260
+ }
261
+ />
178
262
  )}
179
263
  </dd>
180
264
  </div>
181
- {team.length > 0 && (
265
+ {(props.onChangeEmail !== undefined || typeof person.email === "string") && (
266
+ <div className="sc-person-fact">
267
+ <dt>{people.email}</dt>
268
+ <dd>
269
+ {props.onChangeEmail === undefined ? (
270
+ <span className="sc-person-email">{person.email}</span>
271
+ ) : (
272
+ <QuietField
273
+ type="email"
274
+ value={person.email ?? ""}
275
+ label={people.emailOf(name)}
276
+ title={people.emailHint}
277
+ placeholder={people.nobody}
278
+ busy={busy}
279
+ normalize={(typed) => typed.trim().toLowerCase()}
280
+ onEmpty="clear"
281
+ onChange={(email) => props.onChangeEmail?.(person, email)}
282
+ />
283
+ )}
284
+ </dd>
285
+ </div>
286
+ )}
287
+ {reports.length > 0 && (
182
288
  <div className="sc-person-fact">
183
289
  <dt>{people.team}</dt>
184
290
  <dd>
185
- <Workers workers={team} />
291
+ <span
292
+ className="sc-person-reports"
293
+ title={reports.map(personName).join(", ")}
294
+ >
295
+ <span className="sc-workers">
296
+ {reports.slice(0, REPORTS_SHOWN).map((report) => (
297
+ <Avatar key={report.id} person={report} size="sm" />
298
+ ))}
299
+ </span>
300
+ <span className="sc-muted">{people.howMany(reports.length)}</span>
301
+ </span>
302
+ </dd>
303
+ </div>
304
+ )}
305
+ {(joined.length > 0 || (props.onJoinTeam !== undefined && toJoin.length > 0)) && (
306
+ <div className="sc-person-fact">
307
+ <dt>{people.teams}</dt>
308
+ <dd>
309
+ <div className="sc-chips">
310
+ {joined.map((team) => (
311
+ <Chip
312
+ key={team.id}
313
+ onRemove={
314
+ props.onLeaveTeam === undefined
315
+ ? undefined
316
+ : () => props.onLeaveTeam?.(person, team)
317
+ }
318
+ removeLabel={people.leaveTeam(name, team.name)}
319
+ disabled={busy}
320
+ >
321
+ {team.name}
322
+ </Chip>
323
+ ))}
324
+ {props.onJoinTeam !== undefined && toJoin.length > 0 && (
325
+ <Picker<RestTeam>
326
+ className="sc-picker-add"
327
+ value={undefined}
328
+ search={listSearch(toJoin, (candidate, typed) =>
329
+ candidate.name.toLowerCase().includes(typed),
330
+ )}
331
+ keyOf={(candidate) => candidate.id}
332
+ render={(candidate) => candidate.name}
333
+ label={people.joinTeamOf(name)}
334
+ placeholder={people.joinTeam}
335
+ disabled={busy}
336
+ onChange={(candidate) => {
337
+ if (candidate !== undefined) {
338
+ props.onJoinTeam?.(person, candidate);
339
+ }
340
+ }}
341
+ />
342
+ )}
343
+ </div>
186
344
  </dd>
187
345
  </div>
188
346
  )}
@@ -209,7 +367,7 @@ export function PeopleList(props: PeopleListProps) {
209
367
  <Button
210
368
  variant="ghost"
211
369
  size="sm"
212
- disabled={props.busy === true}
370
+ disabled={busy}
213
371
  onClick={() => props.onChangeStatus?.(person, "deactivated")}
214
372
  >
215
373
  {people.deactivate}
@@ -239,7 +397,7 @@ export function PeopleList(props: PeopleListProps) {
239
397
  <Button
240
398
  variant="ghost"
241
399
  size="sm"
242
- disabled={props.busy === true}
400
+ disabled={busy}
243
401
  onClick={() => props.onUnlist?.(listing)}
244
402
  >
245
403
  {words.common.remove}
@@ -274,7 +432,7 @@ export function PeopleList(props: PeopleListProps) {
274
432
  <Button
275
433
  variant="ghost"
276
434
  size="sm"
277
- disabled={props.busy === true}
435
+ disabled={busy}
278
436
  onClick={() => props.onChangeStatus?.(person, "active")}
279
437
  >
280
438
  {people.reactivate}
@@ -289,6 +447,205 @@ export function PeopleList(props: PeopleListProps) {
289
447
  );
290
448
  }
291
449
 
450
+ export interface TeamListProps {
451
+ /** The teams of the workspace (ADR-0073), by name, each with the members the person may see. */
452
+ readonly teams: readonly RestTeam[];
453
+ /** The people the page holds: whom a team's members are named from, and whom an administrator adds from, the active among them. */
454
+ readonly people: readonly RestPerson[];
455
+ /** Called with the name an administrator wrote for a team. Left out, names are read here. */
456
+ readonly onRename?: ((team: RestTeam, name: string) => void) | undefined;
457
+ /** Called when an administrator deletes a team, after asking where it is the page's to ask. Left out, teams are read here. */
458
+ readonly onRemove?: ((team: RestTeam) => void) | undefined;
459
+ /** Called with a person an administrator puts in a team. Left out, the members are read here. */
460
+ readonly onAddMember?: ((team: RestTeam, person: RestPerson) => void) | undefined;
461
+ /** Called with a member an administrator takes out of a team. */
462
+ readonly onRemoveMember?: ((team: RestTeam, person: RestPerson) => void) | undefined;
463
+ readonly busy?: boolean | undefined;
464
+ }
465
+
466
+ /**
467
+ * The teams of the workspace (ADR-0073), a card each: the name, how many are in it, and who, a
468
+ * chip each. For an administrator the name is written anew in its place, a member is taken out
469
+ * from their chip and a person is added from the end of the row, searched among those in; a team
470
+ * is deleted from its card. Its people stay; the roles granted to it go with it.
471
+ */
472
+ export function TeamList(props: TeamListProps) {
473
+ const words = useWords();
474
+ const teams = words.teams;
475
+ const busy = props.busy === true;
476
+ const byId = new Map(props.people.map((person) => [person.id, person]));
477
+ const active = props.people.filter((person) => person.status === "active");
478
+ if (props.teams.length === 0) {
479
+ return <EmptyState title={teams.none.title} body={teams.none.body} />;
480
+ }
481
+ return (
482
+ <ul className="sc-teams" aria-label={teams.list}>
483
+ {props.teams.map((team) => {
484
+ const members = team.memberIds.flatMap((id) => {
485
+ const member = byId.get(id);
486
+ return member === undefined ? [] : [member];
487
+ });
488
+ const candidates = active.filter((person) => !team.memberIds.includes(person.id));
489
+ return (
490
+ <li key={team.id} className="sc-team-card">
491
+ <div className="sc-team-head">
492
+ <div className="sc-team-title">
493
+ {props.onRename === undefined ? (
494
+ <h2 className="sc-team-name">{team.name}</h2>
495
+ ) : (
496
+ <QuietField
497
+ className="sc-team-name"
498
+ value={team.name}
499
+ label={teams.nameOf(team.name)}
500
+ maxLength={MAX_TEAM_NAME_LENGTH}
501
+ busy={busy}
502
+ normalize={(typed) => typed.trim()}
503
+ onEmpty="keep"
504
+ onChange={(name) => {
505
+ if (name !== null) {
506
+ props.onRename?.(team, name);
507
+ }
508
+ }}
509
+ />
510
+ )}
511
+ <span className="sc-team-key" title={teams.keyTitle}>
512
+ {team.key}
513
+ </span>
514
+ </div>
515
+ <Badge kind="count">{teams.members(team.memberIds.length)}</Badge>
516
+ </div>
517
+ <div className="sc-chips sc-team-members">
518
+ {members.map((member) => (
519
+ <Chip
520
+ key={member.id}
521
+ onRemove={
522
+ props.onRemoveMember === undefined
523
+ ? undefined
524
+ : () => props.onRemoveMember?.(team, member)
525
+ }
526
+ removeLabel={teams.removeMember(personName(member), team.name)}
527
+ disabled={busy}
528
+ >
529
+ <PersonChip person={member} />
530
+ </Chip>
531
+ ))}
532
+ {members.length === 0 && props.onAddMember === undefined && (
533
+ <span className="sc-muted">{teams.nobody}</span>
534
+ )}
535
+ {props.onAddMember !== undefined && candidates.length > 0 && (
536
+ <Picker
537
+ className="sc-picker-add"
538
+ value={undefined}
539
+ search={peopleSearch(candidates)}
540
+ keyOf={(candidate) => candidate.id}
541
+ render={(candidate) => <PersonChip person={candidate} />}
542
+ label={teams.addMemberTo(team.name)}
543
+ placeholder={teams.addMember}
544
+ disabled={busy}
545
+ onChange={(candidate) => {
546
+ if (candidate !== undefined) {
547
+ props.onAddMember?.(team, candidate);
548
+ }
549
+ }}
550
+ />
551
+ )}
552
+ </div>
553
+ {props.onRemove !== undefined && (
554
+ <div className="sc-team-ways">
555
+ <Button
556
+ variant="ghost"
557
+ size="sm"
558
+ disabled={busy}
559
+ onClick={() => props.onRemove?.(team)}
560
+ >
561
+ {teams.remove}
562
+ </Button>
563
+ </div>
564
+ )}
565
+ </li>
566
+ );
567
+ })}
568
+ </ul>
569
+ );
570
+ }
571
+
572
+ export interface TeamFormProps {
573
+ readonly busy?: boolean | undefined;
574
+ /** Called with the key and the name written, trimmed. */
575
+ readonly onSubmit: (input: RestTeamInput) => void;
576
+ readonly onCancel?: (() => void) | undefined;
577
+ }
578
+
579
+ /** A key as the pages accept one: a project key's shape (ADR-0074). */
580
+ const TEAM_KEY = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
581
+
582
+ /**
583
+ * Where an administrator makes a team: its name, one in the workspace, and its key, taken from
584
+ * the name as it is typed until the key is typed itself, and never changed after; its people are
585
+ * added on its card.
586
+ */
587
+ export function TeamForm(props: TeamFormProps) {
588
+ const words = useWords();
589
+ const form = words.teams.form;
590
+ const [name, setName] = useState("");
591
+ const [key, setKey] = useState("");
592
+ const [keyTouched, setKeyTouched] = useState(false);
593
+ const valid = name.trim().length > 0 && TEAM_KEY.test(key.trim()) && key.trim() !== "new";
594
+ const submit = (event: FormEvent) => {
595
+ event.preventDefault();
596
+ if (valid) {
597
+ props.onSubmit({ key: key.trim(), name: name.trim() });
598
+ }
599
+ };
600
+ return (
601
+ <form className="sc-form sc-team-form" aria-label={words.teams.newAria} onSubmit={submit}>
602
+ <div className="sc-field-row">
603
+ <label className="sc-field">
604
+ <span className="sc-field-label">{form.name}</span>
605
+ <input
606
+ className="sc-input"
607
+ value={name}
608
+ maxLength={MAX_TEAM_NAME_LENGTH}
609
+ autoComplete="off"
610
+ onChange={(event) => {
611
+ setName(event.target.value);
612
+ if (!keyTouched) {
613
+ setKey(keyOf(event.target.value).slice(0, MAX_TEAM_KEY_LENGTH));
614
+ }
615
+ }}
616
+ />
617
+ </label>
618
+ <label className="sc-field">
619
+ <span className="sc-field-label">{form.key}</span>
620
+ <input
621
+ className="sc-input"
622
+ value={key}
623
+ maxLength={MAX_TEAM_KEY_LENGTH}
624
+ pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
625
+ autoComplete="off"
626
+ title={form.keyHint}
627
+ onChange={(event) => {
628
+ setKeyTouched(true);
629
+ setKey(event.target.value);
630
+ }}
631
+ />
632
+ </label>
633
+ <div className="sc-form-actions">
634
+ <Button type="submit" variant="primary" disabled={props.busy === true || !valid}>
635
+ {form.submit}
636
+ </Button>
637
+ {props.onCancel !== undefined && (
638
+ <Button type="button" variant="ghost" onClick={props.onCancel}>
639
+ {words.common.cancel}
640
+ </Button>
641
+ )}
642
+ </div>
643
+ </div>
644
+ <p className="sc-field-hint">{form.hint}</p>
645
+ </form>
646
+ );
647
+ }
648
+
292
649
  export interface ListingFormProps {
293
650
  /** The providers people sign in through here: what a listing may name. */
294
651
  readonly providers: readonly RestProvider[];
@@ -62,10 +62,18 @@ export interface PickerProps<T> {
62
62
  readonly onChange: (item: T | undefined) => void;
63
63
  /** The field's name, for the input and the list. */
64
64
  readonly label: string;
65
+ /** The input's id, where a label points at it. */
66
+ readonly id?: string | undefined;
65
67
  readonly placeholder: string;
66
68
  readonly disabled?: boolean | undefined;
67
69
  /** What the search may answer with but cannot be chosen: a task, as its own parent. */
68
70
  readonly exclude?: ((item: T) => boolean) | undefined;
71
+ /**
72
+ * A class of the picker's own beside `sc-picker`: `sc-picker-quiet`, a picker that reads as
73
+ * what it holds until the pointer or the focus is on it, a fact changed in place; or
74
+ * `sc-picker-add`, the way to add one more at the end of a row of chips, as low as a chip.
75
+ */
76
+ readonly className?: string | undefined;
69
77
  }
70
78
 
71
79
  export function Picker<T>(props: PickerProps<T>) {
@@ -204,7 +212,7 @@ export function Picker<T>(props: PickerProps<T>) {
204
212
 
205
213
  if (props.value !== undefined || props.held !== undefined) {
206
214
  return (
207
- <div className="sc-picker sc-picker-chosen">
215
+ <div className={cx("sc-picker", "sc-picker-chosen", props.className)}>
208
216
  <span className="sc-picker-value">
209
217
  {props.value === undefined ? props.held : props.render(props.value)}
210
218
  </span>
@@ -222,9 +230,10 @@ export function Picker<T>(props: PickerProps<T>) {
222
230
  }
223
231
 
224
232
  return (
225
- <div className="sc-picker">
233
+ <div className={cx("sc-picker", props.className)}>
226
234
  <input
227
235
  ref={field}
236
+ id={props.id}
228
237
  className="sc-input"
229
238
  type="text"
230
239
  role="combobox"
@@ -10,6 +10,7 @@ import {
10
10
  type BridgeToFrame,
11
11
  isBridgePath,
12
12
  isBridgeToPage,
13
+ isOpenableFrom,
13
14
  PLUGIN_BRIDGE_VERSION,
14
15
  } from "../plugin/bridge.js";
15
16
  import type { Navigation } from "../router.js";
@@ -160,6 +161,20 @@ export async function performBridgeCall(
160
161
  }
161
162
 
162
163
  /** The console's computed design tokens, as the page's root has them: what a frame is told to look like. */
164
+ /**
165
+ * Whether a person pressed something a moment ago, as the browser keeps it: a link in a frame
166
+ * gives the page this too, which is what lets the page open a tab for it; a browser without the
167
+ * API is left to decide for itself, as it does for any page.
168
+ */
169
+ function recentlyPressed(): boolean {
170
+ if (typeof navigator === "undefined") {
171
+ return false;
172
+ }
173
+ const activation = (navigator as Navigator & { userActivation?: { isActive: boolean } })
174
+ .userActivation;
175
+ return activation === undefined || activation.isActive;
176
+ }
177
+
163
178
  export function readTheme(): Record<string, string> {
164
179
  if (typeof document === "undefined" || typeof getComputedStyle === "undefined") {
165
180
  return {};
@@ -321,6 +336,14 @@ export function PluginFrame(props: PluginFrameProps) {
321
336
  anchor.click();
322
337
  return;
323
338
  }
339
+ case "open":
340
+ // A link a person pressed in the frame (ADR-0067): a web or a mail address, opened in a
341
+ // new tab with no opener and no referrer, and only while the press is recent, which a
342
+ // press in the frame gives this page too.
343
+ if (isOpenableFrom(message.url, window.location.origin) && recentlyPressed()) {
344
+ window.open(message.url, "_blank", "noopener,noreferrer");
345
+ }
346
+ return;
324
347
  case "subscribe":
325
348
  subscribed.current = new Set(message.kinds);
326
349
  return;