@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.
- package/README.md +3 -3
- package/dist/api.d.ts +3 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +3 -3
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +162 -15
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +16 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +68 -19
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +26 -5
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +28 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -2
- package/dist/client.js.map +1 -1
- package/dist/compat.js +1 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +46 -15
- package/dist/components/board.js.map +1 -1
- package/dist/components/decision-form.d.ts +0 -3
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +3 -15
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +30 -10
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/my-decisions.d.ts +20 -3
- package/dist/components/my-decisions.d.ts.map +1 -1
- package/dist/components/my-decisions.js +14 -1
- package/dist/components/my-decisions.js.map +1 -1
- package/dist/components/people.d.ts +43 -1
- package/dist/components/people.d.ts.map +1 -1
- package/dist/components/people.js +117 -9
- package/dist/components/people.js.map +1 -1
- package/dist/components/picker.d.ts +8 -0
- package/dist/components/picker.d.ts.map +1 -1
- package/dist/components/picker.js +2 -2
- package/dist/components/picker.js.map +1 -1
- package/dist/components/plugin-frame.d.ts +0 -1
- package/dist/components/plugin-frame.d.ts.map +1 -1
- package/dist/components/plugin-frame.js +22 -1
- package/dist/components/plugin-frame.js.map +1 -1
- package/dist/components/plugin-settings.d.ts +5 -1
- package/dist/components/plugin-settings.d.ts.map +1 -1
- package/dist/components/plugin-settings.js +143 -30
- package/dist/components/plugin-settings.js.map +1 -1
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +3 -1
- package/dist/components/projects.js.map +1 -1
- package/dist/components/searches.d.ts +11 -0
- package/dist/components/searches.d.ts.map +1 -0
- package/dist/components/searches.js +30 -0
- package/dist/components/searches.js.map +1 -0
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +6 -5
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/ui.d.ts +16 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +10 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/console.css +362 -67
- package/dist/console.d.ts +2 -1
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +90 -14
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +14 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +36 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +89 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +87 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +87 -3
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +6 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +6 -0
- package/dist/limits.js.map +1 -1
- package/dist/plugin/args.d.ts +6 -1
- package/dist/plugin/args.d.ts.map +1 -1
- package/dist/plugin/args.js +16 -11
- package/dist/plugin/args.js.map +1 -1
- package/dist/plugin/bridge.d.ts +20 -1
- package/dist/plugin/bridge.d.ts.map +1 -1
- package/dist/plugin/bridge.js +28 -0
- package/dist/plugin/bridge.js.map +1 -1
- package/dist/plugin/capabilities.d.ts +1 -1
- package/dist/plugin/capabilities.d.ts.map +1 -1
- package/dist/plugin/capabilities.js +10 -0
- package/dist/plugin/capabilities.js.map +1 -1
- package/dist/plugin/context.d.ts +158 -13
- package/dist/plugin/context.d.ts.map +1 -1
- package/dist/plugin/define.d.ts +13 -1
- package/dist/plugin/define.d.ts.map +1 -1
- package/dist/plugin/define.js +17 -1
- package/dist/plugin/define.js.map +1 -1
- package/dist/plugin/limits.d.ts +4 -0
- package/dist/plugin/limits.d.ts.map +1 -1
- package/dist/plugin/limits.js +4 -0
- package/dist/plugin/limits.js.map +1 -1
- package/dist/plugin/manifest.d.ts +15 -0
- package/dist/plugin/manifest.d.ts.map +1 -1
- package/dist/plugin/manifest.js +61 -4
- package/dist/plugin/manifest.js.map +1 -1
- package/dist/plugin/rest-schemas.d.ts +44 -0
- package/dist/plugin/rest-schemas.d.ts.map +1 -1
- package/dist/plugin/rest-schemas.js +55 -3
- package/dist/plugin/rest-schemas.js.map +1 -1
- package/dist/plugin/testing.d.ts +15 -1
- package/dist/plugin/testing.d.ts.map +1 -1
- package/dist/plugin/testing.js +40 -4
- package/dist/plugin/testing.js.map +1 -1
- package/dist/plugin/web.d.ts +6 -0
- package/dist/plugin/web.d.ts.map +1 -1
- package/dist/plugin/web.js +10 -1
- package/dist/plugin/web.js.map +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/router.d.ts +19 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +18 -0
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +11 -2
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +14 -1
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +367 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +62 -2
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +6 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +7 -4
- package/docs/specs/plugins.md +47 -39
- package/docs/specs/rest.md +22 -5
- package/package.json +1 -1
- package/skills/building-a-plugin/SKILL.md +8 -6
- package/skills/working-the-board/SKILL.md +1 -1
- package/src/api.ts +17 -0
- package/src/cli/cli.ts +200 -12
- package/src/cli/format.ts +82 -19
- package/src/cli/help.ts +30 -5
- package/src/client.ts +70 -0
- package/src/compat.ts +1 -1
- package/src/components/board.tsx +83 -42
- package/src/components/decision-form.tsx +13 -32
- package/src/components/event-feed.tsx +36 -9
- package/src/components/my-decisions.tsx +50 -5
- package/src/components/people.tsx +394 -37
- package/src/components/picker.tsx +11 -2
- package/src/components/plugin-frame.tsx +23 -0
- package/src/components/plugin-settings.tsx +346 -137
- package/src/components/projects.tsx +14 -14
- package/src/components/searches.ts +47 -0
- package/src/components/task-form.tsx +23 -28
- package/src/components/ui.tsx +36 -0
- package/src/console.tsx +229 -40
- package/src/data.ts +53 -1
- package/src/i18n/en.ts +98 -2
- package/src/i18n/ko.ts +95 -3
- package/src/index.ts +11 -0
- package/src/limits.ts +6 -0
- package/src/plugin/args.ts +16 -11
- package/src/plugin/bridge.ts +45 -0
- package/src/plugin/capabilities.ts +11 -0
- package/src/plugin/context.ts +170 -10
- package/src/plugin/define.ts +34 -0
- package/src/plugin/limits.ts +4 -0
- package/src/plugin/manifest.ts +74 -3
- package/src/plugin/rest-schemas.ts +62 -5
- package/src/plugin/testing.ts +61 -5
- package/src/plugin/web.ts +17 -0
- package/src/plugin.ts +1 -0
- package/src/router.ts +25 -0
- package/src/routes.ts +17 -2
- package/src/schemas.ts +81 -2
- package/src/styles/console.css +362 -67
- 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 {
|
|
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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
|
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
|
-
{
|
|
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(
|
|
217
|
+
aria-label={people.roleOf(name)}
|
|
128
218
|
value={person.role}
|
|
129
|
-
disabled={
|
|
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
|
-
<
|
|
154
|
-
className="sc-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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={
|
|
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={
|
|
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={
|
|
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;
|