@skillcdn/console 0.1.6 → 0.1.7
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 +50 -2
- package/SKILLCDN.md +29 -0
- package/dist/cli/cli.d.ts +3 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +61 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +5 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +2 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/serve.d.ts +37 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +218 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/console.d.ts +6 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +78 -13
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +5 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +4 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +4 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +4 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/schemas.d.ts +1 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +5 -0
- package/dist/schemas.js.map +1 -1
- package/dist/web.d.ts +16 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +149 -0
- package/dist/web.js.map +1 -0
- package/docs/specs/cli.md +92 -0
- package/docs/specs/rest.md +220 -0
- package/package.json +14 -3
- package/skills/building-a-console/SKILL.md +79 -0
- package/skills/working-the-board/SKILL.md +78 -0
- package/src/api.ts +235 -0
- package/src/cli/cli.ts +1114 -0
- package/src/cli/credentials.ts +68 -0
- package/src/cli/directory.ts +59 -0
- package/src/cli/format.ts +284 -0
- package/src/cli/help.ts +326 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/serve.ts +276 -0
- package/src/cli/terminal.ts +28 -0
- package/src/client.ts +533 -0
- package/src/components/board.tsx +112 -0
- package/src/components/connect.tsx +126 -0
- package/src/components/decision-form.tsx +136 -0
- package/src/components/decision-list.tsx +278 -0
- package/src/components/documents.tsx +569 -0
- package/src/components/event-feed.tsx +129 -0
- package/src/components/markdown.tsx +72 -0
- package/src/components/people.tsx +59 -0
- package/src/components/projects.tsx +364 -0
- package/src/components/runs.tsx +175 -0
- package/src/components/shell.tsx +284 -0
- package/src/components/sign-in.tsx +61 -0
- package/src/components/skills.tsx +78 -0
- package/src/components/task-form.tsx +229 -0
- package/src/components/task-view.tsx +272 -0
- package/src/components/tokens.tsx +223 -0
- package/src/components/ui.tsx +219 -0
- package/src/connect-page.tsx +148 -0
- package/src/connect.ts +27 -0
- package/src/console.tsx +1166 -0
- package/src/data.ts +493 -0
- package/src/docs-page.tsx +334 -0
- package/src/documents.ts +76 -0
- package/src/i18n/en.ts +537 -0
- package/src/i18n/index.ts +76 -0
- package/src/i18n/ko.ts +511 -0
- package/src/index.ts +142 -0
- package/src/limits.ts +61 -0
- package/src/router.ts +295 -0
- package/src/routes.ts +106 -0
- package/src/schemas.ts +776 -0
- package/src/styles/console.css +2432 -0
- package/src/text.ts +33 -0
- package/src/use-action.ts +37 -0
- package/src/vocabulary.ts +109 -0
- package/src/web.ts +170 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { normalizeConnectCode, type RestConnectRequest, type RestTokenInput } from "../api.js";
|
|
3
|
+
import { useWords } from "../i18n/index.js";
|
|
4
|
+
import { TokenForm } from "./tokens.js";
|
|
5
|
+
import { Button, Callout, Time } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
// Connecting an agent (ADR-0011): where a person types the code their agent showed, what asks
|
|
8
|
+
// to connect under a code with the way to approve it, and the words that tell a person what to
|
|
9
|
+
// say to their agent. Each takes its data as props and nothing from the network.
|
|
10
|
+
|
|
11
|
+
export interface ConnectCodeFormProps {
|
|
12
|
+
readonly busy?: boolean | undefined;
|
|
13
|
+
/** Called with the code as it is written, once what was typed is one. */
|
|
14
|
+
readonly onSubmit: (code: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Where a person types the code their agent showed. */
|
|
18
|
+
export function ConnectCodeForm(props: ConnectCodeFormProps) {
|
|
19
|
+
const words = useWords().connect;
|
|
20
|
+
const [text, setText] = useState("");
|
|
21
|
+
const code = normalizeConnectCode(text);
|
|
22
|
+
return (
|
|
23
|
+
<form
|
|
24
|
+
className="sc-form sc-connect-code"
|
|
25
|
+
aria-label={words.codeAria}
|
|
26
|
+
onSubmit={(event) => {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
if (code !== undefined) {
|
|
29
|
+
props.onSubmit(code);
|
|
30
|
+
}
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<label className="sc-field">
|
|
34
|
+
<span className="sc-field-label">{words.codeLabel}</span>
|
|
35
|
+
<input
|
|
36
|
+
className="sc-input sc-code-input"
|
|
37
|
+
value={text}
|
|
38
|
+
placeholder="ABCD-EFGH"
|
|
39
|
+
autoComplete="off"
|
|
40
|
+
spellCheck={false}
|
|
41
|
+
maxLength={12}
|
|
42
|
+
onChange={(event) => setText(event.target.value)}
|
|
43
|
+
/>
|
|
44
|
+
</label>
|
|
45
|
+
<div className="sc-form-actions">
|
|
46
|
+
<Button
|
|
47
|
+
type="submit"
|
|
48
|
+
variant="primary"
|
|
49
|
+
disabled={code === undefined || props.busy === true}
|
|
50
|
+
>
|
|
51
|
+
{words.continue}
|
|
52
|
+
</Button>
|
|
53
|
+
</div>
|
|
54
|
+
</form>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ConnectApprovalProps {
|
|
59
|
+
readonly request: RestConnectRequest;
|
|
60
|
+
/** The most days a token may be good for here, or nothing when a person chooses. */
|
|
61
|
+
readonly daysAtMost?: number | null | undefined;
|
|
62
|
+
readonly busy?: boolean | undefined;
|
|
63
|
+
readonly error?: string | undefined;
|
|
64
|
+
/** Called with what to call the agent and for how long its token is good. */
|
|
65
|
+
readonly onApprove: (input: RestTokenInput) => void;
|
|
66
|
+
/** Called when the person says the request is not theirs. */
|
|
67
|
+
readonly onDeny: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What asks to connect under a code, and the way to let it, as the person it will act as. */
|
|
71
|
+
export function ConnectApproval(props: ConnectApprovalProps) {
|
|
72
|
+
const words = useWords().connect;
|
|
73
|
+
const { request } = props;
|
|
74
|
+
return (
|
|
75
|
+
<div className="sc-approval">
|
|
76
|
+
<Callout tone="warning" title={words.asks.title}>
|
|
77
|
+
<p>
|
|
78
|
+
{words.asks.callsItself} <strong>{request.agent}</strong>
|
|
79
|
+
</p>
|
|
80
|
+
<p>
|
|
81
|
+
{words.asks.showedCode} <code className="sc-code-shown">{request.code}</code>
|
|
82
|
+
</p>
|
|
83
|
+
<p>{words.asks.ifNot}</p>
|
|
84
|
+
<p>{words.asks.approvedMeans}</p>
|
|
85
|
+
<p className="sc-muted">
|
|
86
|
+
{words.asks.asked} <Time iso={request.createdAt} />; {words.asks.goodUntil}{" "}
|
|
87
|
+
<Time iso={request.expiresAt} />.
|
|
88
|
+
</p>
|
|
89
|
+
</Callout>
|
|
90
|
+
<TokenForm
|
|
91
|
+
name={request.agent}
|
|
92
|
+
daysAtMost={props.daysAtMost}
|
|
93
|
+
busy={props.busy}
|
|
94
|
+
error={props.error}
|
|
95
|
+
submitLabel={words.approve}
|
|
96
|
+
cancelLabel={words.notMine}
|
|
97
|
+
onSubmit={props.onApprove}
|
|
98
|
+
onCancel={props.onDeny}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ConnectWordsProps {
|
|
105
|
+
/** The origin of the console, as a person tells their agent. */
|
|
106
|
+
readonly origin: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What a person says to their agent to connect it, in words for someone who is not a developer. */
|
|
110
|
+
export function ConnectWords(props: ConnectWordsProps) {
|
|
111
|
+
const words = useWords().connect.words;
|
|
112
|
+
return (
|
|
113
|
+
<section className="sc-panel sc-connect" aria-label={words.title}>
|
|
114
|
+
<h2 className="sc-section-title">{words.title}</h2>
|
|
115
|
+
<p>
|
|
116
|
+
{words.tellYourAgent} <q>{words.quote(props.origin)}</q> {words.doesTheRest}
|
|
117
|
+
</p>
|
|
118
|
+
<p>{words.thenApprove}</p>
|
|
119
|
+
<p>{words.byHand}</p>
|
|
120
|
+
<pre className="sc-code">
|
|
121
|
+
npm install -g @skillcdn/console{"\n"}console login --url {props.origin}
|
|
122
|
+
{"\n"}console use {"<project key>"}
|
|
123
|
+
</pre>
|
|
124
|
+
</section>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { type FormEvent, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
MAX_BODY_LENGTH,
|
|
4
|
+
MAX_OPTION_LABEL_LENGTH,
|
|
5
|
+
MAX_OPTIONS,
|
|
6
|
+
MAX_QUESTION_LENGTH,
|
|
7
|
+
MIN_OPTIONS,
|
|
8
|
+
type RestDecisionInput,
|
|
9
|
+
type RestTask,
|
|
10
|
+
} from "../api.js";
|
|
11
|
+
import { useWords } from "../i18n/index.js";
|
|
12
|
+
import { Button } from "./ui.js";
|
|
13
|
+
|
|
14
|
+
// Raising a decision: the question, what a person needs to know, and the options to choose from.
|
|
15
|
+
|
|
16
|
+
export interface DecisionFormProps {
|
|
17
|
+
/** The tasks the decision may be about. */
|
|
18
|
+
readonly tasks: readonly RestTask[];
|
|
19
|
+
/** The task it is about from the start, when raised from a task's page. */
|
|
20
|
+
readonly taskId?: string | undefined;
|
|
21
|
+
readonly busy?: boolean | undefined;
|
|
22
|
+
readonly error?: string | undefined;
|
|
23
|
+
readonly onSubmit: (input: RestDecisionInput) => void;
|
|
24
|
+
readonly onCancel?: (() => void) | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function DecisionForm(props: DecisionFormProps) {
|
|
28
|
+
const words = useWords();
|
|
29
|
+
const form = words.decisions.form;
|
|
30
|
+
const [question, setQuestion] = useState("");
|
|
31
|
+
const [body, setBody] = useState("");
|
|
32
|
+
const [taskId, setTaskId] = useState(props.taskId ?? "");
|
|
33
|
+
const [options, setOptions] = useState<readonly string[]>(["", ""]);
|
|
34
|
+
|
|
35
|
+
const labels = options.map((option) => option.trim()).filter((option) => option.length > 0);
|
|
36
|
+
const ready = question.trim().length > 0 && labels.length >= MIN_OPTIONS;
|
|
37
|
+
|
|
38
|
+
const submit = (event: FormEvent) => {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
props.onSubmit({
|
|
41
|
+
question: question.trim(),
|
|
42
|
+
body,
|
|
43
|
+
options: labels.map((label) => ({ label })),
|
|
44
|
+
taskId: taskId === "" ? null : taskId,
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<form className="sc-form" onSubmit={submit}>
|
|
50
|
+
<label className="sc-field">
|
|
51
|
+
<span className="sc-field-label">{form.question}</span>
|
|
52
|
+
<input
|
|
53
|
+
className="sc-input"
|
|
54
|
+
value={question}
|
|
55
|
+
maxLength={MAX_QUESTION_LENGTH}
|
|
56
|
+
required
|
|
57
|
+
onChange={(event) => setQuestion(event.target.value)}
|
|
58
|
+
/>
|
|
59
|
+
</label>
|
|
60
|
+
<label className="sc-field">
|
|
61
|
+
<span className="sc-field-label">{form.context}</span>
|
|
62
|
+
<textarea
|
|
63
|
+
className="sc-input sc-textarea"
|
|
64
|
+
value={body}
|
|
65
|
+
maxLength={MAX_BODY_LENGTH}
|
|
66
|
+
rows={4}
|
|
67
|
+
placeholder={form.contextPlaceholder}
|
|
68
|
+
onChange={(event) => setBody(event.target.value)}
|
|
69
|
+
/>
|
|
70
|
+
</label>
|
|
71
|
+
{props.taskId === undefined && props.tasks.length > 0 && (
|
|
72
|
+
<label className="sc-field">
|
|
73
|
+
<span className="sc-field-label">{form.about}</span>
|
|
74
|
+
<select
|
|
75
|
+
className="sc-input"
|
|
76
|
+
value={taskId}
|
|
77
|
+
onChange={(event) => setTaskId(event.target.value)}
|
|
78
|
+
>
|
|
79
|
+
<option value="">{form.noTask}</option>
|
|
80
|
+
{props.tasks.map((task) => (
|
|
81
|
+
<option key={task.id} value={task.id}>
|
|
82
|
+
#{task.number} {task.title}
|
|
83
|
+
</option>
|
|
84
|
+
))}
|
|
85
|
+
</select>
|
|
86
|
+
</label>
|
|
87
|
+
)}
|
|
88
|
+
<fieldset className="sc-field sc-options">
|
|
89
|
+
<legend className="sc-field-label">{form.options}</legend>
|
|
90
|
+
{options.map((option, index) => (
|
|
91
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: ordered drafts without ids
|
|
92
|
+
<div key={index} className="sc-link-row">
|
|
93
|
+
<input
|
|
94
|
+
className="sc-input"
|
|
95
|
+
value={option}
|
|
96
|
+
maxLength={MAX_OPTION_LABEL_LENGTH}
|
|
97
|
+
placeholder={form.option(index + 1)}
|
|
98
|
+
onChange={(event) =>
|
|
99
|
+
setOptions(options.map((entry, at) => (at === index ? event.target.value : entry)))
|
|
100
|
+
}
|
|
101
|
+
/>
|
|
102
|
+
{options.length > MIN_OPTIONS && (
|
|
103
|
+
<Button
|
|
104
|
+
variant="ghost"
|
|
105
|
+
size="sm"
|
|
106
|
+
onClick={() => setOptions(options.filter((_, at) => at !== index))}
|
|
107
|
+
>
|
|
108
|
+
{words.common.remove}
|
|
109
|
+
</Button>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
{options.length < MAX_OPTIONS && (
|
|
114
|
+
<Button variant="ghost" size="sm" onClick={() => setOptions([...options, ""])}>
|
|
115
|
+
{form.addOption}
|
|
116
|
+
</Button>
|
|
117
|
+
)}
|
|
118
|
+
</fieldset>
|
|
119
|
+
{props.error !== undefined && (
|
|
120
|
+
<p className="sc-form-error" role="alert">
|
|
121
|
+
{props.error}
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
<div className="sc-form-actions">
|
|
125
|
+
<Button type="submit" variant="primary" disabled={props.busy === true || !ready}>
|
|
126
|
+
{form.raise}
|
|
127
|
+
</Button>
|
|
128
|
+
{props.onCancel !== undefined && (
|
|
129
|
+
<Button variant="ghost" onClick={props.onCancel}>
|
|
130
|
+
{words.common.cancel}
|
|
131
|
+
</Button>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</form>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
MAX_BODY_LENGTH,
|
|
4
|
+
MAX_NOTE_LENGTH,
|
|
5
|
+
type RestAnswerInput,
|
|
6
|
+
type RestDecision,
|
|
7
|
+
type RestDecisionPatch,
|
|
8
|
+
type RestTask,
|
|
9
|
+
} from "../api.js";
|
|
10
|
+
import { useWords } from "../i18n/index.js";
|
|
11
|
+
import { Markdown } from "./markdown.js";
|
|
12
|
+
import { Button, cx, PersonChip, Time } from "./ui.js";
|
|
13
|
+
|
|
14
|
+
// The decisions: the ones that wait, with the way to answer each, and the ones answered, with
|
|
15
|
+
// who answered and when; each a record (ADR-0009) of its context, its answer with the
|
|
16
|
+
// rationale, and what followed. Each takes its data as props and nothing from the network.
|
|
17
|
+
|
|
18
|
+
export interface DecisionCardProps {
|
|
19
|
+
readonly decision: RestDecision;
|
|
20
|
+
/** The task the decision is about, when it is about one and the page knows it. */
|
|
21
|
+
readonly task?: RestTask | undefined;
|
|
22
|
+
readonly taskHref?: ((task: RestTask) => string) | undefined;
|
|
23
|
+
/** Where a document the decision links to is read, by its path. */
|
|
24
|
+
readonly docHref?: ((path: string) => string) | undefined;
|
|
25
|
+
/** Where the decision's own page is; given, the question leads to it. */
|
|
26
|
+
readonly href?: string | undefined;
|
|
27
|
+
/** Called with the person's answer. Left out, a waiting decision cannot be answered here. */
|
|
28
|
+
readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
|
|
29
|
+
/** Called to grow the record, with what followed. Left out, it cannot be written here. */
|
|
30
|
+
readonly onUpdate?: ((decision: RestDecision, patch: RestDecisionPatch) => void) | undefined;
|
|
31
|
+
readonly busy?: boolean | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What followed the decision: shown, and written or changed by whoever may. */
|
|
35
|
+
function Outcome(props: {
|
|
36
|
+
readonly decision: RestDecision;
|
|
37
|
+
readonly docHref: ((path: string) => string) | undefined;
|
|
38
|
+
readonly onUpdate: ((decision: RestDecision, patch: RestDecisionPatch) => void) | undefined;
|
|
39
|
+
readonly busy: boolean | undefined;
|
|
40
|
+
}) {
|
|
41
|
+
const words = useWords();
|
|
42
|
+
const { decision } = props;
|
|
43
|
+
const [writing, setWriting] = useState(false);
|
|
44
|
+
const [draft, setDraft] = useState(decision.outcome ?? "");
|
|
45
|
+
if (decision.outcome === null && props.onUpdate === undefined) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return (
|
|
49
|
+
<section className="sc-decision-section" aria-label={words.decisions.whatFollowed}>
|
|
50
|
+
<h4 className="sc-decision-label">{words.decisions.whatFollowed}</h4>
|
|
51
|
+
{writing ? (
|
|
52
|
+
<form
|
|
53
|
+
className="sc-outcome-form"
|
|
54
|
+
onSubmit={(event) => {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
props.onUpdate?.(decision, { outcome: draft });
|
|
57
|
+
setWriting(false);
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<textarea
|
|
61
|
+
className="sc-input sc-textarea"
|
|
62
|
+
value={draft}
|
|
63
|
+
maxLength={MAX_BODY_LENGTH}
|
|
64
|
+
rows={4}
|
|
65
|
+
placeholder={words.decisions.outcomePlaceholder}
|
|
66
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
67
|
+
/>
|
|
68
|
+
<div className="sc-form-actions">
|
|
69
|
+
<Button type="submit" variant="primary" size="sm" disabled={props.busy === true}>
|
|
70
|
+
{words.common.save}
|
|
71
|
+
</Button>
|
|
72
|
+
<Button variant="ghost" size="sm" onClick={() => setWriting(false)}>
|
|
73
|
+
{words.common.cancel}
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
</form>
|
|
77
|
+
) : (
|
|
78
|
+
<>
|
|
79
|
+
{decision.outcome === null ? (
|
|
80
|
+
<p className="sc-muted">{words.decisions.nothingWritten}</p>
|
|
81
|
+
) : (
|
|
82
|
+
<Markdown source={decision.outcome} docHref={props.docHref} />
|
|
83
|
+
)}
|
|
84
|
+
{props.onUpdate !== undefined && (
|
|
85
|
+
<Button
|
|
86
|
+
variant="ghost"
|
|
87
|
+
size="sm"
|
|
88
|
+
disabled={props.busy === true}
|
|
89
|
+
onClick={() => {
|
|
90
|
+
setDraft(decision.outcome ?? "");
|
|
91
|
+
setWriting(true);
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{decision.outcome === null
|
|
95
|
+
? words.decisions.writeWhatFollowed
|
|
96
|
+
: words.decisions.changeIt}
|
|
97
|
+
</Button>
|
|
98
|
+
)}
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
</section>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function DecisionCard(props: DecisionCardProps) {
|
|
106
|
+
const words = useWords();
|
|
107
|
+
const { decision, task } = props;
|
|
108
|
+
const [chosen, setChosen] = useState<string | undefined>(undefined);
|
|
109
|
+
const [note, setNote] = useState("");
|
|
110
|
+
const answered = decision.answer !== null;
|
|
111
|
+
const chosenLabel = (id: string) =>
|
|
112
|
+
decision.options.find((option) => option.id === id)?.label ?? id;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<article id={decision.id} className={cx("sc-decision", answered && "sc-decision-answered")}>
|
|
116
|
+
<header className="sc-decision-header">
|
|
117
|
+
<h3 className="sc-decision-question">
|
|
118
|
+
{props.href === undefined ? (
|
|
119
|
+
decision.question
|
|
120
|
+
) : (
|
|
121
|
+
<a href={props.href}>{decision.question}</a>
|
|
122
|
+
)}
|
|
123
|
+
</h3>
|
|
124
|
+
<p className="sc-decision-meta">
|
|
125
|
+
<PersonChip person={decision.raisedBy} />{" "}
|
|
126
|
+
{decision.run === null
|
|
127
|
+
? words.decisions.asked
|
|
128
|
+
: words.decisions.askedThrough(decision.run.agent)}{" "}
|
|
129
|
+
<Time iso={decision.createdAt} />
|
|
130
|
+
{task !== undefined && (
|
|
131
|
+
<>
|
|
132
|
+
{" "}
|
|
133
|
+
{words.decisions.about}{" "}
|
|
134
|
+
{props.taskHref === undefined ? (
|
|
135
|
+
<span>
|
|
136
|
+
#{task.number} {task.title}
|
|
137
|
+
</span>
|
|
138
|
+
) : (
|
|
139
|
+
<a href={props.taskHref(task)}>
|
|
140
|
+
#{task.number} {task.title}
|
|
141
|
+
</a>
|
|
142
|
+
)}
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
</p>
|
|
146
|
+
</header>
|
|
147
|
+
{decision.body.length > 0 && (
|
|
148
|
+
<section className="sc-decision-section" aria-label={words.decisions.context}>
|
|
149
|
+
<h4 className="sc-decision-label">{words.decisions.context}</h4>
|
|
150
|
+
<Markdown source={decision.body} docHref={props.docHref} />
|
|
151
|
+
</section>
|
|
152
|
+
)}
|
|
153
|
+
{decision.answer !== null ? (
|
|
154
|
+
<div className="sc-answer">
|
|
155
|
+
<p className="sc-answer-option">{chosenLabel(decision.answer.option)}</p>
|
|
156
|
+
{decision.answer.note !== null && (
|
|
157
|
+
<div className="sc-answer-note">
|
|
158
|
+
<Markdown source={decision.answer.note} docHref={props.docHref} />
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
<p className="sc-decision-meta">
|
|
162
|
+
<PersonChip person={decision.answer.by} /> {words.decisions.answeredBy}{" "}
|
|
163
|
+
<Time iso={decision.answer.at} />
|
|
164
|
+
</p>
|
|
165
|
+
</div>
|
|
166
|
+
) : props.onAnswer === undefined ? (
|
|
167
|
+
<p className="sc-decision-waiting">{words.decisions.waitingForPerson}</p>
|
|
168
|
+
) : (
|
|
169
|
+
<form
|
|
170
|
+
className="sc-answer-form"
|
|
171
|
+
onSubmit={(event) => {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
if (chosen !== undefined) {
|
|
174
|
+
props.onAnswer?.(decision, {
|
|
175
|
+
option: chosen,
|
|
176
|
+
...(note.trim() === "" ? {} : { note: note.trim() }),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
<div className="sc-options-list" role="radiogroup" aria-label={words.decisions.options}>
|
|
182
|
+
{decision.options.map((option) => (
|
|
183
|
+
<label
|
|
184
|
+
key={option.id}
|
|
185
|
+
className={cx("sc-option", chosen === option.id && "sc-option-chosen")}
|
|
186
|
+
>
|
|
187
|
+
<input
|
|
188
|
+
type="radio"
|
|
189
|
+
name={`decision-${decision.id}`}
|
|
190
|
+
value={option.id}
|
|
191
|
+
checked={chosen === option.id}
|
|
192
|
+
onChange={() => setChosen(option.id)}
|
|
193
|
+
/>
|
|
194
|
+
<span>{option.label}</span>
|
|
195
|
+
</label>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
<input
|
|
199
|
+
className="sc-input"
|
|
200
|
+
placeholder={words.decisions.notePlaceholder}
|
|
201
|
+
maxLength={MAX_NOTE_LENGTH}
|
|
202
|
+
value={note}
|
|
203
|
+
onChange={(event) => setNote(event.target.value)}
|
|
204
|
+
/>
|
|
205
|
+
<div className="sc-form-actions">
|
|
206
|
+
<Button
|
|
207
|
+
type="submit"
|
|
208
|
+
variant="primary"
|
|
209
|
+
size="sm"
|
|
210
|
+
disabled={chosen === undefined || props.busy === true}
|
|
211
|
+
>
|
|
212
|
+
{words.decisions.answer}
|
|
213
|
+
</Button>
|
|
214
|
+
</div>
|
|
215
|
+
</form>
|
|
216
|
+
)}
|
|
217
|
+
{answered && (
|
|
218
|
+
<Outcome
|
|
219
|
+
decision={decision}
|
|
220
|
+
docHref={props.docHref}
|
|
221
|
+
onUpdate={props.onUpdate}
|
|
222
|
+
busy={props.busy}
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
225
|
+
</article>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface DecisionListProps {
|
|
230
|
+
readonly decisions: readonly RestDecision[];
|
|
231
|
+
/** The tasks the decisions may be about, by id, for the cards to name them. */
|
|
232
|
+
readonly tasks: ReadonlyMap<string, RestTask>;
|
|
233
|
+
readonly taskHref?: ((task: RestTask) => string) | undefined;
|
|
234
|
+
readonly docHref?: ((path: string) => string) | undefined;
|
|
235
|
+
/** Where each decision's own page is. */
|
|
236
|
+
readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
|
|
237
|
+
readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
|
|
238
|
+
readonly onUpdate?: ((decision: RestDecision, patch: RestDecisionPatch) => void) | undefined;
|
|
239
|
+
readonly busy?: boolean | undefined;
|
|
240
|
+
readonly empty?: React.ReactNode;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function DecisionList(props: DecisionListProps) {
|
|
244
|
+
const words = useWords();
|
|
245
|
+
const waiting = props.decisions.filter((decision) => decision.answer === null);
|
|
246
|
+
const answered = props.decisions.filter((decision) => decision.answer !== null);
|
|
247
|
+
const card = (decision: RestDecision) => (
|
|
248
|
+
<DecisionCard
|
|
249
|
+
key={decision.id}
|
|
250
|
+
decision={decision}
|
|
251
|
+
task={decision.taskId === null ? undefined : props.tasks.get(decision.taskId)}
|
|
252
|
+
taskHref={props.taskHref}
|
|
253
|
+
docHref={props.docHref}
|
|
254
|
+
href={props.decisionHref?.(decision)}
|
|
255
|
+
onAnswer={props.onAnswer}
|
|
256
|
+
onUpdate={props.onUpdate}
|
|
257
|
+
busy={props.busy}
|
|
258
|
+
/>
|
|
259
|
+
);
|
|
260
|
+
return (
|
|
261
|
+
<div className="sc-decisions">
|
|
262
|
+
<section aria-label={words.decisions.waiting}>
|
|
263
|
+
<h2 className="sc-section-title">
|
|
264
|
+
{words.decisions.waiting} <span className="sc-column-count">{waiting.length}</span>
|
|
265
|
+
</h2>
|
|
266
|
+
{waiting.length === 0 ? (props.empty ?? null) : waiting.map(card)}
|
|
267
|
+
</section>
|
|
268
|
+
{answered.length > 0 && (
|
|
269
|
+
<section aria-label={words.decisions.answered}>
|
|
270
|
+
<h2 className="sc-section-title">
|
|
271
|
+
{words.decisions.answered} <span className="sc-column-count">{answered.length}</span>
|
|
272
|
+
</h2>
|
|
273
|
+
{answered.map(card)}
|
|
274
|
+
</section>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|