@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.
- package/README.md +6 -6
- package/dist/api.d.ts +4 -4
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +255 -33
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +20 -5
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +98 -26
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +39 -15
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/plugin-check.d.ts +5 -1
- package/dist/cli/plugin-check.d.ts.map +1 -1
- package/dist/cli/plugin-check.js +23 -4
- package/dist/cli/plugin-check.js.map +1 -1
- package/dist/cli/plugin-cli.d.ts.map +1 -1
- package/dist/cli/plugin-cli.js +10 -1
- package/dist/cli/plugin-cli.js.map +1 -1
- package/dist/cli/plugin-init.d.ts +2 -0
- package/dist/cli/plugin-init.d.ts.map +1 -1
- package/dist/cli/plugin-init.js +6 -0
- package/dist/cli/plugin-init.js.map +1 -1
- package/dist/client.d.ts +32 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -2
- package/dist/client.js.map +1 -1
- package/dist/compat.js +1 -1
- package/dist/components/board.d.ts +8 -0
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +49 -18
- package/dist/components/board.js.map +1 -1
- package/dist/components/decision-form.d.ts +6 -3
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +24 -18
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/decision-list.d.ts +5 -1
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +25 -5
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +34 -8
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/goals.d.ts.map +1 -1
- package/dist/components/goals.js +2 -2
- package/dist/components/goals.js.map +1 -1
- package/dist/components/home.d.ts +35 -0
- package/dist/components/home.d.ts.map +1 -0
- package/dist/components/home.js +326 -0
- package/dist/components/home.js.map +1 -0
- 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/pack-connect.d.ts.map +1 -1
- package/dist/components/pack-connect.js +5 -4
- package/dist/components/pack-connect.js.map +1 -1
- package/dist/components/people.d.ts +45 -1
- package/dist/components/people.d.ts.map +1 -1
- package/dist/components/people.js +125 -4
- 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 +3 -13
- package/dist/components/plugin-frame.d.ts.map +1 -1
- package/dist/components/plugin-frame.js +75 -21
- 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 +283 -45
- package/dist/components/plugin-settings.js.map +1 -1
- package/dist/components/projects.d.ts +0 -8
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +4 -14
- 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/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +2 -1
- package/dist/components/task-view.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 +15 -2
- package/dist/components/ui.js.map +1 -1
- package/dist/connect-page.d.ts.map +1 -1
- package/dist/connect-page.js +5 -6
- package/dist/connect-page.js.map +1 -1
- package/dist/console.css +1123 -258
- package/dist/console.d.ts +12 -4
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +123 -27
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +18 -3
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +41 -1
- package/dist/data.js.map +1 -1
- package/dist/docs-page.d.ts.map +1 -1
- package/dist/docs-page.js +6 -5
- package/dist/docs-page.js.map +1 -1
- package/dist/home-tiles.d.ts +92 -0
- package/dist/home-tiles.d.ts.map +1 -0
- package/dist/home-tiles.js +294 -0
- package/dist/home-tiles.js.map +1 -0
- package/dist/i18n/en.d.ts +264 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +260 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/index.d.ts +4 -2
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js +10 -11
- package/dist/i18n/index.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +255 -4
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +12 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +12 -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 +63 -3
- package/dist/plugin/bridge.d.ts.map +1 -1
- package/dist/plugin/bridge.js +54 -0
- package/dist/plugin/bridge.js.map +1 -1
- package/dist/plugin/capabilities.d.ts +7 -1
- package/dist/plugin/capabilities.d.ts.map +1 -1
- package/dist/plugin/capabilities.js +24 -1
- package/dist/plugin/capabilities.js.map +1 -1
- package/dist/plugin/context.d.ts +174 -12
- package/dist/plugin/context.d.ts.map +1 -1
- package/dist/plugin/define.d.ts +14 -1
- package/dist/plugin/define.d.ts.map +1 -1
- package/dist/plugin/define.js +18 -2
- package/dist/plugin/define.js.map +1 -1
- package/dist/plugin/limits.d.ts +9 -2
- package/dist/plugin/limits.d.ts.map +1 -1
- package/dist/plugin/limits.js +9 -2
- package/dist/plugin/limits.js.map +1 -1
- package/dist/plugin/manifest.d.ts +98 -6
- package/dist/plugin/manifest.d.ts.map +1 -1
- package/dist/plugin/manifest.js +171 -10
- package/dist/plugin/manifest.js.map +1 -1
- package/dist/plugin/rest-schemas.d.ts +120 -0
- package/dist/plugin/rest-schemas.d.ts.map +1 -1
- package/dist/plugin/rest-schemas.js +78 -5
- package/dist/plugin/rest-schemas.js.map +1 -1
- package/dist/plugin/sandbox.d.ts +4 -3
- package/dist/plugin/sandbox.d.ts.map +1 -1
- package/dist/plugin/sandbox.js +33 -6
- package/dist/plugin/sandbox.js.map +1 -1
- package/dist/plugin/semver.d.ts +26 -0
- package/dist/plugin/semver.d.ts.map +1 -0
- package/dist/plugin/semver.js +76 -0
- package/dist/plugin/semver.js.map +1 -0
- package/dist/plugin/testing.d.ts +15 -1
- package/dist/plugin/testing.d.ts.map +1 -1
- package/dist/plugin/testing.js +42 -5
- package/dist/plugin/testing.js.map +1 -1
- package/dist/plugin/web.d.ts +16 -1
- package/dist/plugin/web.d.ts.map +1 -1
- package/dist/plugin/web.js +44 -1
- package/dist/plugin/web.js.map +1 -1
- package/dist/plugin.d.ts +3 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +3 -2
- 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/rules.d.ts +2 -2
- package/dist/rules.js +3 -3
- package/dist/schemas.d.ts +864 -2
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +156 -9
- package/dist/schemas.js.map +1 -1
- package/dist/use-action.d.ts +8 -2
- package/dist/use-action.d.ts.map +1 -1
- package/dist/use-action.js +13 -6
- package/dist/use-action.js.map +1 -1
- package/dist/vocabulary.d.ts +3 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +9 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +10 -6
- package/docs/specs/plugins.md +83 -62
- package/docs/specs/rest.md +34 -16
- package/package.json +1 -1
- package/skills/building-a-plugin/SKILL.md +15 -12
- package/skills/planning-the-board/references/setting-up.md +2 -1
- package/skills/working-the-board/SKILL.md +3 -3
- package/src/api.ts +23 -0
- package/src/cli/cli.ts +323 -30
- package/src/cli/format.ts +122 -26
- package/src/cli/help.ts +43 -14
- package/src/cli/plugin-check.ts +41 -2
- package/src/cli/plugin-cli.ts +10 -1
- package/src/cli/plugin-init.ts +8 -0
- package/src/client.ts +78 -0
- package/src/compat.ts +1 -1
- package/src/components/board.tsx +86 -45
- package/src/components/decision-form.tsx +73 -36
- package/src/components/decision-list.tsx +45 -13
- package/src/components/event-feed.tsx +40 -7
- package/src/components/goals.tsx +4 -1
- package/src/components/home.tsx +615 -0
- package/src/components/my-decisions.tsx +50 -5
- package/src/components/pack-connect.tsx +5 -4
- package/src/components/people.tsx +500 -61
- package/src/components/picker.tsx +11 -2
- package/src/components/plugin-frame.tsx +85 -63
- package/src/components/plugin-settings.tsx +735 -185
- package/src/components/projects.tsx +14 -69
- package/src/components/searches.ts +47 -0
- package/src/components/task-form.tsx +23 -28
- package/src/components/task-view.tsx +7 -0
- package/src/components/ui.tsx +42 -2
- package/src/connect-page.tsx +5 -6
- package/src/console.tsx +303 -64
- package/src/data.ts +62 -3
- package/src/docs-page.tsx +6 -5
- package/src/home-tiles.ts +438 -0
- package/src/i18n/en.ts +274 -0
- package/src/i18n/index.ts +17 -14
- package/src/i18n/ko.ts +267 -4
- package/src/index.ts +18 -5
- package/src/limits.ts +12 -0
- package/src/plugin/args.ts +16 -11
- package/src/plugin/bridge.ts +122 -1
- package/src/plugin/capabilities.ts +28 -1
- package/src/plugin/context.ts +186 -9
- package/src/plugin/define.ts +38 -1
- package/src/plugin/limits.ts +9 -2
- package/src/plugin/manifest.ts +228 -13
- package/src/plugin/rest-schemas.ts +89 -6
- package/src/plugin/sandbox.ts +36 -8
- package/src/plugin/semver.ts +89 -0
- package/src/plugin/testing.ts +63 -6
- package/src/plugin/web.ts +66 -0
- package/src/plugin.ts +19 -0
- package/src/router.ts +25 -0
- package/src/routes.ts +17 -2
- package/src/rules.ts +3 -3
- package/src/schemas.ts +197 -10
- package/src/styles/console.css +1123 -258
- package/src/use-action.ts +16 -7
- package/src/vocabulary.ts +10 -0
- 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
|
|
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
|
|
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
|
|
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 {
|
|
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[];
|
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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={
|
|
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={
|
|
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[];
|