@skillcdn/console 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/README.md +6 -6
  2. package/dist/api.d.ts +4 -4
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +4 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +255 -33
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +20 -5
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +98 -26
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +39 -15
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/plugin-check.d.ts +5 -1
  17. package/dist/cli/plugin-check.d.ts.map +1 -1
  18. package/dist/cli/plugin-check.js +23 -4
  19. package/dist/cli/plugin-check.js.map +1 -1
  20. package/dist/cli/plugin-cli.d.ts.map +1 -1
  21. package/dist/cli/plugin-cli.js +10 -1
  22. package/dist/cli/plugin-cli.js.map +1 -1
  23. package/dist/cli/plugin-init.d.ts +2 -0
  24. package/dist/cli/plugin-init.d.ts.map +1 -1
  25. package/dist/cli/plugin-init.js +6 -0
  26. package/dist/cli/plugin-init.js.map +1 -1
  27. package/dist/client.d.ts +32 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +14 -2
  30. package/dist/client.js.map +1 -1
  31. package/dist/compat.js +1 -1
  32. package/dist/components/board.d.ts +8 -0
  33. package/dist/components/board.d.ts.map +1 -1
  34. package/dist/components/board.js +49 -18
  35. package/dist/components/board.js.map +1 -1
  36. package/dist/components/decision-form.d.ts +6 -3
  37. package/dist/components/decision-form.d.ts.map +1 -1
  38. package/dist/components/decision-form.js +24 -18
  39. package/dist/components/decision-form.js.map +1 -1
  40. package/dist/components/decision-list.d.ts +5 -1
  41. package/dist/components/decision-list.d.ts.map +1 -1
  42. package/dist/components/decision-list.js +25 -5
  43. package/dist/components/decision-list.js.map +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +34 -8
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/goals.d.ts.map +1 -1
  48. package/dist/components/goals.js +2 -2
  49. package/dist/components/goals.js.map +1 -1
  50. package/dist/components/home.d.ts +35 -0
  51. package/dist/components/home.d.ts.map +1 -0
  52. package/dist/components/home.js +326 -0
  53. package/dist/components/home.js.map +1 -0
  54. package/dist/components/my-decisions.d.ts +20 -3
  55. package/dist/components/my-decisions.d.ts.map +1 -1
  56. package/dist/components/my-decisions.js +14 -1
  57. package/dist/components/my-decisions.js.map +1 -1
  58. package/dist/components/pack-connect.d.ts.map +1 -1
  59. package/dist/components/pack-connect.js +5 -4
  60. package/dist/components/pack-connect.js.map +1 -1
  61. package/dist/components/people.d.ts +45 -1
  62. package/dist/components/people.d.ts.map +1 -1
  63. package/dist/components/people.js +125 -4
  64. package/dist/components/people.js.map +1 -1
  65. package/dist/components/picker.d.ts +8 -0
  66. package/dist/components/picker.d.ts.map +1 -1
  67. package/dist/components/picker.js +2 -2
  68. package/dist/components/picker.js.map +1 -1
  69. package/dist/components/plugin-frame.d.ts +3 -13
  70. package/dist/components/plugin-frame.d.ts.map +1 -1
  71. package/dist/components/plugin-frame.js +75 -21
  72. package/dist/components/plugin-frame.js.map +1 -1
  73. package/dist/components/plugin-settings.d.ts +5 -1
  74. package/dist/components/plugin-settings.d.ts.map +1 -1
  75. package/dist/components/plugin-settings.js +283 -45
  76. package/dist/components/plugin-settings.js.map +1 -1
  77. package/dist/components/projects.d.ts +0 -8
  78. package/dist/components/projects.d.ts.map +1 -1
  79. package/dist/components/projects.js +4 -14
  80. package/dist/components/projects.js.map +1 -1
  81. package/dist/components/searches.d.ts +11 -0
  82. package/dist/components/searches.d.ts.map +1 -0
  83. package/dist/components/searches.js +30 -0
  84. package/dist/components/searches.js.map +1 -0
  85. package/dist/components/task-form.d.ts.map +1 -1
  86. package/dist/components/task-form.js +6 -5
  87. package/dist/components/task-form.js.map +1 -1
  88. package/dist/components/task-view.d.ts.map +1 -1
  89. package/dist/components/task-view.js +2 -1
  90. package/dist/components/task-view.js.map +1 -1
  91. package/dist/components/ui.d.ts +16 -0
  92. package/dist/components/ui.d.ts.map +1 -1
  93. package/dist/components/ui.js +15 -2
  94. package/dist/components/ui.js.map +1 -1
  95. package/dist/connect-page.d.ts.map +1 -1
  96. package/dist/connect-page.js +5 -6
  97. package/dist/connect-page.js.map +1 -1
  98. package/dist/console.css +1123 -258
  99. package/dist/console.d.ts +12 -4
  100. package/dist/console.d.ts.map +1 -1
  101. package/dist/console.js +123 -27
  102. package/dist/console.js.map +1 -1
  103. package/dist/data.d.ts +18 -3
  104. package/dist/data.d.ts.map +1 -1
  105. package/dist/data.js +41 -1
  106. package/dist/data.js.map +1 -1
  107. package/dist/docs-page.d.ts.map +1 -1
  108. package/dist/docs-page.js +6 -5
  109. package/dist/docs-page.js.map +1 -1
  110. package/dist/home-tiles.d.ts +92 -0
  111. package/dist/home-tiles.d.ts.map +1 -0
  112. package/dist/home-tiles.js +294 -0
  113. package/dist/home-tiles.js.map +1 -0
  114. package/dist/i18n/en.d.ts +264 -1
  115. package/dist/i18n/en.d.ts.map +1 -1
  116. package/dist/i18n/en.js +260 -1
  117. package/dist/i18n/en.js.map +1 -1
  118. package/dist/i18n/index.d.ts +4 -2
  119. package/dist/i18n/index.d.ts.map +1 -1
  120. package/dist/i18n/index.js +10 -11
  121. package/dist/i18n/index.js.map +1 -1
  122. package/dist/i18n/ko.d.ts.map +1 -1
  123. package/dist/i18n/ko.js +255 -4
  124. package/dist/i18n/ko.js.map +1 -1
  125. package/dist/index.d.ts +7 -5
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +7 -5
  128. package/dist/index.js.map +1 -1
  129. package/dist/limits.d.ts +12 -0
  130. package/dist/limits.d.ts.map +1 -1
  131. package/dist/limits.js +12 -0
  132. package/dist/limits.js.map +1 -1
  133. package/dist/plugin/args.d.ts +6 -1
  134. package/dist/plugin/args.d.ts.map +1 -1
  135. package/dist/plugin/args.js +16 -11
  136. package/dist/plugin/args.js.map +1 -1
  137. package/dist/plugin/bridge.d.ts +63 -3
  138. package/dist/plugin/bridge.d.ts.map +1 -1
  139. package/dist/plugin/bridge.js +54 -0
  140. package/dist/plugin/bridge.js.map +1 -1
  141. package/dist/plugin/capabilities.d.ts +7 -1
  142. package/dist/plugin/capabilities.d.ts.map +1 -1
  143. package/dist/plugin/capabilities.js +24 -1
  144. package/dist/plugin/capabilities.js.map +1 -1
  145. package/dist/plugin/context.d.ts +174 -12
  146. package/dist/plugin/context.d.ts.map +1 -1
  147. package/dist/plugin/define.d.ts +14 -1
  148. package/dist/plugin/define.d.ts.map +1 -1
  149. package/dist/plugin/define.js +18 -2
  150. package/dist/plugin/define.js.map +1 -1
  151. package/dist/plugin/limits.d.ts +9 -2
  152. package/dist/plugin/limits.d.ts.map +1 -1
  153. package/dist/plugin/limits.js +9 -2
  154. package/dist/plugin/limits.js.map +1 -1
  155. package/dist/plugin/manifest.d.ts +98 -6
  156. package/dist/plugin/manifest.d.ts.map +1 -1
  157. package/dist/plugin/manifest.js +171 -10
  158. package/dist/plugin/manifest.js.map +1 -1
  159. package/dist/plugin/rest-schemas.d.ts +120 -0
  160. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  161. package/dist/plugin/rest-schemas.js +78 -5
  162. package/dist/plugin/rest-schemas.js.map +1 -1
  163. package/dist/plugin/sandbox.d.ts +4 -3
  164. package/dist/plugin/sandbox.d.ts.map +1 -1
  165. package/dist/plugin/sandbox.js +33 -6
  166. package/dist/plugin/sandbox.js.map +1 -1
  167. package/dist/plugin/semver.d.ts +26 -0
  168. package/dist/plugin/semver.d.ts.map +1 -0
  169. package/dist/plugin/semver.js +76 -0
  170. package/dist/plugin/semver.js.map +1 -0
  171. package/dist/plugin/testing.d.ts +15 -1
  172. package/dist/plugin/testing.d.ts.map +1 -1
  173. package/dist/plugin/testing.js +42 -5
  174. package/dist/plugin/testing.js.map +1 -1
  175. package/dist/plugin/web.d.ts +16 -1
  176. package/dist/plugin/web.d.ts.map +1 -1
  177. package/dist/plugin/web.js +44 -1
  178. package/dist/plugin/web.js.map +1 -1
  179. package/dist/plugin.d.ts +3 -2
  180. package/dist/plugin.d.ts.map +1 -1
  181. package/dist/plugin.js +3 -2
  182. package/dist/plugin.js.map +1 -1
  183. package/dist/router.d.ts +19 -0
  184. package/dist/router.d.ts.map +1 -1
  185. package/dist/router.js +18 -0
  186. package/dist/router.js.map +1 -1
  187. package/dist/routes.d.ts +11 -2
  188. package/dist/routes.d.ts.map +1 -1
  189. package/dist/routes.js +14 -1
  190. package/dist/routes.js.map +1 -1
  191. package/dist/rules.d.ts +2 -2
  192. package/dist/rules.js +3 -3
  193. package/dist/schemas.d.ts +864 -2
  194. package/dist/schemas.d.ts.map +1 -1
  195. package/dist/schemas.js +156 -9
  196. package/dist/schemas.js.map +1 -1
  197. package/dist/use-action.d.ts +8 -2
  198. package/dist/use-action.d.ts.map +1 -1
  199. package/dist/use-action.js +13 -6
  200. package/dist/use-action.js.map +1 -1
  201. package/dist/vocabulary.d.ts +3 -1
  202. package/dist/vocabulary.d.ts.map +1 -1
  203. package/dist/vocabulary.js +9 -0
  204. package/dist/vocabulary.js.map +1 -1
  205. package/docs/specs/cli.md +10 -6
  206. package/docs/specs/plugins.md +83 -62
  207. package/docs/specs/rest.md +34 -16
  208. package/package.json +1 -1
  209. package/skills/building-a-plugin/SKILL.md +15 -12
  210. package/skills/planning-the-board/references/setting-up.md +2 -1
  211. package/skills/working-the-board/SKILL.md +3 -3
  212. package/src/api.ts +23 -0
  213. package/src/cli/cli.ts +323 -30
  214. package/src/cli/format.ts +122 -26
  215. package/src/cli/help.ts +43 -14
  216. package/src/cli/plugin-check.ts +41 -2
  217. package/src/cli/plugin-cli.ts +10 -1
  218. package/src/cli/plugin-init.ts +8 -0
  219. package/src/client.ts +78 -0
  220. package/src/compat.ts +1 -1
  221. package/src/components/board.tsx +86 -45
  222. package/src/components/decision-form.tsx +73 -36
  223. package/src/components/decision-list.tsx +45 -13
  224. package/src/components/event-feed.tsx +40 -7
  225. package/src/components/goals.tsx +4 -1
  226. package/src/components/home.tsx +615 -0
  227. package/src/components/my-decisions.tsx +50 -5
  228. package/src/components/pack-connect.tsx +5 -4
  229. package/src/components/people.tsx +500 -61
  230. package/src/components/picker.tsx +11 -2
  231. package/src/components/plugin-frame.tsx +85 -63
  232. package/src/components/plugin-settings.tsx +735 -185
  233. package/src/components/projects.tsx +14 -69
  234. package/src/components/searches.ts +47 -0
  235. package/src/components/task-form.tsx +23 -28
  236. package/src/components/task-view.tsx +7 -0
  237. package/src/components/ui.tsx +42 -2
  238. package/src/connect-page.tsx +5 -6
  239. package/src/console.tsx +303 -64
  240. package/src/data.ts +62 -3
  241. package/src/docs-page.tsx +6 -5
  242. package/src/home-tiles.ts +438 -0
  243. package/src/i18n/en.ts +274 -0
  244. package/src/i18n/index.ts +17 -14
  245. package/src/i18n/ko.ts +267 -4
  246. package/src/index.ts +18 -5
  247. package/src/limits.ts +12 -0
  248. package/src/plugin/args.ts +16 -11
  249. package/src/plugin/bridge.ts +122 -1
  250. package/src/plugin/capabilities.ts +28 -1
  251. package/src/plugin/context.ts +186 -9
  252. package/src/plugin/define.ts +38 -1
  253. package/src/plugin/limits.ts +9 -2
  254. package/src/plugin/manifest.ts +228 -13
  255. package/src/plugin/rest-schemas.ts +89 -6
  256. package/src/plugin/sandbox.ts +36 -8
  257. package/src/plugin/semver.ts +89 -0
  258. package/src/plugin/testing.ts +63 -6
  259. package/src/plugin/web.ts +66 -0
  260. package/src/plugin.ts +19 -0
  261. package/src/router.ts +25 -0
  262. package/src/routes.ts +17 -2
  263. package/src/rules.ts +3 -3
  264. package/src/schemas.ts +197 -10
  265. package/src/styles/console.css +1123 -258
  266. package/src/use-action.ts +16 -7
  267. package/src/vocabulary.ts +10 -0
  268. package/template/plugin/console-plugin.json +3 -1
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import type { PackConnectEnding, RestPackConnect, RestPackConnectStarted } from "../api.js";
3
3
  import { useWords } from "../i18n/index.js";
4
+ import { errorWords } from "../use-action.js";
4
5
  import { Button, Time } from "./ui.js";
5
6
 
6
7
  // A private pack connected with a code a person approves (ADR-0036): an owner, or for the
@@ -97,7 +98,7 @@ export function PackConnect(props: PackConnectProps) {
97
98
  return;
98
99
  }
99
100
  // The console could not be asked this time; it is asked again.
100
- setProblem(failure instanceof Error ? failure.message : String(failure));
101
+ setProblem(errorWords(failure, words.common.somethingWentWrong, words));
101
102
  setShown((current) => (current.kind === "waiting" ? { ...current } : current));
102
103
  });
103
104
  }, Math.max(shown.intervalSeconds, MIN_INTERVAL_SECONDS) * 1000);
@@ -105,7 +106,7 @@ export function PackConnect(props: PackConnectProps) {
105
106
  controller.abort();
106
107
  clearTimeout(timer);
107
108
  };
108
- }, [shown, claim, onConnected]);
109
+ }, [shown, claim, onConnected, words]);
109
110
 
110
111
  const start = () => {
111
112
  setProblem(undefined);
@@ -113,7 +114,7 @@ export function PackConnect(props: PackConnectProps) {
113
114
  .onStart()
114
115
  .then((started) => setShown({ kind: "waiting", ...started }))
115
116
  .catch((failure: unknown) => {
116
- setProblem(failure instanceof Error ? failure.message : String(failure));
117
+ setProblem(errorWords(failure, words.common.somethingWentWrong, words));
117
118
  });
118
119
  };
119
120
  const cancel = () => {
@@ -121,7 +122,7 @@ export function PackConnect(props: PackConnectProps) {
121
122
  .onCancel()
122
123
  .then(() => setShown({ kind: "idle" }))
123
124
  .catch((failure: unknown) => {
124
- setProblem(failure instanceof Error ? failure.message : String(failure));
125
+ setProblem(errorWords(failure, words.common.somethingWentWrong, words));
125
126
  });
126
127
  };
127
128
 
@@ -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 { Badge, Button, PersonChip, personName, RoleBadge, Time } 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[];
@@ -37,8 +54,18 @@ export interface PeopleListProps {
37
54
  * with it.
38
55
  */
39
56
  readonly onChangeStatus?: ((person: RestPerson, status: PersonStatus) => void) | undefined;
57
+ /** Called with the manager an administrator chose for the person, or null for none. Left out, managers are read here, not changed. */
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;
40
61
  /** Called when an administrator takes a listing away. Left out, listings are read here. */
41
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;
42
69
  /** Where a person's work is seen; given to an administrator, or an owner of a project. */
43
70
  readonly workHref?: ((person: RestPerson) => string) | undefined;
44
71
  /** Where a person's agents are seen; given to an administrator. */
@@ -46,17 +73,85 @@ export interface PeopleListProps {
46
73
  readonly busy?: boolean | undefined;
47
74
  }
48
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
+
49
136
  export function PeopleList(props: PeopleListProps) {
50
137
  const words = useWords();
51
138
  const people = words.people;
52
139
  const active = props.people.filter((person) => person.status === "active");
53
140
  const deactivated = props.people.filter((person) => person.status === "deactivated");
54
141
  const listed = props.listed ?? [];
142
+ const busy = props.busy === true;
55
143
  const mayChange = (person: RestPerson): boolean =>
56
144
  props.onChangeStatus !== undefined &&
57
145
  person.source !== "organization" &&
58
146
  props.me?.id !== person.id;
59
147
  const organization = props.organization ?? undefined;
148
+ const byId = new Map(props.people.map((person) => [person.id, person]));
149
+ const teamList = props.teams ?? [];
150
+ /** The person's manager, where they have one that can be named here. */
151
+ const managerOf = (person: RestPerson): RestPerson | undefined =>
152
+ person.managerId === null || person.managerId === undefined
153
+ ? undefined
154
+ : byId.get(person.managerId);
60
155
  /** What let the person in, by its name: the organization, the domain of their login, or an administrator. */
61
156
  const noteOf = (person: RestPerson): string => {
62
157
  if (person.source === "organization") {
@@ -85,59 +180,204 @@ export function PeopleList(props: PeopleListProps) {
85
180
  </p>
86
181
  )}
87
182
  <ul className="sc-people" aria-label={people.list}>
88
- {active.map((person) => (
89
- <li key={person.id} className="sc-person-row">
90
- <PersonChip person={person} size="md" />
91
- {props.me?.id === person.id && (
92
- <span className="sc-person-you">{words.common.you}</span>
93
- )}
94
- <span className="sc-person-name sc-muted">
95
- {person.name !== null && <>{person.login} · </>}
96
- <span className="sc-person-note">{noteOf(person)}</span>
97
- </span>
98
- {props.onChangeRole === undefined ? (
99
- <RoleBadge role={person.role} />
100
- ) : (
101
- <label className="sc-field sc-field-inline">
102
- <span className="sc-visually-hidden">{people.roleOf(personName(person))}</span>
103
- <select
104
- className="sc-input"
105
- value={person.role}
106
- disabled={props.busy === true}
107
- onChange={(event) =>
108
- props.onChangeRole?.(person, event.target.value as PersonRole)
109
- }
110
- >
111
- {PERSON_ROLES.map((role) => (
112
- <option key={role} value={role}>
113
- {words.vocabulary.role[role]}
114
- </option>
115
- ))}
116
- </select>
117
- </label>
118
- )}
119
- {props.workHref !== undefined && (
120
- <a className="sc-button sc-button-ghost sc-button-sm" href={props.workHref(person)}>
121
- {people.work}
122
- </a>
123
- )}
124
- {props.agentsHref !== undefined && (
125
- <a className="sc-button sc-button-ghost sc-button-sm" href={props.agentsHref(person)}>
126
- {people.agents}
127
- </a>
128
- )}
129
- {mayChange(person) && (
130
- <Button
131
- variant="ghost"
132
- size="sm"
133
- disabled={props.busy === true}
134
- onClick={() => props.onChangeStatus?.(person, "deactivated")}
135
- >
136
- {people.deactivate}
137
- </Button>
138
- )}
139
- </li>
140
- ))}
183
+ {active.map((person) => {
184
+ const manager = managerOf(person);
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);
189
+ const hasWays =
190
+ props.workHref !== undefined || props.agentsHref !== undefined || mayChange(person);
191
+ return (
192
+ <li key={person.id} className="sc-person-card">
193
+ <div className="sc-person-card-head">
194
+ <Avatar person={person} size="lg" />
195
+ <div className="sc-person-card-who">
196
+ <span className="sc-person-card-name">
197
+ {name}
198
+ {props.me?.id === person.id && (
199
+ <span className="sc-person-you">{words.common.you}</span>
200
+ )}
201
+ </span>
202
+ <span className="sc-person-card-note">
203
+ {person.name !== null && <>{person.login} · </>}
204
+ {noteOf(person)}
205
+ </span>
206
+ </div>
207
+ </div>
208
+ <dl className="sc-person-facts">
209
+ <div className="sc-person-fact">
210
+ <dt>{people.role}</dt>
211
+ <dd>
212
+ {props.onChangeRole === undefined ? (
213
+ <RoleBadge role={person.role} />
214
+ ) : (
215
+ <select
216
+ className="sc-input sc-input-quiet"
217
+ aria-label={people.roleOf(name)}
218
+ value={person.role}
219
+ disabled={busy}
220
+ onChange={(event) =>
221
+ props.onChangeRole?.(person, event.target.value as PersonRole)
222
+ }
223
+ >
224
+ {PERSON_ROLES.map((role) => (
225
+ <option key={role} value={role}>
226
+ {words.vocabulary.role[role]}
227
+ </option>
228
+ ))}
229
+ </select>
230
+ )}
231
+ </dd>
232
+ </div>
233
+ <div className="sc-person-fact">
234
+ <dt>{people.manager}</dt>
235
+ <dd>
236
+ {props.onChangeManager === undefined ? (
237
+ manager === undefined ? (
238
+ <span className="sc-muted">{people.nobody}</span>
239
+ ) : (
240
+ <PersonChip person={manager} />
241
+ )
242
+ ) : (
243
+ <Picker
244
+ className="sc-picker-quiet"
245
+ value={manager}
246
+ search={peopleSearch(
247
+ props.people.filter(
248
+ (candidate) =>
249
+ candidate.id !== person.id &&
250
+ (candidate.status === "active" || candidate.id === person.managerId),
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
+ />
262
+ )}
263
+ </dd>
264
+ </div>
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 && (
288
+ <div className="sc-person-fact">
289
+ <dt>{people.team}</dt>
290
+ <dd>
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>
344
+ </dd>
345
+ </div>
346
+ )}
347
+ </dl>
348
+ {hasWays && (
349
+ <div className="sc-person-card-ways">
350
+ {props.workHref !== undefined && (
351
+ <a
352
+ className="sc-button sc-button-ghost sc-button-sm"
353
+ href={props.workHref(person)}
354
+ >
355
+ {people.work}
356
+ </a>
357
+ )}
358
+ {props.agentsHref !== undefined && (
359
+ <a
360
+ className="sc-button sc-button-ghost sc-button-sm"
361
+ href={props.agentsHref(person)}
362
+ >
363
+ {people.agents}
364
+ </a>
365
+ )}
366
+ {mayChange(person) && (
367
+ <Button
368
+ variant="ghost"
369
+ size="sm"
370
+ disabled={busy}
371
+ onClick={() => props.onChangeStatus?.(person, "deactivated")}
372
+ >
373
+ {people.deactivate}
374
+ </Button>
375
+ )}
376
+ </div>
377
+ )}
378
+ </li>
379
+ );
380
+ })}
141
381
  </ul>
142
382
  {listed.length > 0 && (
143
383
  <section className="sc-people-section" aria-label={people.notSignedIn}>
@@ -157,7 +397,7 @@ export function PeopleList(props: PeopleListProps) {
157
397
  <Button
158
398
  variant="ghost"
159
399
  size="sm"
160
- disabled={props.busy === true}
400
+ disabled={busy}
161
401
  onClick={() => props.onUnlist?.(listing)}
162
402
  >
163
403
  {words.common.remove}
@@ -192,7 +432,7 @@ export function PeopleList(props: PeopleListProps) {
192
432
  <Button
193
433
  variant="ghost"
194
434
  size="sm"
195
- disabled={props.busy === true}
435
+ disabled={busy}
196
436
  onClick={() => props.onChangeStatus?.(person, "active")}
197
437
  >
198
438
  {people.reactivate}
@@ -207,6 +447,205 @@ export function PeopleList(props: PeopleListProps) {
207
447
  );
208
448
  }
209
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
+
210
649
  export interface ListingFormProps {
211
650
  /** The providers people sign in through here: what a listing may name. */
212
651
  readonly providers: readonly RestProvider[];