@skillcdn/console 0.4.1 → 0.4.2
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 +19 -2
- package/SKILLCDN.md +7 -5
- package/dist/api.d.ts +4 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -3
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +543 -34
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +34 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +277 -6
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +35 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/plugin-check.d.ts +21 -0
- package/dist/cli/plugin-check.d.ts.map +1 -0
- package/dist/cli/plugin-check.js +374 -0
- package/dist/cli/plugin-check.js.map +1 -0
- package/dist/cli/plugin-cli.d.ts +20 -0
- package/dist/cli/plugin-cli.d.ts.map +1 -0
- package/dist/cli/plugin-cli.js +123 -0
- package/dist/cli/plugin-cli.js.map +1 -0
- package/dist/cli/plugin-init.d.ts +20 -0
- package/dist/cli/plugin-init.d.ts.map +1 -0
- package/dist/cli/plugin-init.js +74 -0
- package/dist/cli/plugin-init.js.map +1 -0
- package/dist/cli/plugin-main.d.ts +3 -0
- package/dist/cli/plugin-main.d.ts.map +1 -0
- package/dist/cli/plugin-main.js +28 -0
- package/dist/cli/plugin-main.js.map +1 -0
- package/dist/client.d.ts +97 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +63 -2
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +1 -1
- package/dist/compat.js +2 -2
- package/dist/components/board.d.ts +8 -0
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +59 -15
- package/dist/components/board.js.map +1 -1
- package/dist/components/event-feed.d.ts +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +33 -1
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/plugin-frame.d.ts +52 -0
- package/dist/components/plugin-frame.d.ts.map +1 -0
- package/dist/components/plugin-frame.js +314 -0
- package/dist/components/plugin-frame.js.map +1 -0
- package/dist/components/plugin-settings.d.ts +38 -0
- package/dist/components/plugin-settings.d.ts.map +1 -0
- package/dist/components/plugin-settings.js +348 -0
- package/dist/components/plugin-settings.js.map +1 -0
- package/dist/components/shell.d.ts +2 -0
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +26 -1
- package/dist/components/shell.js.map +1 -1
- package/dist/components/workspace.d.ts +7 -1
- package/dist/components/workspace.d.ts.map +1 -1
- package/dist/components/workspace.js +1 -1
- package/dist/components/workspace.js.map +1 -1
- package/dist/console.css +327 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +136 -26
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +7 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +19 -2
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +149 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +160 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +144 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin/args.d.ts +148 -0
- package/dist/plugin/args.d.ts.map +1 -0
- package/dist/plugin/args.js +369 -0
- package/dist/plugin/args.js.map +1 -0
- package/dist/plugin/bridge.d.ts +146 -0
- package/dist/plugin/bridge.d.ts.map +1 -0
- package/dist/plugin/bridge.js +125 -0
- package/dist/plugin/bridge.js.map +1 -0
- package/dist/plugin/capabilities.d.ts +20 -0
- package/dist/plugin/capabilities.d.ts.map +1 -0
- package/dist/plugin/capabilities.js +133 -0
- package/dist/plugin/capabilities.js.map +1 -0
- package/dist/plugin/context.d.ts +374 -0
- package/dist/plugin/context.d.ts.map +1 -0
- package/dist/plugin/context.js +2 -0
- package/dist/plugin/context.js.map +1 -0
- package/dist/plugin/define.d.ts +208 -0
- package/dist/plugin/define.d.ts.map +1 -0
- package/dist/plugin/define.js +482 -0
- package/dist/plugin/define.js.map +1 -0
- package/dist/plugin/definition-error.d.ts +11 -0
- package/dist/plugin/definition-error.d.ts.map +1 -0
- package/dist/plugin/definition-error.js +17 -0
- package/dist/plugin/definition-error.js.map +1 -0
- package/dist/plugin/limits.d.ts +67 -0
- package/dist/plugin/limits.d.ts.map +1 -0
- package/dist/plugin/limits.js +70 -0
- package/dist/plugin/limits.js.map +1 -0
- package/dist/plugin/load-worker.d.ts +7 -0
- package/dist/plugin/load-worker.d.ts.map +1 -0
- package/dist/plugin/load-worker.js +41 -0
- package/dist/plugin/load-worker.js.map +1 -0
- package/dist/plugin/load.d.ts +18 -0
- package/dist/plugin/load.d.ts.map +1 -0
- package/dist/plugin/load.js +62 -0
- package/dist/plugin/load.js.map +1 -0
- package/dist/plugin/manifest.d.ts +237 -0
- package/dist/plugin/manifest.d.ts.map +1 -0
- package/dist/plugin/manifest.js +411 -0
- package/dist/plugin/manifest.js.map +1 -0
- package/dist/plugin/refusals.d.ts +49 -0
- package/dist/plugin/refusals.d.ts.map +1 -0
- package/dist/plugin/refusals.js +79 -0
- package/dist/plugin/refusals.js.map +1 -0
- package/dist/plugin/rest-schemas.d.ts +556 -0
- package/dist/plugin/rest-schemas.d.ts.map +1 -0
- package/dist/plugin/rest-schemas.js +262 -0
- package/dist/plugin/rest-schemas.js.map +1 -0
- package/dist/plugin/sandbox.d.ts +23 -0
- package/dist/plugin/sandbox.d.ts.map +1 -0
- package/dist/plugin/sandbox.js +99 -0
- package/dist/plugin/sandbox.js.map +1 -0
- package/dist/plugin/testing.d.ts +45 -0
- package/dist/plugin/testing.d.ts.map +1 -0
- package/dist/plugin/testing.js +420 -0
- package/dist/plugin/testing.js.map +1 -0
- package/dist/plugin/web-react.d.ts +11 -0
- package/dist/plugin/web-react.d.ts.map +1 -0
- package/dist/plugin/web-react.js +34 -0
- package/dist/plugin/web-react.js.map +1 -0
- package/dist/plugin/web.d.ts +95 -0
- package/dist/plugin/web.d.ts.map +1 -0
- package/dist/plugin/web.js +245 -0
- package/dist/plugin/web.js.map +1 -0
- package/dist/plugin-nav.d.ts +29 -0
- package/dist/plugin-nav.d.ts.map +1 -0
- package/dist/plugin-nav.js +92 -0
- package/dist/plugin-nav.js.map +1 -0
- package/dist/plugin-testing.d.ts +2 -0
- package/dist/plugin-testing.d.ts.map +1 -0
- package/dist/plugin-testing.js +4 -0
- package/dist/plugin-testing.js.map +1 -0
- package/dist/plugin-web-react.d.ts +3 -0
- package/dist/plugin-web-react.d.ts.map +1 -0
- package/dist/plugin-web-react.js +3 -0
- package/dist/plugin-web-react.js.map +1 -0
- package/dist/plugin-web.d.ts +4 -0
- package/dist/plugin-web.d.ts.map +1 -0
- package/dist/plugin-web.js +5 -0
- package/dist/plugin-web.js.map +1 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +15 -0
- package/dist/plugin.js.map +1 -0
- package/dist/router.d.ts +28 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +47 -0
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +4 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +6 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +117 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +40 -1
- package/dist/schemas.js.map +1 -1
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +101 -0
- package/dist/tokens.js.map +1 -0
- package/dist/vocabulary.d.ts +11 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +24 -0
- package/dist/vocabulary.js.map +1 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +2 -0
- package/dist/web.js.map +1 -1
- package/docs/specs/cli.md +11 -4
- package/docs/specs/plugins.md +487 -0
- package/docs/specs/rest.md +3 -2
- package/package.json +20 -2
- package/skills/building-a-console/SKILL.md +25 -26
- package/skills/building-a-console/references/configuring.md +40 -0
- package/skills/building-a-console/references/the-default-console.md +1 -1
- package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
- package/skills/building-a-plugin/SKILL.md +137 -0
- package/skills/deploying-a-console/SKILL.md +24 -26
- package/skills/deploying-a-console/references/access.md +13 -9
- package/skills/deploying-a-console/references/resources.md +25 -10
- package/skills/deploying-a-console/references/running-it-again.md +15 -0
- package/skills/deploying-a-console/references/sizing.md +2 -0
- package/skills/planning-the-board/SKILL.md +96 -0
- package/skills/planning-the-board/references/a-task.md +36 -0
- package/skills/planning-the-board/references/setting-up.md +44 -0
- package/skills/working-the-board/SKILL.md +43 -34
- package/skills/working-the-board/references/the-pack.md +19 -0
- package/src/api.ts +8 -0
- package/src/cli/cli.ts +697 -33
- package/src/cli/format.ts +356 -9
- package/src/cli/help.ts +41 -2
- package/src/cli/plugin-check.ts +470 -0
- package/src/cli/plugin-cli.ts +148 -0
- package/src/cli/plugin-init.ts +90 -0
- package/src/cli/plugin-main.ts +30 -0
- package/src/client.ts +294 -0
- package/src/compat.ts +2 -2
- package/src/components/board.tsx +62 -14
- package/src/components/event-feed.tsx +42 -1
- package/src/components/plugin-frame.tsx +437 -0
- package/src/components/plugin-settings.tsx +1111 -0
- package/src/components/shell.tsx +82 -14
- package/src/components/workspace.tsx +18 -0
- package/src/console.tsx +286 -19
- package/src/data.ts +27 -2
- package/src/i18n/en.ts +168 -0
- package/src/i18n/ko.ts +148 -0
- package/src/index.ts +12 -0
- package/src/plugin/args.ts +526 -0
- package/src/plugin/bridge.ts +314 -0
- package/src/plugin/capabilities.ts +150 -0
- package/src/plugin/context.ts +416 -0
- package/src/plugin/define.ts +792 -0
- package/src/plugin/definition-error.ts +19 -0
- package/src/plugin/limits.ts +70 -0
- package/src/plugin/load-worker.ts +50 -0
- package/src/plugin/load.ts +82 -0
- package/src/plugin/manifest.ts +561 -0
- package/src/plugin/refusals.ts +94 -0
- package/src/plugin/rest-schemas.ts +354 -0
- package/src/plugin/sandbox.ts +123 -0
- package/src/plugin/testing.ts +612 -0
- package/src/plugin/web-react.ts +45 -0
- package/src/plugin/web.ts +403 -0
- package/src/plugin-nav.ts +132 -0
- package/src/plugin-testing.ts +10 -0
- package/src/plugin-web-react.ts +4 -0
- package/src/plugin-web.ts +35 -0
- package/src/plugin.ts +126 -0
- package/src/router.ts +73 -0
- package/src/routes.ts +7 -0
- package/src/schemas.ts +43 -0
- package/src/styles/console.css +327 -0
- package/src/tokens.ts +101 -0
- package/src/vocabulary.ts +30 -0
- package/src/web.ts +2 -0
- package/template/plugin/.gitignore +2 -0
- package/template/plugin/README.md +26 -0
- package/template/plugin/SKILLCDN.md +13 -0
- package/template/plugin/build.mjs +40 -0
- package/template/plugin/console-plugin.json +50 -0
- package/template/plugin/package.json +27 -0
- package/template/plugin/skills/__key__/SKILL.md +23 -0
- package/template/plugin/src/check.test.ts +34 -0
- package/template/plugin/src/server/index.test.ts +41 -0
- package/template/plugin/src/server/index.ts +72 -0
- package/template/plugin/src/web/index.css +51 -0
- package/template/plugin/src/web/index.ts +115 -0
- package/template/plugin/tsconfig.json +17 -0
- package/skills/working-the-board/references/setting-up.md +0 -3
- /package/skills/{working-the-board → planning-the-board}/references/project-rules.md +0 -0
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
import { type FormEvent, type ReactNode, useEffect, useId, useMemo, useState } from "react";
|
|
2
|
+
import type { ConsoleClient } from "../client.js";
|
|
3
|
+
import type { Messages } from "../i18n/en.js";
|
|
4
|
+
import { useWords } from "../i18n/index.js";
|
|
5
|
+
import { PLUGIN_CAPABILITIES, type PluginCapability } from "../plugin/capabilities.js";
|
|
6
|
+
import type {
|
|
7
|
+
RestEvent,
|
|
8
|
+
RestPerson,
|
|
9
|
+
RestPlugin,
|
|
10
|
+
RestPluginDetail,
|
|
11
|
+
RestPluginEnableInput,
|
|
12
|
+
RestPluginGrant,
|
|
13
|
+
RestPluginGrantInput,
|
|
14
|
+
RestPluginSetting,
|
|
15
|
+
RestProject,
|
|
16
|
+
} from "../schemas.js";
|
|
17
|
+
import { useAction } from "../use-action.js";
|
|
18
|
+
import { EventFeed } from "./event-feed.js";
|
|
19
|
+
import {
|
|
20
|
+
Badge,
|
|
21
|
+
Button,
|
|
22
|
+
Callout,
|
|
23
|
+
EmptyState,
|
|
24
|
+
formatInstant,
|
|
25
|
+
LinkButton,
|
|
26
|
+
personName,
|
|
27
|
+
Spinner,
|
|
28
|
+
} from "./ui.js";
|
|
29
|
+
|
|
30
|
+
// The plugins' settings (docs/specs/plugins.md, "The settings pages"): what is installed and how
|
|
31
|
+
// each stands; and one plugin, read at a glance: its state in the head with the way to disable
|
|
32
|
+
// it, what it is on and holds a row each, the form that enables it (the capabilities to consent
|
|
33
|
+
// to, each by a name with what it opens, the board project, the projects), the settings form
|
|
34
|
+
// from the manifest with the secrets set and never shown, the roles with who holds each and the
|
|
35
|
+
// way to grant one, what the plugin did, export and purge. No plugin code runs here: everything
|
|
36
|
+
// is the console's own.
|
|
37
|
+
|
|
38
|
+
/** A text in the person's language, else in English, else the first the manifest has. */
|
|
39
|
+
export function pluginWord(
|
|
40
|
+
texts: Readonly<Record<string, string>> | null | undefined,
|
|
41
|
+
language: string,
|
|
42
|
+
): string {
|
|
43
|
+
if (texts === null || texts === undefined) {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
return (
|
|
47
|
+
texts[language] ??
|
|
48
|
+
texts[language.split("-")[0] ?? language] ??
|
|
49
|
+
texts.en ??
|
|
50
|
+
Object.values(texts)[0] ??
|
|
51
|
+
""
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CapabilityText {
|
|
56
|
+
/** What the capability is called on the pages. */
|
|
57
|
+
readonly name: string;
|
|
58
|
+
/** The sentence of what it opens, as the administrator reads it before consenting. */
|
|
59
|
+
readonly opens: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A capability by the name the pages give it and the sentence of what it opens, in the person's
|
|
64
|
+
* language; one the packs do not know, by its key alone.
|
|
65
|
+
*/
|
|
66
|
+
export function capabilityText(words: Messages, capability: string): CapabilityText {
|
|
67
|
+
// Typed against the catalogue, so that a capability added there is named in the packs too.
|
|
68
|
+
const known: Readonly<Record<PluginCapability, CapabilityText>> = words.plugins.capability;
|
|
69
|
+
if ((PLUGIN_CAPABILITIES as readonly string[]).includes(capability)) {
|
|
70
|
+
return known[capability as PluginCapability];
|
|
71
|
+
}
|
|
72
|
+
if (capability.startsWith("outbound:")) {
|
|
73
|
+
return words.plugins.capability.outbound(capability.slice("outbound:".length));
|
|
74
|
+
}
|
|
75
|
+
if (capability.startsWith("plugins:")) {
|
|
76
|
+
return words.plugins.capability.plugins(capability.slice("plugins:".length));
|
|
77
|
+
}
|
|
78
|
+
return { name: capability, opens: "" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STATE_TONES: Readonly<
|
|
82
|
+
Record<RestPlugin["state"], "neutral" | "success" | "warning" | "danger">
|
|
83
|
+
> = {
|
|
84
|
+
installed: "neutral",
|
|
85
|
+
enabled: "success",
|
|
86
|
+
needs_consent: "warning",
|
|
87
|
+
disabled: "neutral",
|
|
88
|
+
failed: "danger",
|
|
89
|
+
orphaned: "warning",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export interface PluginSettingsListProps {
|
|
93
|
+
readonly plugins: readonly RestPlugin[];
|
|
94
|
+
readonly language: string;
|
|
95
|
+
readonly href: (plugin: RestPlugin) => string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every plugin in a row: its name, its key, its version, how it stands. */
|
|
99
|
+
export function PluginSettingsList(props: PluginSettingsListProps) {
|
|
100
|
+
const words = useWords();
|
|
101
|
+
const settings = words.plugins.settings;
|
|
102
|
+
if (props.plugins.length === 0) {
|
|
103
|
+
return <EmptyState title={settings.empty.title} body={settings.empty.body} />;
|
|
104
|
+
}
|
|
105
|
+
return (
|
|
106
|
+
<ul className="sc-task-rows" aria-label={settings.title}>
|
|
107
|
+
{props.plugins.map((plugin) => (
|
|
108
|
+
<li key={plugin.key} className="sc-task-row">
|
|
109
|
+
<a className="sc-task-row-title" href={props.href(plugin)}>
|
|
110
|
+
{pluginWord(plugin.name, props.language)}{" "}
|
|
111
|
+
<span className="sc-muted">
|
|
112
|
+
{plugin.key} {plugin.version}
|
|
113
|
+
</span>
|
|
114
|
+
</a>
|
|
115
|
+
<span className="sc-task-row-meta">
|
|
116
|
+
<Badge tone={STATE_TONES[plugin.state]}>
|
|
117
|
+
{words.vocabulary.pluginState[plugin.state] ?? plugin.state}
|
|
118
|
+
</Badge>
|
|
119
|
+
</span>
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface PluginSettingsViewProps {
|
|
127
|
+
readonly detail: RestPluginDetail;
|
|
128
|
+
readonly projects: readonly RestProject[];
|
|
129
|
+
/** The people of the workspace, to grant a role to; an administrator sees everyone. */
|
|
130
|
+
readonly people: readonly RestPerson[];
|
|
131
|
+
readonly language: string;
|
|
132
|
+
readonly client: ConsoleClient;
|
|
133
|
+
/** Called after a change, so that the page reads the plugin again. */
|
|
134
|
+
readonly onChanged: () => Promise<void>;
|
|
135
|
+
/** Where the list is, for after a purge. */
|
|
136
|
+
readonly onPurged: () => void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A set with a value put in or taken out. */
|
|
140
|
+
function toggled(set: ReadonlySet<string>, value: string, on: boolean): ReadonlySet<string> {
|
|
141
|
+
const next = new Set(set);
|
|
142
|
+
if (on) {
|
|
143
|
+
next.add(value);
|
|
144
|
+
} else {
|
|
145
|
+
next.delete(value);
|
|
146
|
+
}
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A row of a list to tick: the box, a name, a word under it. */
|
|
151
|
+
function TickRow(props: {
|
|
152
|
+
readonly checked: boolean;
|
|
153
|
+
readonly onChange: (checked: boolean) => void;
|
|
154
|
+
readonly name: string;
|
|
155
|
+
readonly under?: string | undefined;
|
|
156
|
+
}) {
|
|
157
|
+
return (
|
|
158
|
+
<li>
|
|
159
|
+
<label className="sc-plugin-tick">
|
|
160
|
+
<input
|
|
161
|
+
type="checkbox"
|
|
162
|
+
checked={props.checked}
|
|
163
|
+
onChange={(event) => props.onChange(event.target.checked)}
|
|
164
|
+
/>
|
|
165
|
+
<span className="sc-plugin-tick-words">
|
|
166
|
+
<span className="sc-plugin-tick-name">{props.name}</span>
|
|
167
|
+
{props.under !== undefined && props.under !== "" && (
|
|
168
|
+
<span className="sc-plugin-tick-under">{props.under}</span>
|
|
169
|
+
)}
|
|
170
|
+
</span>
|
|
171
|
+
</label>
|
|
172
|
+
</li>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** What the plugin is on and holds, a row each; nothing where there is nothing to say yet. */
|
|
177
|
+
function Overview(props: {
|
|
178
|
+
readonly detail: RestPluginDetail;
|
|
179
|
+
readonly projects: readonly RestProject[];
|
|
180
|
+
readonly language: string;
|
|
181
|
+
}) {
|
|
182
|
+
const words = useWords();
|
|
183
|
+
const settings = words.plugins.settings;
|
|
184
|
+
const { detail, language } = props;
|
|
185
|
+
const enabled = detail.state === "enabled";
|
|
186
|
+
const rows: { readonly key: string; readonly name: string; readonly value: ReactNode }[] = [];
|
|
187
|
+
if (detail.board !== null) {
|
|
188
|
+
rows.push({
|
|
189
|
+
key: "board",
|
|
190
|
+
name: settings.board,
|
|
191
|
+
value: (
|
|
192
|
+
<>
|
|
193
|
+
{detail.board.name} <span className="sc-muted">{detail.board.key}</span>
|
|
194
|
+
</>
|
|
195
|
+
),
|
|
196
|
+
});
|
|
197
|
+
} else if (enabled && detail.boardNeeds !== "none") {
|
|
198
|
+
rows.push({ key: "board", name: settings.board, value: settings.nothing });
|
|
199
|
+
}
|
|
200
|
+
if (enabled && detail.projects !== null) {
|
|
201
|
+
const names = detail.projects;
|
|
202
|
+
rows.push({
|
|
203
|
+
key: "projects",
|
|
204
|
+
name: settings.projects,
|
|
205
|
+
value:
|
|
206
|
+
names === "all"
|
|
207
|
+
? settings.projectsAll
|
|
208
|
+
: names.length === 0
|
|
209
|
+
? settings.nothing
|
|
210
|
+
: names
|
|
211
|
+
.map((key) => props.projects.find((project) => project.key === key)?.name ?? key)
|
|
212
|
+
.join(", "),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (enabled && detail.consent !== null) {
|
|
216
|
+
const consent = detail.consent;
|
|
217
|
+
rows.push({
|
|
218
|
+
key: "capabilities",
|
|
219
|
+
name: settings.capabilities,
|
|
220
|
+
value:
|
|
221
|
+
consent.length === 0 ? (
|
|
222
|
+
settings.nothing
|
|
223
|
+
) : (
|
|
224
|
+
<ul className="sc-plugin-held">
|
|
225
|
+
{consent.map((capability) => {
|
|
226
|
+
const text = capabilityText(words, capability);
|
|
227
|
+
return (
|
|
228
|
+
<li key={capability}>
|
|
229
|
+
<span className="sc-plugin-tick-name">{text.name}</span>
|
|
230
|
+
{text.opens !== "" && <span className="sc-plugin-tick-under"> {text.opens}</span>}
|
|
231
|
+
</li>
|
|
232
|
+
);
|
|
233
|
+
})}
|
|
234
|
+
</ul>
|
|
235
|
+
),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (detail.operations.length > 0) {
|
|
239
|
+
rows.push({
|
|
240
|
+
key: "operations",
|
|
241
|
+
name: settings.operations,
|
|
242
|
+
value: (
|
|
243
|
+
<span className="sc-plugin-codes">
|
|
244
|
+
{detail.operations.map((operation) => (
|
|
245
|
+
<code key={operation.name}>{operation.name}</code>
|
|
246
|
+
))}
|
|
247
|
+
</span>
|
|
248
|
+
),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (detail.agentNote !== null) {
|
|
252
|
+
rows.push({
|
|
253
|
+
key: "agentNote",
|
|
254
|
+
name: settings.agentNote,
|
|
255
|
+
value: pluginWord(detail.agentNote, language),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (rows.length === 0) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return (
|
|
262
|
+
<section className="sc-panel" aria-label={settings.about}>
|
|
263
|
+
<dl className="sc-settings-list">
|
|
264
|
+
{rows.map((row) => (
|
|
265
|
+
<div key={row.key} className="sc-settings-item">
|
|
266
|
+
<dt>{row.name}</dt>
|
|
267
|
+
<dd>{row.value}</dd>
|
|
268
|
+
</div>
|
|
269
|
+
))}
|
|
270
|
+
</dl>
|
|
271
|
+
</section>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A setting's control, by its type: a person or a project is chosen among the workspace's, by
|
|
277
|
+
* name, and held as its id; a secret has a row of its own below.
|
|
278
|
+
*/
|
|
279
|
+
function SettingControl(props: {
|
|
280
|
+
readonly setting: RestPluginSetting;
|
|
281
|
+
readonly value: unknown;
|
|
282
|
+
readonly onChange: (value: unknown) => void;
|
|
283
|
+
readonly id: string;
|
|
284
|
+
readonly language: string;
|
|
285
|
+
readonly people: readonly RestPerson[];
|
|
286
|
+
readonly projects: readonly RestProject[];
|
|
287
|
+
}) {
|
|
288
|
+
const { setting, value, onChange, id } = props;
|
|
289
|
+
const chosen = typeof value === "string" ? value : "";
|
|
290
|
+
const choose = (event: { readonly target: { readonly value: string } }) =>
|
|
291
|
+
onChange(event.target.value === "" ? null : event.target.value);
|
|
292
|
+
switch (setting.type) {
|
|
293
|
+
case "person": {
|
|
294
|
+
// The people in; the one chosen stays listed when they have left, so that the value shows.
|
|
295
|
+
const people = props.people.filter(
|
|
296
|
+
(person) => person.status === "active" || person.id === chosen,
|
|
297
|
+
);
|
|
298
|
+
return (
|
|
299
|
+
<select id={id} className="sc-input" value={chosen} onChange={choose}>
|
|
300
|
+
<option value="">—</option>
|
|
301
|
+
{people.map((person) => (
|
|
302
|
+
<option key={person.id} value={person.id}>
|
|
303
|
+
{personChoice(person)}
|
|
304
|
+
</option>
|
|
305
|
+
))}
|
|
306
|
+
{chosen !== "" && !people.some((person) => person.id === chosen) && (
|
|
307
|
+
<option value={chosen}>{chosen}</option>
|
|
308
|
+
)}
|
|
309
|
+
</select>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
case "project":
|
|
313
|
+
return (
|
|
314
|
+
<select id={id} className="sc-input" value={chosen} onChange={choose}>
|
|
315
|
+
<option value="">—</option>
|
|
316
|
+
{props.projects.map((project) => (
|
|
317
|
+
<option key={project.id} value={project.id}>
|
|
318
|
+
{project.name} ({project.key})
|
|
319
|
+
</option>
|
|
320
|
+
))}
|
|
321
|
+
{chosen !== "" && !props.projects.some((project) => project.id === chosen) && (
|
|
322
|
+
<option value={chosen}>{chosen}</option>
|
|
323
|
+
)}
|
|
324
|
+
</select>
|
|
325
|
+
);
|
|
326
|
+
case "boolean":
|
|
327
|
+
return (
|
|
328
|
+
<input
|
|
329
|
+
id={id}
|
|
330
|
+
type="checkbox"
|
|
331
|
+
checked={value === true}
|
|
332
|
+
onChange={(event) => onChange(event.target.checked)}
|
|
333
|
+
/>
|
|
334
|
+
);
|
|
335
|
+
case "number":
|
|
336
|
+
return (
|
|
337
|
+
<input
|
|
338
|
+
id={id}
|
|
339
|
+
className="sc-input"
|
|
340
|
+
type="number"
|
|
341
|
+
value={typeof value === "number" ? value : ""}
|
|
342
|
+
min={setting.min ?? undefined}
|
|
343
|
+
max={setting.max ?? undefined}
|
|
344
|
+
onChange={(event) =>
|
|
345
|
+
onChange(event.target.value === "" ? null : Number(event.target.value))
|
|
346
|
+
}
|
|
347
|
+
/>
|
|
348
|
+
);
|
|
349
|
+
case "enum":
|
|
350
|
+
return (
|
|
351
|
+
<select
|
|
352
|
+
id={id}
|
|
353
|
+
className="sc-input"
|
|
354
|
+
value={typeof value === "string" ? value : ""}
|
|
355
|
+
onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
|
|
356
|
+
>
|
|
357
|
+
<option value="">—</option>
|
|
358
|
+
{(setting.options ?? []).map((option) => (
|
|
359
|
+
<option key={option.value} value={option.value}>
|
|
360
|
+
{pluginWord(option.name, props.language)}
|
|
361
|
+
</option>
|
|
362
|
+
))}
|
|
363
|
+
</select>
|
|
364
|
+
);
|
|
365
|
+
case "markdown":
|
|
366
|
+
return (
|
|
367
|
+
<textarea
|
|
368
|
+
id={id}
|
|
369
|
+
className="sc-input"
|
|
370
|
+
rows={4}
|
|
371
|
+
value={typeof value === "string" ? value : ""}
|
|
372
|
+
onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
|
|
373
|
+
/>
|
|
374
|
+
);
|
|
375
|
+
case "list":
|
|
376
|
+
return (
|
|
377
|
+
<input
|
|
378
|
+
id={id}
|
|
379
|
+
className="sc-input"
|
|
380
|
+
value={Array.isArray(value) ? value.join(", ") : ""}
|
|
381
|
+
onChange={(event) =>
|
|
382
|
+
onChange(
|
|
383
|
+
event.target.value.trim() === ""
|
|
384
|
+
? null
|
|
385
|
+
: event.target.value
|
|
386
|
+
.split(",")
|
|
387
|
+
.map((item) => item.trim())
|
|
388
|
+
.filter((item) => item.length > 0)
|
|
389
|
+
.map((item) => (setting.of === "number" ? Number(item) : item)),
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
/>
|
|
393
|
+
);
|
|
394
|
+
default:
|
|
395
|
+
return (
|
|
396
|
+
<input
|
|
397
|
+
id={id}
|
|
398
|
+
className="sc-input"
|
|
399
|
+
type={setting.type === "day" ? "date" : "text"}
|
|
400
|
+
value={typeof value === "string" ? value : ""}
|
|
401
|
+
maxLength={setting.max ?? undefined}
|
|
402
|
+
onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
|
|
403
|
+
/>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The settings form from the manifest, without the secrets: the values as they stand, saved as a whole. */
|
|
409
|
+
function SettingsForm(props: {
|
|
410
|
+
readonly detail: RestPluginDetail;
|
|
411
|
+
readonly language: string;
|
|
412
|
+
readonly busy: boolean;
|
|
413
|
+
readonly people: readonly RestPerson[];
|
|
414
|
+
readonly projects: readonly RestProject[];
|
|
415
|
+
readonly onSave: (values: Record<string, unknown>) => void;
|
|
416
|
+
}) {
|
|
417
|
+
const words = useWords();
|
|
418
|
+
const settings = props.detail.settings.filter((setting) => setting.type !== "secret");
|
|
419
|
+
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
|
420
|
+
Object.fromEntries(settings.map((setting) => [setting.key, setting.value ?? setting.default])),
|
|
421
|
+
);
|
|
422
|
+
const prefix = useId();
|
|
423
|
+
if (settings.length === 0) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
const submit = (event: FormEvent) => {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
props.onSave(values);
|
|
429
|
+
};
|
|
430
|
+
return (
|
|
431
|
+
<form
|
|
432
|
+
className="sc-form sc-settings-form"
|
|
433
|
+
onSubmit={submit}
|
|
434
|
+
aria-label={words.plugins.settings.settingsForm}
|
|
435
|
+
>
|
|
436
|
+
{settings.map((setting) => (
|
|
437
|
+
<div key={setting.key} className="sc-settings-row">
|
|
438
|
+
<div className="sc-settings-words">
|
|
439
|
+
<label className="sc-settings-label" htmlFor={`${prefix}-${setting.key}`}>
|
|
440
|
+
{pluginWord(setting.name, props.language)}
|
|
441
|
+
{setting.required && " *"}
|
|
442
|
+
</label>
|
|
443
|
+
{setting.description !== null && (
|
|
444
|
+
<p className="sc-settings-hint">{pluginWord(setting.description, props.language)}</p>
|
|
445
|
+
)}
|
|
446
|
+
</div>
|
|
447
|
+
<div className="sc-settings-control">
|
|
448
|
+
<SettingControl
|
|
449
|
+
setting={setting}
|
|
450
|
+
value={values[setting.key]}
|
|
451
|
+
id={`${prefix}-${setting.key}`}
|
|
452
|
+
language={props.language}
|
|
453
|
+
people={props.people}
|
|
454
|
+
projects={props.projects}
|
|
455
|
+
onChange={(value) => setValues((current) => ({ ...current, [setting.key]: value }))}
|
|
456
|
+
/>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
))}
|
|
460
|
+
<div className="sc-form-actions">
|
|
461
|
+
<Button type="submit" variant="primary" disabled={props.busy}>
|
|
462
|
+
{words.common.save}
|
|
463
|
+
</Button>
|
|
464
|
+
</div>
|
|
465
|
+
</form>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** One secret: whether and when it is set, a field to set it anew with its buttons on the line. */
|
|
470
|
+
function SecretRow(props: {
|
|
471
|
+
readonly setting: RestPluginSetting;
|
|
472
|
+
readonly language: string;
|
|
473
|
+
readonly busy: boolean;
|
|
474
|
+
readonly onSet: (value: string) => void;
|
|
475
|
+
readonly onRemove: () => void;
|
|
476
|
+
}) {
|
|
477
|
+
const words = useWords();
|
|
478
|
+
const settings = words.plugins.settings;
|
|
479
|
+
const [value, setValue] = useState("");
|
|
480
|
+
const id = useId();
|
|
481
|
+
const { setting } = props;
|
|
482
|
+
return (
|
|
483
|
+
<div className="sc-settings-row">
|
|
484
|
+
<div className="sc-settings-words">
|
|
485
|
+
<label className="sc-settings-label" htmlFor={id}>
|
|
486
|
+
{pluginWord(setting.name, props.language)}
|
|
487
|
+
{setting.required && " *"}
|
|
488
|
+
</label>
|
|
489
|
+
<p className="sc-settings-hint">
|
|
490
|
+
{setting.set && setting.setAt !== null
|
|
491
|
+
? settings.secretSet(formatInstant(setting.setAt, props.language))
|
|
492
|
+
: settings.secretUnset}
|
|
493
|
+
</p>
|
|
494
|
+
{setting.description !== null && (
|
|
495
|
+
<p className="sc-settings-hint">{pluginWord(setting.description, props.language)}</p>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
<div className="sc-settings-control">
|
|
499
|
+
<div className="sc-plugin-line">
|
|
500
|
+
<input
|
|
501
|
+
id={id}
|
|
502
|
+
className="sc-input sc-plugin-line-input"
|
|
503
|
+
type="password"
|
|
504
|
+
autoComplete="off"
|
|
505
|
+
placeholder={settings.secretNew}
|
|
506
|
+
value={value}
|
|
507
|
+
onChange={(event) => setValue(event.target.value)}
|
|
508
|
+
/>
|
|
509
|
+
<Button
|
|
510
|
+
disabled={props.busy || value.length === 0}
|
|
511
|
+
onClick={() => {
|
|
512
|
+
props.onSet(value);
|
|
513
|
+
setValue("");
|
|
514
|
+
}}
|
|
515
|
+
>
|
|
516
|
+
{words.common.save}
|
|
517
|
+
</Button>
|
|
518
|
+
{setting.set && (
|
|
519
|
+
<Button variant="ghost" disabled={props.busy} onClick={props.onRemove}>
|
|
520
|
+
{words.common.remove}
|
|
521
|
+
</Button>
|
|
522
|
+
)}
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Enabling: the capabilities to consent to, one by one, those consented to before ticked
|
|
531
|
+
* already; the board project, the one it had chosen already; the projects, as they were.
|
|
532
|
+
*/
|
|
533
|
+
function EnableForm(props: {
|
|
534
|
+
readonly detail: RestPluginDetail;
|
|
535
|
+
readonly projects: readonly RestProject[];
|
|
536
|
+
readonly language: string;
|
|
537
|
+
readonly busy: boolean;
|
|
538
|
+
readonly onEnable: (input: RestPluginEnableInput) => void;
|
|
539
|
+
}) {
|
|
540
|
+
const words = useWords();
|
|
541
|
+
const settings = words.plugins.settings;
|
|
542
|
+
const { detail, language } = props;
|
|
543
|
+
const [consented, setConsented] = useState<ReadonlySet<string>>(
|
|
544
|
+
() =>
|
|
545
|
+
new Set(
|
|
546
|
+
(detail.consent ?? []).filter((capability) => detail.capabilities.includes(capability)),
|
|
547
|
+
),
|
|
548
|
+
);
|
|
549
|
+
const needsBoard = detail.boardNeeds !== "none";
|
|
550
|
+
const [boardMode, setBoardMode] = useState<"none" | "create" | "choose">(
|
|
551
|
+
detail.boardNeeds === "none"
|
|
552
|
+
? "none"
|
|
553
|
+
: detail.board !== null || detail.boardDefaults === null
|
|
554
|
+
? "choose"
|
|
555
|
+
: "create",
|
|
556
|
+
);
|
|
557
|
+
const [boardKey, setBoardKey] = useState(detail.boardDefaults?.key ?? "");
|
|
558
|
+
const [boardName, setBoardName] = useState(pluginWord(detail.boardDefaults?.name, language));
|
|
559
|
+
const [boardDescription, setBoardDescription] = useState(
|
|
560
|
+
pluginWord(detail.boardDefaults?.description, language),
|
|
561
|
+
);
|
|
562
|
+
const [chosen, setChosen] = useState(detail.board?.key ?? props.projects[0]?.key ?? "");
|
|
563
|
+
const before = detail.projects;
|
|
564
|
+
const [allProjects, setAllProjects] = useState(!Array.isArray(before) || before.length === 0);
|
|
565
|
+
const [onProjects, setOnProjects] = useState<ReadonlySet<string>>(
|
|
566
|
+
() => new Set(Array.isArray(before) ? before : []),
|
|
567
|
+
);
|
|
568
|
+
const ids = { key: useId(), name: useId(), description: useId(), choose: useId() };
|
|
569
|
+
const everyCapability = detail.capabilities.every((capability) => consented.has(capability));
|
|
570
|
+
const boardReady =
|
|
571
|
+
boardMode === "none"
|
|
572
|
+
? detail.boardNeeds !== "required"
|
|
573
|
+
: boardMode === "create"
|
|
574
|
+
? boardKey.length > 0 && boardName.length > 0
|
|
575
|
+
: chosen.length > 0;
|
|
576
|
+
const submit = (event: FormEvent) => {
|
|
577
|
+
event.preventDefault();
|
|
578
|
+
props.onEnable({
|
|
579
|
+
consent: [...consented],
|
|
580
|
+
board:
|
|
581
|
+
boardMode === "none"
|
|
582
|
+
? null
|
|
583
|
+
: boardMode === "create"
|
|
584
|
+
? { create: { key: boardKey, name: boardName, description: boardDescription } }
|
|
585
|
+
: { project: chosen },
|
|
586
|
+
projects: allProjects ? "all" : [...onProjects],
|
|
587
|
+
});
|
|
588
|
+
};
|
|
589
|
+
return (
|
|
590
|
+
<form className="sc-form sc-plugin-enable" onSubmit={submit} aria-label={settings.enable}>
|
|
591
|
+
{detail.capabilities.length > 0 && (
|
|
592
|
+
<div className="sc-plugin-group">
|
|
593
|
+
<h3 className="sc-plugin-group-title">{settings.capabilities}</h3>
|
|
594
|
+
<ul className="sc-plugin-ticks">
|
|
595
|
+
{detail.capabilities.map((capability) => {
|
|
596
|
+
const text = capabilityText(words, capability);
|
|
597
|
+
return (
|
|
598
|
+
<TickRow
|
|
599
|
+
key={capability}
|
|
600
|
+
checked={consented.has(capability)}
|
|
601
|
+
onChange={(on) => setConsented((current) => toggled(current, capability, on))}
|
|
602
|
+
name={text.name}
|
|
603
|
+
under={text.opens}
|
|
604
|
+
/>
|
|
605
|
+
);
|
|
606
|
+
})}
|
|
607
|
+
</ul>
|
|
608
|
+
</div>
|
|
609
|
+
)}
|
|
610
|
+
{needsBoard && (
|
|
611
|
+
<div className="sc-plugin-group">
|
|
612
|
+
<h3 className="sc-plugin-group-title">{settings.board}</h3>
|
|
613
|
+
<p className="sc-settings-hint">{settings.boardHint}</p>
|
|
614
|
+
<div className="sc-radio-row">
|
|
615
|
+
{detail.boardNeeds === "optional" && (
|
|
616
|
+
<label className="sc-check">
|
|
617
|
+
<input
|
|
618
|
+
type="radio"
|
|
619
|
+
name="board"
|
|
620
|
+
checked={boardMode === "none"}
|
|
621
|
+
onChange={() => setBoardMode("none")}
|
|
622
|
+
/>
|
|
623
|
+
{settings.boardNone}
|
|
624
|
+
</label>
|
|
625
|
+
)}
|
|
626
|
+
<label className="sc-check">
|
|
627
|
+
<input
|
|
628
|
+
type="radio"
|
|
629
|
+
name="board"
|
|
630
|
+
checked={boardMode === "create"}
|
|
631
|
+
onChange={() => setBoardMode("create")}
|
|
632
|
+
/>
|
|
633
|
+
{settings.boardCreate}
|
|
634
|
+
</label>
|
|
635
|
+
<label className="sc-check">
|
|
636
|
+
<input
|
|
637
|
+
type="radio"
|
|
638
|
+
name="board"
|
|
639
|
+
checked={boardMode === "choose"}
|
|
640
|
+
onChange={() => setBoardMode("choose")}
|
|
641
|
+
/>
|
|
642
|
+
{settings.boardChoose}
|
|
643
|
+
</label>
|
|
644
|
+
</div>
|
|
645
|
+
{boardMode === "create" && (
|
|
646
|
+
<div className="sc-plugin-fields">
|
|
647
|
+
<div className="sc-field">
|
|
648
|
+
<label className="sc-field-label" htmlFor={ids.key}>
|
|
649
|
+
{settings.boardKey}
|
|
650
|
+
</label>
|
|
651
|
+
<input
|
|
652
|
+
id={ids.key}
|
|
653
|
+
className="sc-input"
|
|
654
|
+
value={boardKey}
|
|
655
|
+
onChange={(event) => setBoardKey(event.target.value)}
|
|
656
|
+
/>
|
|
657
|
+
</div>
|
|
658
|
+
<div className="sc-field">
|
|
659
|
+
<label className="sc-field-label" htmlFor={ids.name}>
|
|
660
|
+
{settings.boardName}
|
|
661
|
+
</label>
|
|
662
|
+
<input
|
|
663
|
+
id={ids.name}
|
|
664
|
+
className="sc-input"
|
|
665
|
+
value={boardName}
|
|
666
|
+
onChange={(event) => setBoardName(event.target.value)}
|
|
667
|
+
/>
|
|
668
|
+
</div>
|
|
669
|
+
<div className="sc-field">
|
|
670
|
+
<label className="sc-field-label" htmlFor={ids.description}>
|
|
671
|
+
{settings.boardDescription}
|
|
672
|
+
</label>
|
|
673
|
+
<input
|
|
674
|
+
id={ids.description}
|
|
675
|
+
className="sc-input"
|
|
676
|
+
value={boardDescription}
|
|
677
|
+
onChange={(event) => setBoardDescription(event.target.value)}
|
|
678
|
+
/>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
)}
|
|
682
|
+
{boardMode === "choose" && (
|
|
683
|
+
<div className="sc-plugin-fields">
|
|
684
|
+
<div className="sc-field">
|
|
685
|
+
<label className="sc-field-label" htmlFor={ids.choose}>
|
|
686
|
+
{settings.boardChoose}
|
|
687
|
+
</label>
|
|
688
|
+
<select
|
|
689
|
+
id={ids.choose}
|
|
690
|
+
className="sc-input"
|
|
691
|
+
value={chosen}
|
|
692
|
+
onChange={(event) => setChosen(event.target.value)}
|
|
693
|
+
>
|
|
694
|
+
{props.projects.map((project) => (
|
|
695
|
+
<option key={project.key} value={project.key}>
|
|
696
|
+
{project.name} ({project.key})
|
|
697
|
+
</option>
|
|
698
|
+
))}
|
|
699
|
+
</select>
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
)}
|
|
703
|
+
</div>
|
|
704
|
+
)}
|
|
705
|
+
<div className="sc-plugin-group">
|
|
706
|
+
<h3 className="sc-plugin-group-title">{settings.projects}</h3>
|
|
707
|
+
<div className="sc-radio-row">
|
|
708
|
+
<label className="sc-check">
|
|
709
|
+
<input
|
|
710
|
+
type="radio"
|
|
711
|
+
name="projects"
|
|
712
|
+
checked={allProjects}
|
|
713
|
+
onChange={() => setAllProjects(true)}
|
|
714
|
+
/>
|
|
715
|
+
{settings.projectsAll}
|
|
716
|
+
</label>
|
|
717
|
+
<label className="sc-check">
|
|
718
|
+
<input
|
|
719
|
+
type="radio"
|
|
720
|
+
name="projects"
|
|
721
|
+
checked={!allProjects}
|
|
722
|
+
onChange={() => setAllProjects(false)}
|
|
723
|
+
/>
|
|
724
|
+
{settings.projectsSome}
|
|
725
|
+
</label>
|
|
726
|
+
</div>
|
|
727
|
+
{!allProjects && (
|
|
728
|
+
<ul className="sc-plugin-ticks">
|
|
729
|
+
{props.projects.map((project) => (
|
|
730
|
+
<TickRow
|
|
731
|
+
key={project.key}
|
|
732
|
+
checked={onProjects.has(project.key)}
|
|
733
|
+
onChange={(on) => setOnProjects((current) => toggled(current, project.key, on))}
|
|
734
|
+
name={project.name}
|
|
735
|
+
under={project.key}
|
|
736
|
+
/>
|
|
737
|
+
))}
|
|
738
|
+
</ul>
|
|
739
|
+
)}
|
|
740
|
+
</div>
|
|
741
|
+
<div className="sc-plugin-group">
|
|
742
|
+
<div className="sc-form-actions">
|
|
743
|
+
<Button
|
|
744
|
+
type="submit"
|
|
745
|
+
variant="primary"
|
|
746
|
+
disabled={props.busy || !everyCapability || !boardReady}
|
|
747
|
+
>
|
|
748
|
+
{settings.enable}
|
|
749
|
+
</Button>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
</form>
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Who a grant is to, as the chip says it. */
|
|
757
|
+
function holderOf(grant: RestPluginGrant, words: Messages["plugins"]["settings"]): string {
|
|
758
|
+
if (grant.everyone) {
|
|
759
|
+
return words.grantEveryone;
|
|
760
|
+
}
|
|
761
|
+
if (grant.project !== null) {
|
|
762
|
+
return words.grantMembersOf(grant.project.name);
|
|
763
|
+
}
|
|
764
|
+
if (grant.person !== null) {
|
|
765
|
+
return personName(grant.person);
|
|
766
|
+
}
|
|
767
|
+
return grant.role;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** A person as the choice names them: the name, with the login where it is another word. */
|
|
771
|
+
function personChoice(person: RestPerson): string {
|
|
772
|
+
const name = personName(person);
|
|
773
|
+
return name === person.login ? name : `${name} (${person.login})`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* One role: its name and what it is for; who holds it, every member for the default role and
|
|
778
|
+
* each grant as a chip with the way to revoke it; and one choice to grant it to a person, the
|
|
779
|
+
* members of a project or everyone, those who hold it already left out.
|
|
780
|
+
*/
|
|
781
|
+
function RoleRow(props: {
|
|
782
|
+
readonly role: RestPluginDetail["declaredRoles"][number];
|
|
783
|
+
readonly isDefault: boolean;
|
|
784
|
+
readonly grants: readonly RestPluginGrant[];
|
|
785
|
+
readonly projects: readonly RestProject[];
|
|
786
|
+
readonly people: readonly RestPerson[];
|
|
787
|
+
readonly language: string;
|
|
788
|
+
readonly busy: boolean;
|
|
789
|
+
readonly onGrant: (input: RestPluginGrantInput) => void;
|
|
790
|
+
readonly onRevoke: (id: string) => void;
|
|
791
|
+
}) {
|
|
792
|
+
const settings = useWords().plugins.settings;
|
|
793
|
+
const [whom, setWhom] = useState("");
|
|
794
|
+
const id = useId();
|
|
795
|
+
const { role, grants } = props;
|
|
796
|
+
const everyoneHas = grants.some((grant) => grant.everyone);
|
|
797
|
+
const projectsWith = new Set(
|
|
798
|
+
grants.flatMap((grant) => (grant.project === null ? [] : [grant.project.key])),
|
|
799
|
+
);
|
|
800
|
+
const peopleWith = new Set(
|
|
801
|
+
grants.flatMap((grant) => (grant.person === null ? [] : [grant.person.id])),
|
|
802
|
+
);
|
|
803
|
+
const projects = props.projects.filter((project) => !projectsWith.has(project.key));
|
|
804
|
+
const people = props.people.filter(
|
|
805
|
+
(person) => person.status === "active" && !peopleWith.has(person.id),
|
|
806
|
+
);
|
|
807
|
+
const description = pluginWord(role.description, props.language);
|
|
808
|
+
const submit = (event: FormEvent) => {
|
|
809
|
+
event.preventDefault();
|
|
810
|
+
if (whom === "everyone") {
|
|
811
|
+
props.onGrant({ role: role.key, everyone: true });
|
|
812
|
+
} else if (whom.startsWith("project:")) {
|
|
813
|
+
props.onGrant({ role: role.key, project: whom.slice("project:".length) });
|
|
814
|
+
} else if (whom.startsWith("person:")) {
|
|
815
|
+
props.onGrant({ role: role.key, person: whom.slice("person:".length) });
|
|
816
|
+
}
|
|
817
|
+
setWhom("");
|
|
818
|
+
};
|
|
819
|
+
return (
|
|
820
|
+
<li className="sc-plugin-role">
|
|
821
|
+
<div>
|
|
822
|
+
<p className="sc-plugin-role-name">
|
|
823
|
+
{pluginWord(role.name, props.language)}
|
|
824
|
+
<code className="sc-plugin-role-key">{role.key}</code>
|
|
825
|
+
{props.isDefault && <Badge tone="accent">{settings.roleDefault}</Badge>}
|
|
826
|
+
</p>
|
|
827
|
+
{description !== "" && <p className="sc-settings-hint">{description}</p>}
|
|
828
|
+
</div>
|
|
829
|
+
<div>
|
|
830
|
+
<div className="sc-plugin-holders">
|
|
831
|
+
{props.isDefault && (
|
|
832
|
+
<span className="sc-plugin-holder sc-plugin-holder-fixed">{settings.everyMember}</span>
|
|
833
|
+
)}
|
|
834
|
+
{grants.map((grant) => {
|
|
835
|
+
const holder = holderOf(grant, settings);
|
|
836
|
+
return (
|
|
837
|
+
<span key={grant.id} className="sc-plugin-holder">
|
|
838
|
+
{holder}
|
|
839
|
+
<Button
|
|
840
|
+
size="sm"
|
|
841
|
+
variant="ghost"
|
|
842
|
+
disabled={props.busy}
|
|
843
|
+
onClick={() => props.onRevoke(grant.id)}
|
|
844
|
+
aria-label={`${settings.revoke}: ${holder}`}
|
|
845
|
+
title={settings.revoke}
|
|
846
|
+
>
|
|
847
|
+
×
|
|
848
|
+
</Button>
|
|
849
|
+
</span>
|
|
850
|
+
);
|
|
851
|
+
})}
|
|
852
|
+
</div>
|
|
853
|
+
<form className="sc-plugin-grant" onSubmit={submit}>
|
|
854
|
+
<label htmlFor={id} className="sc-visually-hidden">
|
|
855
|
+
{settings.grantWhom}
|
|
856
|
+
</label>
|
|
857
|
+
<select
|
|
858
|
+
id={id}
|
|
859
|
+
className="sc-input sc-plugin-grant-input"
|
|
860
|
+
value={whom}
|
|
861
|
+
onChange={(event) => setWhom(event.target.value)}
|
|
862
|
+
>
|
|
863
|
+
<option value="">{settings.grantWhom}</option>
|
|
864
|
+
{!props.isDefault && !everyoneHas && (
|
|
865
|
+
<option value="everyone">{settings.grantEveryone}</option>
|
|
866
|
+
)}
|
|
867
|
+
{projects.length > 0 && (
|
|
868
|
+
<optgroup label={settings.grantProjects}>
|
|
869
|
+
{projects.map((project) => (
|
|
870
|
+
<option key={project.key} value={`project:${project.key}`}>
|
|
871
|
+
{project.name}
|
|
872
|
+
</option>
|
|
873
|
+
))}
|
|
874
|
+
</optgroup>
|
|
875
|
+
)}
|
|
876
|
+
{people.length > 0 && (
|
|
877
|
+
<optgroup label={settings.grantPeople}>
|
|
878
|
+
{people.map((person) => (
|
|
879
|
+
<option key={person.id} value={`person:${person.id}`}>
|
|
880
|
+
{personChoice(person)}
|
|
881
|
+
</option>
|
|
882
|
+
))}
|
|
883
|
+
</optgroup>
|
|
884
|
+
)}
|
|
885
|
+
</select>
|
|
886
|
+
<Button type="submit" disabled={props.busy || whom === ""}>
|
|
887
|
+
{settings.grant}
|
|
888
|
+
</Button>
|
|
889
|
+
</form>
|
|
890
|
+
</div>
|
|
891
|
+
</li>
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** The roles the plugin declares, each with who holds it; a plugin without roles is every member's. */
|
|
896
|
+
function RolesSection(props: {
|
|
897
|
+
readonly detail: RestPluginDetail;
|
|
898
|
+
readonly projects: readonly RestProject[];
|
|
899
|
+
readonly people: readonly RestPerson[];
|
|
900
|
+
readonly language: string;
|
|
901
|
+
readonly busy: boolean;
|
|
902
|
+
readonly onGrant: (input: RestPluginGrantInput) => void;
|
|
903
|
+
readonly onRevoke: (id: string) => void;
|
|
904
|
+
}) {
|
|
905
|
+
const settings = useWords().plugins.settings;
|
|
906
|
+
const { detail } = props;
|
|
907
|
+
const grants = detail.grants ?? [];
|
|
908
|
+
return (
|
|
909
|
+
<section className="sc-panel" aria-label={settings.roles}>
|
|
910
|
+
<h2 className="sc-section-title">{settings.roles}</h2>
|
|
911
|
+
{detail.declaredRoles.length === 0 ? (
|
|
912
|
+
<p className="sc-settings-hint">{settings.rolesNone}</p>
|
|
913
|
+
) : (
|
|
914
|
+
<ul className="sc-plugin-roles">
|
|
915
|
+
{detail.declaredRoles.map((role) => (
|
|
916
|
+
<RoleRow
|
|
917
|
+
key={role.key}
|
|
918
|
+
role={role}
|
|
919
|
+
isDefault={detail.defaultRole === role.key}
|
|
920
|
+
grants={grants.filter((grant) => grant.role === role.key)}
|
|
921
|
+
projects={props.projects}
|
|
922
|
+
people={props.people}
|
|
923
|
+
language={props.language}
|
|
924
|
+
busy={props.busy}
|
|
925
|
+
onGrant={props.onGrant}
|
|
926
|
+
onRevoke={props.onRevoke}
|
|
927
|
+
/>
|
|
928
|
+
))}
|
|
929
|
+
</ul>
|
|
930
|
+
)}
|
|
931
|
+
</section>
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** One plugin, whole, for an administrator. */
|
|
936
|
+
export function PluginSettingsView(props: PluginSettingsViewProps) {
|
|
937
|
+
const words = useWords();
|
|
938
|
+
const settings = words.plugins.settings;
|
|
939
|
+
const { detail, language, client } = props;
|
|
940
|
+
const { busy, error, act } = useAction();
|
|
941
|
+
const plugin = useMemo(() => client.plugin(detail.key), [client, detail.key]);
|
|
942
|
+
const [events, setEvents] = useState<readonly RestEvent[] | undefined>(undefined);
|
|
943
|
+
const [confirm, setConfirm] = useState("");
|
|
944
|
+
const [purged, setPurged] = useState(false);
|
|
945
|
+
// The plugin's events, read once the page is shown and again after every change.
|
|
946
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the detail as read again is the trigger
|
|
947
|
+
useEffect(() => {
|
|
948
|
+
const controller = new AbortController();
|
|
949
|
+
plugin
|
|
950
|
+
.events({ limit: 50 }, controller.signal)
|
|
951
|
+
.then((page) => {
|
|
952
|
+
if (!controller.signal.aborted) {
|
|
953
|
+
setEvents([...page.items].reverse());
|
|
954
|
+
}
|
|
955
|
+
})
|
|
956
|
+
.catch(() => undefined);
|
|
957
|
+
return () => controller.abort();
|
|
958
|
+
}, [plugin, detail]);
|
|
959
|
+
const change = (work: () => Promise<unknown>) =>
|
|
960
|
+
void act(async () => {
|
|
961
|
+
await work();
|
|
962
|
+
await props.onChanged();
|
|
963
|
+
});
|
|
964
|
+
const enabled = detail.state === "enabled";
|
|
965
|
+
const canEnable =
|
|
966
|
+
detail.state === "installed" || detail.state === "disabled" || detail.state === "needs_consent";
|
|
967
|
+
return (
|
|
968
|
+
<>
|
|
969
|
+
<div className="sc-page-head">
|
|
970
|
+
<div className="sc-page-heading">
|
|
971
|
+
<h1 className="sc-page-title">{pluginWord(detail.name, language)}</h1>
|
|
972
|
+
<Badge tone={STATE_TONES[detail.state]}>
|
|
973
|
+
{words.vocabulary.pluginState[detail.state] ?? detail.state}
|
|
974
|
+
</Badge>
|
|
975
|
+
<span className="sc-muted">
|
|
976
|
+
<code>{detail.key}</code> {detail.version}
|
|
977
|
+
</span>
|
|
978
|
+
</div>
|
|
979
|
+
{enabled && (
|
|
980
|
+
<div className="sc-page-actions">
|
|
981
|
+
<Button size="sm" disabled={busy} onClick={() => change(() => plugin.disable())}>
|
|
982
|
+
{settings.disable}
|
|
983
|
+
</Button>
|
|
984
|
+
</div>
|
|
985
|
+
)}
|
|
986
|
+
</div>
|
|
987
|
+
<p className="sc-lead">{pluginWord(detail.description, language)}</p>
|
|
988
|
+
{error !== undefined && <Callout tone="danger">{error}</Callout>}
|
|
989
|
+
{detail.problems.length > 0 && (
|
|
990
|
+
<Callout tone="danger" title={settings.problems}>
|
|
991
|
+
<ul className="sc-plugin-problems">
|
|
992
|
+
{detail.problems.map((problem) => (
|
|
993
|
+
<li key={problem}>{problem}</li>
|
|
994
|
+
))}
|
|
995
|
+
</ul>
|
|
996
|
+
</Callout>
|
|
997
|
+
)}
|
|
998
|
+
<Overview detail={detail} projects={props.projects} language={language} />
|
|
999
|
+
{canEnable && (
|
|
1000
|
+
<section className="sc-panel sc-panel-form" aria-label={settings.enable}>
|
|
1001
|
+
<h2 className="sc-section-title">{settings.enable}</h2>
|
|
1002
|
+
<EnableForm
|
|
1003
|
+
detail={detail}
|
|
1004
|
+
projects={props.projects}
|
|
1005
|
+
language={language}
|
|
1006
|
+
busy={busy}
|
|
1007
|
+
onEnable={(input) => change(() => plugin.enable(input))}
|
|
1008
|
+
/>
|
|
1009
|
+
</section>
|
|
1010
|
+
)}
|
|
1011
|
+
{enabled && detail.settings.length > 0 && (
|
|
1012
|
+
<section className="sc-panel sc-panel-form" aria-label={settings.settingsForm}>
|
|
1013
|
+
<h2 className="sc-section-title">{settings.settingsForm}</h2>
|
|
1014
|
+
<div>
|
|
1015
|
+
<SettingsForm
|
|
1016
|
+
detail={detail}
|
|
1017
|
+
language={language}
|
|
1018
|
+
busy={busy}
|
|
1019
|
+
people={props.people}
|
|
1020
|
+
projects={props.projects}
|
|
1021
|
+
onSave={(values) => change(() => plugin.updateSettings({ values }))}
|
|
1022
|
+
/>
|
|
1023
|
+
{detail.settings
|
|
1024
|
+
.filter((setting) => setting.type === "secret")
|
|
1025
|
+
.map((setting) => (
|
|
1026
|
+
<SecretRow
|
|
1027
|
+
key={setting.key}
|
|
1028
|
+
setting={setting}
|
|
1029
|
+
language={language}
|
|
1030
|
+
busy={busy}
|
|
1031
|
+
onSet={(value) => change(() => plugin.setSecret(setting.key, value))}
|
|
1032
|
+
onRemove={() => change(() => plugin.removeSecret(setting.key))}
|
|
1033
|
+
/>
|
|
1034
|
+
))}
|
|
1035
|
+
</div>
|
|
1036
|
+
</section>
|
|
1037
|
+
)}
|
|
1038
|
+
{detail.grants !== null && (
|
|
1039
|
+
<RolesSection
|
|
1040
|
+
detail={detail}
|
|
1041
|
+
projects={props.projects}
|
|
1042
|
+
people={props.people}
|
|
1043
|
+
language={language}
|
|
1044
|
+
busy={busy}
|
|
1045
|
+
onGrant={(input) => change(() => plugin.grant(input))}
|
|
1046
|
+
onRevoke={(id) => change(() => plugin.revoke(id))}
|
|
1047
|
+
/>
|
|
1048
|
+
)}
|
|
1049
|
+
<section className="sc-panel" aria-label={settings.events}>
|
|
1050
|
+
<h2 className="sc-section-title">{settings.events}</h2>
|
|
1051
|
+
{events === undefined ? (
|
|
1052
|
+
<Spinner label={words.common.loading} />
|
|
1053
|
+
) : (
|
|
1054
|
+
<EventFeed events={events} empty={<p className="sc-muted">{settings.noEvents}</p>} />
|
|
1055
|
+
)}
|
|
1056
|
+
</section>
|
|
1057
|
+
<section className="sc-panel sc-panel-form" aria-label={settings.data}>
|
|
1058
|
+
<h2 className="sc-section-title">{settings.data}</h2>
|
|
1059
|
+
<div>
|
|
1060
|
+
<div className="sc-settings-row">
|
|
1061
|
+
<div className="sc-settings-words">
|
|
1062
|
+
<p className="sc-settings-label">{settings.export}</p>
|
|
1063
|
+
<p className="sc-settings-hint">{settings.exportHint}</p>
|
|
1064
|
+
</div>
|
|
1065
|
+
<div className="sc-settings-control">
|
|
1066
|
+
<LinkButton href={`/api/v1/plugins/${detail.key}/export`}>
|
|
1067
|
+
{settings.exportLink}
|
|
1068
|
+
</LinkButton>
|
|
1069
|
+
</div>
|
|
1070
|
+
</div>
|
|
1071
|
+
<div className="sc-settings-row">
|
|
1072
|
+
<div className="sc-settings-words">
|
|
1073
|
+
<p className="sc-settings-label">{settings.purge}</p>
|
|
1074
|
+
<p className="sc-settings-hint">{settings.purgeHint}</p>
|
|
1075
|
+
</div>
|
|
1076
|
+
<div className="sc-settings-control">
|
|
1077
|
+
{purged ? (
|
|
1078
|
+
<p className="sc-settings-hint">{settings.purged}</p>
|
|
1079
|
+
) : enabled ? (
|
|
1080
|
+
<p className="sc-settings-hint">{settings.purgeDisabledFirst}</p>
|
|
1081
|
+
) : (
|
|
1082
|
+
<form
|
|
1083
|
+
className="sc-plugin-line"
|
|
1084
|
+
onSubmit={(event) => {
|
|
1085
|
+
event.preventDefault();
|
|
1086
|
+
void act(async () => {
|
|
1087
|
+
await plugin.purge(confirm);
|
|
1088
|
+
setPurged(true);
|
|
1089
|
+
props.onPurged();
|
|
1090
|
+
});
|
|
1091
|
+
}}
|
|
1092
|
+
>
|
|
1093
|
+
<input
|
|
1094
|
+
className="sc-input sc-plugin-line-input"
|
|
1095
|
+
value={confirm}
|
|
1096
|
+
placeholder={settings.purgeConfirm(detail.key)}
|
|
1097
|
+
aria-label={settings.purgeConfirm(detail.key)}
|
|
1098
|
+
onChange={(event) => setConfirm(event.target.value)}
|
|
1099
|
+
/>
|
|
1100
|
+
<Button type="submit" variant="danger" disabled={busy || confirm !== detail.key}>
|
|
1101
|
+
{settings.purge}
|
|
1102
|
+
</Button>
|
|
1103
|
+
</form>
|
|
1104
|
+
)}
|
|
1105
|
+
</div>
|
|
1106
|
+
</div>
|
|
1107
|
+
</div>
|
|
1108
|
+
</section>
|
|
1109
|
+
</>
|
|
1110
|
+
);
|
|
1111
|
+
}
|