@skillcdn/console 0.3.2 → 0.4.1
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 +4 -3
- package/dist/api.d.ts +2 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2 -2
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +132 -18
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +6 -4
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +72 -21
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/pack.d.ts +7 -2
- package/dist/cli/pack.d.ts.map +1 -1
- package/dist/cli/pack.js +16 -2
- package/dist/cli/pack.js.map +1 -1
- package/dist/client.d.ts +19 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -1
- 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 +12 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +52 -4
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +4 -0
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +61 -22
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/decision-form.d.ts +12 -4
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +24 -6
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/decision-list.d.ts +4 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +9 -6
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/documents.d.ts.map +1 -1
- package/dist/components/documents.js +3 -3
- package/dist/components/documents.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +63 -8
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +6 -3
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/my-decisions.d.ts +20 -0
- package/dist/components/my-decisions.d.ts.map +1 -0
- package/dist/components/my-decisions.js +36 -0
- package/dist/components/my-decisions.js.map +1 -0
- package/dist/components/pack-connect.d.ts +3 -1
- package/dist/components/pack-connect.d.ts.map +1 -1
- package/dist/components/pack-connect.js +4 -4
- package/dist/components/pack-connect.js.map +1 -1
- package/dist/components/pack-token.d.ts +3 -1
- package/dist/components/pack-token.d.ts.map +1 -1
- package/dist/components/pack-token.js +1 -1
- package/dist/components/pack-token.js.map +1 -1
- package/dist/components/people.d.ts.map +1 -1
- package/dist/components/people.js +14 -3
- package/dist/components/people.js.map +1 -1
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +3 -3
- package/dist/components/projects.js.map +1 -1
- package/dist/components/remote-pictures.d.ts +6 -0
- package/dist/components/remote-pictures.d.ts.map +1 -0
- package/dist/components/remote-pictures.js +8 -0
- package/dist/components/remote-pictures.js.map +1 -0
- package/dist/components/roadmap.js +1 -1
- package/dist/components/roadmap.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +1 -1
- package/dist/components/runs.js.map +1 -1
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +36 -1
- package/dist/components/shell.js.map +1 -1
- package/dist/components/sign-in.d.ts.map +1 -1
- package/dist/components/sign-in.js +49 -2
- package/dist/components/sign-in.js.map +1 -1
- package/dist/components/task-list.js +1 -1
- package/dist/components/task-list.js.map +1 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +2 -2
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +13 -2
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +21 -14
- package/dist/components/ui.js.map +1 -1
- package/dist/console.css +1686 -425
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +83 -13
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +15 -2
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +18 -19
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +48 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +50 -3
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +47 -4
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +14 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +14 -0
- package/dist/limits.js.map +1 -1
- package/dist/router.d.ts +9 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +12 -0
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +5 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +5 -1
- package/dist/routes.js.map +1 -1
- package/dist/rules.js +1 -1
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +272 -4
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +33 -8
- package/dist/schemas.js.map +1 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +3 -2
- package/dist/web.js.map +1 -1
- package/docs/specs/cli.md +11 -10
- package/docs/specs/rest.md +33 -17
- package/package.json +2 -2
- package/skills/building-a-console/SKILL.md +5 -5
- package/skills/building-a-console/references/the-default-console.md +4 -4
- package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
- package/skills/working-the-board/SKILL.md +6 -4
- package/skills/working-the-board/references/setting-up.md +1 -1
- package/skills/working-the-board/references/terms.md +1 -1
- package/src/api.ts +10 -0
- package/src/cli/cli.ts +162 -19
- package/src/cli/format.ts +11 -4
- package/src/cli/help.ts +79 -20
- package/src/cli/pack.ts +17 -1
- package/src/client.ts +51 -2
- package/src/compat.ts +2 -2
- package/src/components/board.tsx +114 -21
- package/src/components/calendar.tsx +207 -102
- package/src/components/decision-form.tsx +71 -23
- package/src/components/decision-list.tsx +30 -3
- package/src/components/documents.tsx +27 -11
- package/src/components/event-feed.tsx +116 -33
- package/src/components/markdown.tsx +6 -3
- package/src/components/my-decisions.tsx +138 -0
- package/src/components/pack-connect.tsx +10 -7
- package/src/components/pack-token.tsx +4 -1
- package/src/components/people.tsx +20 -8
- package/src/components/projects.tsx +7 -5
- package/src/components/remote-pictures.ts +19 -0
- package/src/components/roadmap.tsx +1 -1
- package/src/components/runs.tsx +5 -1
- package/src/components/shell.tsx +36 -1
- package/src/components/sign-in.tsx +114 -13
- package/src/components/task-list.tsx +1 -1
- package/src/components/task-view.tsx +48 -47
- package/src/components/ui.tsx +70 -14
- package/src/console.tsx +153 -32
- package/src/data.ts +37 -20
- package/src/i18n/en.ts +53 -3
- package/src/i18n/ko.ts +50 -4
- package/src/index.ts +3 -0
- package/src/limits.ts +14 -0
- package/src/router.ts +18 -1
- package/src/routes.ts +5 -1
- package/src/rules.ts +1 -1
- package/src/schemas.ts +37 -8
- package/src/styles/console.css +1686 -425
- package/src/web.ts +3 -2
|
@@ -440,12 +440,21 @@ export function DocumentFiles(props: DocumentFilesProps) {
|
|
|
440
440
|
}
|
|
441
441
|
}}
|
|
442
442
|
>
|
|
443
|
-
<
|
|
444
|
-
className="sc-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
443
|
+
<div className="sc-doc-attach-file">
|
|
444
|
+
<label className="sc-button sc-button-secondary sc-file-choice">
|
|
445
|
+
{words.chooseFile}
|
|
446
|
+
<input
|
|
447
|
+
key={chosen === undefined ? "none" : "some"}
|
|
448
|
+
className="sc-file-input"
|
|
449
|
+
type="file"
|
|
450
|
+
aria-label={words.fileToAttach}
|
|
451
|
+
onChange={(event) => setChosen(event.target.files?.[0] ?? undefined)}
|
|
452
|
+
/>
|
|
453
|
+
</label>
|
|
454
|
+
<span className={cx("sc-doc-attach-chosen", chosen === undefined && "sc-muted")}>
|
|
455
|
+
{chosen === undefined ? words.noFileChosen : chosen.name}
|
|
456
|
+
</span>
|
|
457
|
+
</div>
|
|
449
458
|
<input
|
|
450
459
|
className="sc-input"
|
|
451
460
|
placeholder={words.labelPlaceholder}
|
|
@@ -453,7 +462,11 @@ export function DocumentFiles(props: DocumentFilesProps) {
|
|
|
453
462
|
value={label}
|
|
454
463
|
onChange={(event) => setLabel(event.target.value)}
|
|
455
464
|
/>
|
|
456
|
-
<Button
|
|
465
|
+
<Button
|
|
466
|
+
type="submit"
|
|
467
|
+
variant="primary"
|
|
468
|
+
disabled={chosen === undefined || props.busy === true}
|
|
469
|
+
>
|
|
457
470
|
{words.attach}
|
|
458
471
|
</Button>
|
|
459
472
|
</form>
|
|
@@ -555,7 +568,7 @@ export function DocumentView(props: DocumentViewProps) {
|
|
|
555
568
|
</dd>
|
|
556
569
|
</div>
|
|
557
570
|
<div>
|
|
558
|
-
<dt>{docs.
|
|
571
|
+
<dt>{docs.author}</dt>
|
|
559
572
|
<dd>
|
|
560
573
|
<Writer by={shown.author} agent={shown.agent} /> <Time iso={shown.createdAt} />
|
|
561
574
|
</dd>
|
|
@@ -641,9 +654,12 @@ export function DocumentView(props: DocumentViewProps) {
|
|
|
641
654
|
>
|
|
642
655
|
{docs.version(entry.number)}
|
|
643
656
|
</a>
|
|
644
|
-
)}
|
|
645
|
-
|
|
646
|
-
<
|
|
657
|
+
)}
|
|
658
|
+
<span className="sc-version-title">{entry.title}</span>
|
|
659
|
+
<span className="sc-version-meta">
|
|
660
|
+
<Writer by={entry.author} agent={entry.agent} />{" "}
|
|
661
|
+
<Time iso={entry.createdAt} />
|
|
662
|
+
</span>
|
|
647
663
|
</li>
|
|
648
664
|
))}
|
|
649
665
|
</ol>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { agentLabel, type RestEvent } from "../api.js";
|
|
3
|
+
import { dayOf } from "../dates.js";
|
|
3
4
|
import { en, type Messages } from "../i18n/en.js";
|
|
4
|
-
import { useWords } from "../i18n/index.js";
|
|
5
|
+
import { useLanguage, useWords } from "../i18n/index.js";
|
|
5
6
|
import { ShowMore, usePaged } from "./paged.js";
|
|
6
|
-
import {
|
|
7
|
+
import { formatInstant, PersonChip } from "./ui.js";
|
|
7
8
|
|
|
8
9
|
// The feed: one line per event, newest first, as a sentence a person reads at a glance: who,
|
|
9
10
|
// as which agent when through one, and what. It takes its data as props and nothing from the
|
|
@@ -103,7 +104,9 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
|
|
|
103
104
|
? feed.event.runFinished(task)
|
|
104
105
|
: data.status === "failed"
|
|
105
106
|
? feed.event.runFailed(task)
|
|
106
|
-
:
|
|
107
|
+
: data.reason === "unheard"
|
|
108
|
+
? feed.event.runUnheard(task, data.agent ?? feed.someone)
|
|
109
|
+
: feed.event.runAbandoned(task);
|
|
107
110
|
case "document.written":
|
|
108
111
|
return feed.event.documentWritten(page, data.version);
|
|
109
112
|
case "document.archived":
|
|
@@ -123,8 +126,65 @@ export interface EventFeedProps {
|
|
|
123
126
|
readonly empty?: ReactNode;
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
/** The day an event fell on, by this machine's clock, as the calendar spells a day. */
|
|
130
|
+
function dayOfEvent(event: RestEvent): string {
|
|
131
|
+
const date = new Date(event.createdAt);
|
|
132
|
+
return Number.isNaN(date.getTime()) ? event.createdAt : dayOf(date);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A day as the feed heads it: the weekday and the date, with the year where it is not this one. */
|
|
136
|
+
function formatFeedDay(day: string, language: string, now: Date = new Date()): string {
|
|
137
|
+
const date = new Date(`${day}T00:00:00`);
|
|
138
|
+
if (Number.isNaN(date.getTime())) {
|
|
139
|
+
return day;
|
|
140
|
+
}
|
|
141
|
+
return date.toLocaleDateString(language, {
|
|
142
|
+
weekday: "short",
|
|
143
|
+
month: "long",
|
|
144
|
+
day: "numeric",
|
|
145
|
+
...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether two events were done by the same person as the same agent, so the name is said once. */
|
|
150
|
+
function sameHands(a: RestEvent, b: RestEvent): boolean {
|
|
151
|
+
return (a.actor?.id ?? null) === (b.actor?.id ?? null) && a.agent === b.agent;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The hour of an event, for a row whose day is in the heading above it. */
|
|
155
|
+
function formatClock(iso: string, language: string): string {
|
|
156
|
+
const date = new Date(iso);
|
|
157
|
+
return Number.isNaN(date.getTime())
|
|
158
|
+
? iso
|
|
159
|
+
: date.toLocaleTimeString(language, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type FeedRun = { readonly actor: RestEvent; readonly events: RestEvent[] };
|
|
163
|
+
type FeedDay = { readonly day: string; readonly runs: FeedRun[] };
|
|
164
|
+
|
|
165
|
+
/** The events by the day they fell on, and within a day by runs of the same hands; newest first. */
|
|
166
|
+
function groupFeed(events: readonly RestEvent[]): FeedDay[] {
|
|
167
|
+
const days: FeedDay[] = [];
|
|
168
|
+
for (const event of events) {
|
|
169
|
+
const day = dayOfEvent(event);
|
|
170
|
+
let last = days[days.length - 1];
|
|
171
|
+
if (last === undefined || last.day !== day) {
|
|
172
|
+
last = { day, runs: [] };
|
|
173
|
+
days.push(last);
|
|
174
|
+
}
|
|
175
|
+
const run = last.runs[last.runs.length - 1];
|
|
176
|
+
if (run !== undefined && sameHands(run.actor, event)) {
|
|
177
|
+
run.events.push(event);
|
|
178
|
+
} else {
|
|
179
|
+
last.runs.push({ actor: event, events: [event] });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return days;
|
|
183
|
+
}
|
|
184
|
+
|
|
126
185
|
export function EventFeed(props: EventFeedProps) {
|
|
127
186
|
const words = useWords();
|
|
187
|
+
const { tag } = useLanguage();
|
|
128
188
|
const newestFirst = [...props.events].reverse();
|
|
129
189
|
const { shown, rest, more } = usePaged(newestFirst);
|
|
130
190
|
if (props.events.length === 0) {
|
|
@@ -132,37 +192,60 @@ export function EventFeed(props: EventFeedProps) {
|
|
|
132
192
|
}
|
|
133
193
|
return (
|
|
134
194
|
<>
|
|
135
|
-
<
|
|
136
|
-
{shown.map((
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
195
|
+
<section className="sc-feed" aria-label={words.feed.list}>
|
|
196
|
+
{groupFeed(shown).map((group) => (
|
|
197
|
+
<section key={group.day} className="sc-feed-day">
|
|
198
|
+
<h3 className="sc-feed-day-title">
|
|
199
|
+
<time dateTime={group.day}>{formatFeedDay(group.day, tag)}</time>
|
|
200
|
+
</h3>
|
|
201
|
+
<ol className="sc-feed-day-list">
|
|
202
|
+
{group.runs.map((run) => (
|
|
203
|
+
<li key={run.actor.id} className="sc-feed-run">
|
|
204
|
+
<span className="sc-feed-actor">
|
|
205
|
+
{run.actor.actor === null ? (
|
|
206
|
+
<span className="sc-person-login">{words.feed.console}</span>
|
|
207
|
+
) : (
|
|
208
|
+
<PersonChip person={run.actor.actor} />
|
|
209
|
+
)}
|
|
210
|
+
{run.actor.agent !== null && (
|
|
211
|
+
<span
|
|
212
|
+
className="sc-feed-agent"
|
|
213
|
+
title={
|
|
214
|
+
agentLabel(run.actor.agent) === run.actor.agent
|
|
215
|
+
? words.feed.asTitle
|
|
216
|
+
: run.actor.agent
|
|
217
|
+
}
|
|
218
|
+
>
|
|
219
|
+
{words.feed.as(agentLabel(run.actor.agent))}
|
|
220
|
+
</span>
|
|
221
|
+
)}
|
|
155
222
|
</span>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
223
|
+
<ol className="sc-feed-run-list">
|
|
224
|
+
{run.events.map((event) => {
|
|
225
|
+
const href = props.href?.(event);
|
|
226
|
+
const said = describeEvent(event, words);
|
|
227
|
+
return (
|
|
228
|
+
<li key={event.id} className="sc-feed-item">
|
|
229
|
+
<time
|
|
230
|
+
className="sc-time sc-feed-clock"
|
|
231
|
+
dateTime={event.createdAt}
|
|
232
|
+
title={formatInstant(event.createdAt, tag)}
|
|
233
|
+
>
|
|
234
|
+
{formatClock(event.createdAt, tag)}
|
|
235
|
+
</time>
|
|
236
|
+
<span className="sc-feed-words">
|
|
237
|
+
{href === undefined ? said : <a href={href}>{said}</a>}
|
|
238
|
+
</span>
|
|
239
|
+
</li>
|
|
240
|
+
);
|
|
241
|
+
})}
|
|
242
|
+
</ol>
|
|
243
|
+
</li>
|
|
244
|
+
))}
|
|
245
|
+
</ol>
|
|
246
|
+
</section>
|
|
247
|
+
))}
|
|
248
|
+
</section>
|
|
166
249
|
<ShowMore rest={rest} onMore={more} />
|
|
167
250
|
</>
|
|
168
251
|
);
|
|
@@ -2,11 +2,13 @@ import ReactMarkdown from "react-markdown";
|
|
|
2
2
|
import remarkGfm from "remark-gfm";
|
|
3
3
|
import { isDocumentPath } from "../documents.js";
|
|
4
4
|
import { type Picture, PictureThumb } from "./pictures.js";
|
|
5
|
+
import { useRemotePictureSrc } from "./remote-pictures.js";
|
|
5
6
|
|
|
6
7
|
// What people and agents write is untrusted. It is rendered as React elements, never as HTML:
|
|
7
8
|
// raw HTML in the source stays text, links are limited to a few schemes and open elsewhere,
|
|
8
|
-
// and
|
|
9
|
-
// is a document's path (ADR-0009)
|
|
9
|
+
// and a picture from elsewhere, named over https, is read through the console (ADR-0048),
|
|
10
|
+
// lazily and without a referrer. A link whose destination is a document's path (ADR-0009)
|
|
11
|
+
// leads to the document's page, when the page knows where.
|
|
10
12
|
|
|
11
13
|
const SAFE_LINK = /^(https?:|mailto:)/i;
|
|
12
14
|
const SAFE_IMAGE = /^https:\/\//i;
|
|
@@ -29,6 +31,7 @@ export interface MarkdownProps {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export function Markdown(props: MarkdownProps) {
|
|
34
|
+
const remote = useRemotePictureSrc();
|
|
32
35
|
return (
|
|
33
36
|
<div className="sc-prose">
|
|
34
37
|
<ReactMarkdown
|
|
@@ -62,7 +65,7 @@ export function Markdown(props: MarkdownProps) {
|
|
|
62
65
|
img({ src, alt, title }) {
|
|
63
66
|
const named = typeof src === "string" ? props.fileSrc?.(src) : undefined;
|
|
64
67
|
const url =
|
|
65
|
-
named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined);
|
|
68
|
+
named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? remote(src) : undefined);
|
|
66
69
|
if (url === undefined) {
|
|
67
70
|
return <span className="sc-prose-image-text">{alt ?? ""}</span>;
|
|
68
71
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { RestAnswerInput, RestDecision, RestMyDecisions } from "../api.js";
|
|
3
|
+
import { useWords } from "../i18n/index.js";
|
|
4
|
+
import { DecisionCard, DecisionLog } from "./decision-list.js";
|
|
5
|
+
import { ProjectMark } from "./projects.js";
|
|
6
|
+
import { Button } from "./ui.js";
|
|
7
|
+
|
|
8
|
+
// A person's decisions (ADR-0042), across the projects they may see, laid out as a project's
|
|
9
|
+
// decisions page is: in the first column the decisions that wait for their answer, as cards
|
|
10
|
+
// answered here, and under them the decisions they asked of someone else, as cards that say
|
|
11
|
+
// whom they wait for; in the second the record, the answered ones of both kinds, newest first.
|
|
12
|
+
// Every card and row names the project, and who asked and who answers, as the cards do
|
|
13
|
+
// everywhere. Takes its data as props and nothing from the network; the page reads more when
|
|
14
|
+
// asked.
|
|
15
|
+
|
|
16
|
+
/** One kind of a person's decisions, a page at a time. */
|
|
17
|
+
export interface DecisionsPage {
|
|
18
|
+
readonly items: RestMyDecisions["items"];
|
|
19
|
+
/** Whether there is more after what is shown; `onMore` reads it. */
|
|
20
|
+
readonly more?: boolean | undefined;
|
|
21
|
+
readonly onMore?: (() => void) | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MyDecisionsProps {
|
|
25
|
+
/** The decisions addressed to the person: waiting and answered. */
|
|
26
|
+
readonly addressed: DecisionsPage;
|
|
27
|
+
/** The decisions the person asked of someone else: waiting and answered. */
|
|
28
|
+
readonly raised: DecisionsPage;
|
|
29
|
+
readonly decisionHref: (projectKey: string, decisionId: string) => string;
|
|
30
|
+
/** Called with the person's answer on a decision of theirs, in its project. Left out, a waiting decision is answered on its own page. */
|
|
31
|
+
readonly onAnswer?:
|
|
32
|
+
| ((projectKey: string, decision: RestDecision, input: RestAnswerInput) => void)
|
|
33
|
+
| undefined;
|
|
34
|
+
readonly busy?: boolean | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Item = RestMyDecisions["items"][number];
|
|
38
|
+
|
|
39
|
+
/** The project a decision is in, as the plate names one. */
|
|
40
|
+
function InProject(props: { readonly project: Item["project"] }) {
|
|
41
|
+
const words = useWords();
|
|
42
|
+
return (
|
|
43
|
+
<span className="sc-task-row-project">
|
|
44
|
+
<ProjectMark project={props.project} size="sm" /> {words.work.inProject(props.project.name)}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function More(props: {
|
|
50
|
+
readonly page: DecisionsPage;
|
|
51
|
+
readonly label: string;
|
|
52
|
+
readonly busy?: boolean | undefined;
|
|
53
|
+
}) {
|
|
54
|
+
const { page } = props;
|
|
55
|
+
if (page.more !== true || page.onMore === undefined) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<div className="sc-paged-more">
|
|
60
|
+
<Button variant="secondary" size="sm" disabled={props.busy} onClick={page.onMore}>
|
|
61
|
+
{props.label}
|
|
62
|
+
</Button>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function MyDecisions(props: MyDecisionsProps) {
|
|
68
|
+
const words = useWords();
|
|
69
|
+
const own = words.myDecisions;
|
|
70
|
+
const { onAnswer } = props;
|
|
71
|
+
const waiting = props.addressed.items.filter(({ decision }) => decision.answer === null);
|
|
72
|
+
const asked = props.raised.items.filter(({ decision }) => decision.answer === null);
|
|
73
|
+
const record = [...props.addressed.items, ...props.raised.items]
|
|
74
|
+
.filter(({ decision }) => decision.answer !== null)
|
|
75
|
+
.sort((a, b) => (b.decision.answer?.at ?? "").localeCompare(a.decision.answer?.at ?? ""));
|
|
76
|
+
const projectOf = new Map(record.map((item) => [item.decision.id, item.project]));
|
|
77
|
+
const card = (item: Item, answerable: boolean): ReactNode => (
|
|
78
|
+
<DecisionCard
|
|
79
|
+
key={item.decision.id}
|
|
80
|
+
decision={item.decision}
|
|
81
|
+
href={props.decisionHref(item.project.key, item.decision.id)}
|
|
82
|
+
project={<InProject project={item.project} />}
|
|
83
|
+
onAnswer={
|
|
84
|
+
answerable && onAnswer !== undefined
|
|
85
|
+
? (decision, input) => onAnswer(item.project.key, decision, input)
|
|
86
|
+
: undefined
|
|
87
|
+
}
|
|
88
|
+
busy={props.busy}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
return (
|
|
92
|
+
<div className="sc-decisions">
|
|
93
|
+
<div className="sc-decisions-waiting">
|
|
94
|
+
<section aria-label={own.waiting}>
|
|
95
|
+
<h2 className="sc-section-title">
|
|
96
|
+
{own.waiting} <span className="sc-column-count">{waiting.length}</span>
|
|
97
|
+
</h2>
|
|
98
|
+
{waiting.length === 0 ? (
|
|
99
|
+
<p className="sc-muted">{own.noneWaiting}</p>
|
|
100
|
+
) : (
|
|
101
|
+
waiting.map((item) => card(item, true))
|
|
102
|
+
)}
|
|
103
|
+
<More page={props.addressed} label={own.more} busy={props.busy} />
|
|
104
|
+
</section>
|
|
105
|
+
<section aria-label={own.raised}>
|
|
106
|
+
<h2 className="sc-section-title">
|
|
107
|
+
{own.raised} <span className="sc-column-count">{asked.length}</span>
|
|
108
|
+
</h2>
|
|
109
|
+
{asked.length === 0 ? (
|
|
110
|
+
<p className="sc-muted">{own.noneRaised}</p>
|
|
111
|
+
) : (
|
|
112
|
+
asked.map((item) => card(item, false))
|
|
113
|
+
)}
|
|
114
|
+
<More page={props.raised} label={own.more} busy={props.busy} />
|
|
115
|
+
</section>
|
|
116
|
+
</div>
|
|
117
|
+
<section aria-label={words.decisions.record}>
|
|
118
|
+
<h2 className="sc-section-title">
|
|
119
|
+
{words.decisions.record} <span className="sc-column-count">{record.length}</span>
|
|
120
|
+
</h2>
|
|
121
|
+
{record.length === 0 ? (
|
|
122
|
+
<p className="sc-muted">{own.noneAnswered}</p>
|
|
123
|
+
) : (
|
|
124
|
+
<DecisionLog
|
|
125
|
+
decisions={record.map((item) => item.decision)}
|
|
126
|
+
decisionHref={(decision) =>
|
|
127
|
+
props.decisionHref(projectOf.get(decision.id)?.key ?? "", decision.id)
|
|
128
|
+
}
|
|
129
|
+
projectOf={(decision) => {
|
|
130
|
+
const project = projectOf.get(decision.id);
|
|
131
|
+
return project === undefined ? undefined : <InProject project={project} />;
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
</section>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -6,8 +6,9 @@ import { Button, Time } from "./ui.js";
|
|
|
6
6
|
// A private pack connected with a code a person approves (ADR-0036): an owner, or for the
|
|
7
7
|
// workspace's token an administrator, asks, the console asks the deployment and shows what it
|
|
8
8
|
// answered, the code and the page, and the person approves there; the page asks the console
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// to claim the token at the interval the deployment named, until the token is kept or the
|
|
10
|
+
// request ended; reading how the request stands, when the page opens, changes nothing
|
|
11
|
+
// (ADR-0046). Takes its data through the callbacks and keeps nothing itself.
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* How a connection stands, as the component needs it: what the API answers for a project's
|
|
@@ -20,8 +21,10 @@ export type PackConnectState =
|
|
|
20
21
|
export interface PackConnectProps {
|
|
21
22
|
/** Asks the deployment to connect; answers what the person is shown. */
|
|
22
23
|
readonly onStart: () => Promise<RestPackConnectStarted>;
|
|
23
|
-
/** How the connection stands
|
|
24
|
+
/** How the connection stands, read when the page opens; changes nothing. */
|
|
24
25
|
readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
26
|
+
/** Asks the console to claim the token at the deployment: pending still, connected, or ended. */
|
|
27
|
+
readonly claim: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
25
28
|
/** Gives the connection up. */
|
|
26
29
|
readonly onCancel: () => Promise<void>;
|
|
27
30
|
/** Called once the token is kept. */
|
|
@@ -47,7 +50,7 @@ const MIN_INTERVAL_SECONDS = 2;
|
|
|
47
50
|
export function PackConnect(props: PackConnectProps) {
|
|
48
51
|
const words = useWords();
|
|
49
52
|
const pack = words.projects.packToken;
|
|
50
|
-
const { poll, onConnected } = props;
|
|
53
|
+
const { poll, claim, onConnected } = props;
|
|
51
54
|
const [shown, setShown] = useState<Shown>({ kind: "idle" });
|
|
52
55
|
const [problem, setProblem] = useState<string | undefined>(undefined);
|
|
53
56
|
|
|
@@ -64,7 +67,7 @@ export function PackConnect(props: PackConnectProps) {
|
|
|
64
67
|
return () => controller.abort();
|
|
65
68
|
}, [poll]);
|
|
66
69
|
|
|
67
|
-
// While waiting, the console is asked
|
|
70
|
+
// While waiting, the console is asked to claim at the interval the deployment named; a new
|
|
68
71
|
// answer, pending still, is a new state, which schedules the next asking.
|
|
69
72
|
useEffect(() => {
|
|
70
73
|
if (shown.kind !== "waiting") {
|
|
@@ -72,7 +75,7 @@ export function PackConnect(props: PackConnectProps) {
|
|
|
72
75
|
}
|
|
73
76
|
const controller = new AbortController();
|
|
74
77
|
const timer = setTimeout(() => {
|
|
75
|
-
|
|
78
|
+
claim(controller.signal)
|
|
76
79
|
.then((state) => {
|
|
77
80
|
if (controller.signal.aborted) {
|
|
78
81
|
return;
|
|
@@ -102,7 +105,7 @@ export function PackConnect(props: PackConnectProps) {
|
|
|
102
105
|
controller.abort();
|
|
103
106
|
clearTimeout(timer);
|
|
104
107
|
};
|
|
105
|
-
}, [shown,
|
|
108
|
+
}, [shown, claim, onConnected]);
|
|
106
109
|
|
|
107
110
|
const start = () => {
|
|
108
111
|
setProblem(undefined);
|
|
@@ -20,8 +20,10 @@ export interface PackTokenWaysProps {
|
|
|
20
20
|
readonly busy?: boolean | undefined;
|
|
21
21
|
/** Asks the deployment to connect; answers what the person is shown. */
|
|
22
22
|
readonly onStart: () => Promise<RestPackConnectStarted>;
|
|
23
|
-
/** How the connection stands
|
|
23
|
+
/** How the connection stands, read when the page opens; changes nothing. */
|
|
24
24
|
readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
25
|
+
/** Asks the console to claim the token at the deployment (ADR-0046). */
|
|
26
|
+
readonly claim: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
25
27
|
/** Gives the connection up. */
|
|
26
28
|
readonly onCancelConnect: () => Promise<void>;
|
|
27
29
|
/** Called once the token is kept. */
|
|
@@ -46,6 +48,7 @@ export function PackTokenWays(props: PackTokenWaysProps) {
|
|
|
46
48
|
<PackConnect
|
|
47
49
|
onStart={props.onStart}
|
|
48
50
|
poll={props.poll}
|
|
51
|
+
claim={props.claim}
|
|
49
52
|
onCancel={props.onCancelConnect}
|
|
50
53
|
onConnected={props.onConnected}
|
|
51
54
|
busy={props.busy}
|
|
@@ -57,6 +57,19 @@ export function PeopleList(props: PeopleListProps) {
|
|
|
57
57
|
person.source !== "organization" &&
|
|
58
58
|
props.me?.id !== person.id;
|
|
59
59
|
const organization = props.organization ?? undefined;
|
|
60
|
+
/** What let the person in, by its name: the organization, the domain of their login, or an administrator. */
|
|
61
|
+
const noteOf = (person: RestPerson): string => {
|
|
62
|
+
if (person.source === "organization") {
|
|
63
|
+
return organization === undefined
|
|
64
|
+
? people.source.organization
|
|
65
|
+
: people.ofOrganization(organization.name);
|
|
66
|
+
}
|
|
67
|
+
if (person.source === "domain") {
|
|
68
|
+
const at = person.login.lastIndexOf("@");
|
|
69
|
+
return at === -1 ? people.source.domain : people.ofDomain(person.login.slice(at + 1));
|
|
70
|
+
}
|
|
71
|
+
return people.source.listed;
|
|
72
|
+
};
|
|
60
73
|
return (
|
|
61
74
|
<div className="sc-people-page">
|
|
62
75
|
{organization !== undefined && (
|
|
@@ -75,10 +88,12 @@ export function PeopleList(props: PeopleListProps) {
|
|
|
75
88
|
{active.map((person) => (
|
|
76
89
|
<li key={person.id} className="sc-person-row">
|
|
77
90
|
<PersonChip person={person} size="md" />
|
|
91
|
+
{props.me?.id === person.id && (
|
|
92
|
+
<span className="sc-person-you">{words.common.you}</span>
|
|
93
|
+
)}
|
|
78
94
|
<span className="sc-person-name sc-muted">
|
|
79
|
-
{person.name
|
|
80
|
-
|
|
81
|
-
<span className="sc-person-note"> {people.source[person.source]}</span>
|
|
95
|
+
{person.name !== null && <>{person.login} · </>}
|
|
96
|
+
<span className="sc-person-note">{noteOf(person)}</span>
|
|
82
97
|
</span>
|
|
83
98
|
{props.onChangeRole === undefined ? (
|
|
84
99
|
<RoleBadge role={person.role} />
|
|
@@ -161,12 +176,9 @@ export function PeopleList(props: PeopleListProps) {
|
|
|
161
176
|
<li key={person.id} className="sc-person-row sc-person-row-out">
|
|
162
177
|
<PersonChip person={person} size="md" />
|
|
163
178
|
<span className="sc-person-name sc-muted">
|
|
164
|
-
{person.name
|
|
179
|
+
{person.name !== null && <>{person.login} · </>}
|
|
165
180
|
<span className="sc-person-note">
|
|
166
|
-
{"
|
|
167
|
-
{person.source === "organization"
|
|
168
|
-
? people.leftOrganization
|
|
169
|
-
: people.source[person.source]}
|
|
181
|
+
{person.source === "organization" ? people.leftOrganization : noteOf(person)}
|
|
170
182
|
{person.deactivatedAt !== null && (
|
|
171
183
|
<>
|
|
172
184
|
{" "}
|
|
@@ -93,7 +93,7 @@ export const VISIBILITY_LABELS: Readonly<Record<ProjectVisibility, string>> = {
|
|
|
93
93
|
export function ProjectRoleBadge(props: { readonly role: ProjectRole }) {
|
|
94
94
|
const words = useWords();
|
|
95
95
|
return (
|
|
96
|
-
<Badge tone={props.role === "owner" ? "
|
|
96
|
+
<Badge tone={props.role === "owner" ? "accent" : "neutral"}>
|
|
97
97
|
{words.vocabulary.projectRole[props.role]}
|
|
98
98
|
</Badge>
|
|
99
99
|
);
|
|
@@ -143,7 +143,9 @@ export function ProjectList(props: ProjectListProps) {
|
|
|
143
143
|
)}
|
|
144
144
|
{project.openDecisions > 0 && (
|
|
145
145
|
<p className="sc-project-meta">
|
|
146
|
-
<Badge tone="warning">
|
|
146
|
+
<Badge tone="warning" kind="count">
|
|
147
|
+
{words.decisionsWaiting(project.openDecisions)}
|
|
148
|
+
</Badge>
|
|
147
149
|
</p>
|
|
148
150
|
)}
|
|
149
151
|
</li>
|
|
@@ -444,11 +446,11 @@ export function MemberList(props: MemberListProps) {
|
|
|
444
446
|
{props.members.map((member) => (
|
|
445
447
|
<li key={member.person.id} className="sc-person-row">
|
|
446
448
|
<PersonChip person={member.person} size="md" />
|
|
449
|
+
{props.me?.id === member.person.id && (
|
|
450
|
+
<span className="sc-person-you">{words.common.you}</span>
|
|
451
|
+
)}
|
|
447
452
|
<span className="sc-person-name">
|
|
448
453
|
{member.person.name ?? ""}
|
|
449
|
-
{props.me?.id === member.person.id && (
|
|
450
|
-
<span className="sc-muted"> {words.common.you}</span>
|
|
451
|
-
)}
|
|
452
454
|
<span className="sc-muted">
|
|
453
455
|
{" "}
|
|
454
456
|
{members.since} <Time iso={member.addedAt} />
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import { REST_ROUTES } from "../routes.js";
|
|
3
|
+
|
|
4
|
+
// Where a picture from elsewhere is read (ADR-0048): through the console, which fetches it and
|
|
5
|
+
// keeps it a while, so that the person's browser reaches nobody's server and the pages load
|
|
6
|
+
// nothing from elsewhere. The composition provides the client's way to it; a component outside
|
|
7
|
+
// one reads it at the API's own path, which a console served beside its API answers.
|
|
8
|
+
|
|
9
|
+
/** The address a picture from elsewhere is read at, given the https URL that names it. */
|
|
10
|
+
export type RemotePictureSrc = (url: string) => string;
|
|
11
|
+
|
|
12
|
+
export const defaultRemotePictureSrc: RemotePictureSrc = (url) =>
|
|
13
|
+
`${REST_ROUTES.pictures}?src=${encodeURIComponent(url)}`;
|
|
14
|
+
|
|
15
|
+
export const RemotePicturesContext = createContext<RemotePictureSrc>(defaultRemotePictureSrc);
|
|
16
|
+
|
|
17
|
+
export function useRemotePictureSrc(): RemotePictureSrc {
|
|
18
|
+
return useContext(RemotePicturesContext);
|
|
19
|
+
}
|
|
@@ -157,7 +157,7 @@ export function GoalTimeline(props: GoalTimelineProps) {
|
|
|
157
157
|
return (
|
|
158
158
|
<div key={goal.id} className={cx("sc-timeline-row", `sc-goal-${goal.state}`)}>
|
|
159
159
|
<div className="sc-timeline-label">
|
|
160
|
-
<a className="sc-timeline-goal" href={props.goalHref(goal)}>
|
|
160
|
+
<a className="sc-timeline-goal" href={props.goalHref(goal)} title={goal.title}>
|
|
161
161
|
{goal.title}
|
|
162
162
|
</a>
|
|
163
163
|
<GoalStateBadge state={goal.state} />
|
package/src/components/runs.tsx
CHANGED
|
@@ -32,7 +32,11 @@ export function RunStatusBadge(props: { readonly status: RunStatus }) {
|
|
|
32
32
|
: props.status === "failed"
|
|
33
33
|
? "danger"
|
|
34
34
|
: "neutral";
|
|
35
|
-
return
|
|
35
|
+
return (
|
|
36
|
+
<Badge tone={tone} kind="state">
|
|
37
|
+
{words.vocabulary.runStatus[props.status]}
|
|
38
|
+
</Badge>
|
|
39
|
+
);
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export interface RunCardProps {
|