@skillcdn/console 0.1.10 → 0.1.11
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 -4
- package/dist/api.d.ts +6 -5
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +5 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +49 -9
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +7 -6
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +10 -9
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +14 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +13 -1
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +1 -4
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +5 -12
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +21 -1
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +67 -7
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +11 -9
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +11 -7
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/paged.d.ts +14 -0
- package/dist/components/paged.d.ts.map +1 -0
- package/dist/components/paged.js +28 -0
- package/dist/components/paged.js.map +1 -0
- package/dist/components/projects.d.ts +22 -2
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +35 -7
- package/dist/components/projects.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +2 -2
- package/dist/components/runs.js.map +1 -1
- package/dist/components/shell.d.ts +2 -1
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +1 -1
- package/dist/components/shell.js.map +1 -1
- package/dist/components/task-list.d.ts +1 -1
- package/dist/components/task-list.d.ts.map +1 -1
- package/dist/components/task-list.js +6 -4
- package/dist/components/task-list.js.map +1 -1
- package/dist/components/task-view.js +1 -1
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/work.d.ts.map +1 -1
- package/dist/components/work.js +7 -3
- package/dist/components/work.js.map +1 -1
- package/dist/console.css +149 -2
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +34 -18
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +5 -2
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +15 -2
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +12 -6
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +12 -8
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +12 -8
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +6 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +6 -0
- package/dist/limits.js.map +1 -1
- package/dist/marks.d.ts +12 -0
- package/dist/marks.d.ts.map +1 -0
- package/dist/marks.js +31 -0
- package/dist/marks.js.map +1 -0
- package/dist/router.d.ts +4 -3
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +11 -5
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +7 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +8 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +123 -3
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +42 -10
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +10 -4
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +8 -3
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +7 -5
- package/docs/specs/rest.md +18 -12
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +1 -1
- package/src/api.ts +12 -0
- package/src/cli/cli.ts +51 -8
- package/src/cli/format.ts +8 -6
- package/src/cli/help.ts +10 -10
- package/src/client.ts +29 -6
- package/src/components/board.tsx +5 -38
- package/src/components/calendar.tsx +166 -54
- package/src/components/decision-list.tsx +38 -33
- package/src/components/event-feed.tsx +34 -27
- package/src/components/paged.tsx +39 -0
- package/src/components/projects.tsx +128 -10
- package/src/components/runs.tsx +7 -1
- package/src/components/shell.tsx +5 -2
- package/src/components/task-list.tsx +35 -32
- package/src/components/task-view.tsx +1 -1
- package/src/components/work.tsx +12 -2
- package/src/console.tsx +54 -20
- package/src/data.ts +20 -4
- package/src/i18n/en.ts +12 -8
- package/src/i18n/ko.ts +12 -8
- package/src/index.ts +4 -0
- package/src/limits.ts +6 -0
- package/src/marks.ts +40 -0
- package/src/router.ts +19 -7
- package/src/routes.ts +9 -0
- package/src/schemas.ts +48 -8
- package/src/styles/console.css +149 -2
- package/src/vocabulary.ts +14 -4
|
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { RestEvent } from "../api.js";
|
|
3
3
|
import { en, type Messages } from "../i18n/en.js";
|
|
4
4
|
import { useWords } from "../i18n/index.js";
|
|
5
|
+
import { ShowMore, usePaged } from "./paged.js";
|
|
5
6
|
import { PersonChip, Time } from "./ui.js";
|
|
6
7
|
|
|
7
8
|
// The feed: one line per event, newest first, as a sentence a person reads at a glance: who,
|
|
@@ -79,6 +80,8 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
|
|
|
79
80
|
return feed.event.runReported(task, data.excerpt);
|
|
80
81
|
case "run.handed_in":
|
|
81
82
|
return feed.event.runHandedIn(data.label ?? feed.something, task);
|
|
83
|
+
case "run.taken_over":
|
|
84
|
+
return feed.event.runTakenOver(task, data.agent ?? feed.someone);
|
|
82
85
|
case "run.ended":
|
|
83
86
|
return data.status === "finished"
|
|
84
87
|
? feed.event.runFinished(task)
|
|
@@ -106,36 +109,40 @@ export interface EventFeedProps {
|
|
|
106
109
|
|
|
107
110
|
export function EventFeed(props: EventFeedProps) {
|
|
108
111
|
const words = useWords();
|
|
112
|
+
const newestFirst = [...props.events].reverse();
|
|
113
|
+
const { shown, rest, more } = usePaged(newestFirst);
|
|
109
114
|
if (props.events.length === 0) {
|
|
110
115
|
return <div className="sc-feed">{props.empty ?? null}</div>;
|
|
111
116
|
}
|
|
112
|
-
const newestFirst = [...props.events].reverse();
|
|
113
117
|
return (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{words.feed.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
118
|
+
<>
|
|
119
|
+
<ol className="sc-feed" aria-label={words.feed.list}>
|
|
120
|
+
{shown.map((event) => {
|
|
121
|
+
const href = props.href?.(event);
|
|
122
|
+
const said = describeEvent(event, words);
|
|
123
|
+
return (
|
|
124
|
+
<li key={event.id} className="sc-feed-item">
|
|
125
|
+
<span className="sc-feed-actor">
|
|
126
|
+
{event.actor === null ? (
|
|
127
|
+
<span className="sc-person-login">{words.feed.console}</span>
|
|
128
|
+
) : (
|
|
129
|
+
<PersonChip person={event.actor} />
|
|
130
|
+
)}
|
|
131
|
+
{event.agent !== null && (
|
|
132
|
+
<span className="sc-feed-agent" title={words.feed.asTitle}>
|
|
133
|
+
{words.feed.as(event.agent)}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
</span>
|
|
137
|
+
<span className="sc-feed-words">
|
|
138
|
+
{href === undefined ? said : <a href={href}>{said}</a>}
|
|
139
|
+
</span>
|
|
140
|
+
<Time iso={event.createdAt} />
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ol>
|
|
145
|
+
<ShowMore rest={rest} onMore={more} />
|
|
146
|
+
</>
|
|
140
147
|
);
|
|
141
148
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useWords } from "../i18n/index.js";
|
|
3
|
+
import { Button } from "./ui.js";
|
|
4
|
+
|
|
5
|
+
// A long list shown a page at a time, from what the page already holds: the record of the
|
|
6
|
+
// decisions, the feed, the tasks with no day. The next page is a click away and nothing is
|
|
7
|
+
// asked of the server; the API's own pages (`limit`, `offset`) are for the command and scripts.
|
|
8
|
+
|
|
9
|
+
/** How many items a list shows before it asks to be told to show more. */
|
|
10
|
+
export const PAGE_SIZE = 50;
|
|
11
|
+
|
|
12
|
+
/** The first `shown` items of a list, and the way to show the next page of it. */
|
|
13
|
+
export function usePaged<T>(
|
|
14
|
+
items: readonly T[],
|
|
15
|
+
pageSize = PAGE_SIZE,
|
|
16
|
+
): { readonly shown: readonly T[]; readonly rest: number; readonly more: () => void } {
|
|
17
|
+
const [pages, setPages] = useState(1);
|
|
18
|
+
const shown = items.slice(0, pages * pageSize);
|
|
19
|
+
return {
|
|
20
|
+
shown,
|
|
21
|
+
rest: items.length - shown.length,
|
|
22
|
+
more: () => setPages((current) => current + 1),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The button under a paged list, saying how many more there are; nothing when there are none. */
|
|
27
|
+
export function ShowMore(props: { readonly rest: number; readonly onMore: () => void }) {
|
|
28
|
+
const words = useWords();
|
|
29
|
+
if (props.rest <= 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return (
|
|
33
|
+
<div className="sc-show-more">
|
|
34
|
+
<Button variant="secondary" size="sm" onClick={props.onMore}>
|
|
35
|
+
{words.common.showMore(Math.min(props.rest, PAGE_SIZE), props.rest)}
|
|
36
|
+
</Button>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import { type FormEvent, type ReactNode, useState } from "react";
|
|
1
|
+
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import {
|
|
3
|
+
COLOR_NAMES,
|
|
4
|
+
type ColorName,
|
|
3
5
|
MAX_PROJECT_DESCRIPTION_LENGTH,
|
|
4
6
|
MAX_PROJECT_KEY_LENGTH,
|
|
5
7
|
MAX_PROJECT_NAME_LENGTH,
|
|
8
|
+
MAX_PROJECT_SYMBOL_LENGTH,
|
|
6
9
|
MAX_SKILLS_ADDRESS_LENGTH,
|
|
10
|
+
PROJECT_ICON_TYPES,
|
|
7
11
|
PROJECT_ROLES,
|
|
8
12
|
PROJECT_VISIBILITIES,
|
|
9
13
|
type ProjectRole,
|
|
10
14
|
type ProjectVisibility,
|
|
15
|
+
projectColorOf,
|
|
16
|
+
projectIconPath,
|
|
17
|
+
projectSymbolOf,
|
|
11
18
|
type RestMember,
|
|
12
19
|
type RestMemberInput,
|
|
13
20
|
type RestPerson,
|
|
@@ -16,7 +23,44 @@ import {
|
|
|
16
23
|
type RestProjectPatch,
|
|
17
24
|
} from "../api.js";
|
|
18
25
|
import { useWords } from "../i18n/index.js";
|
|
19
|
-
import { Badge, Button, PersonChip, Time } from "./ui.js";
|
|
26
|
+
import { Badge, Button, cx, PersonChip, Time } from "./ui.js";
|
|
27
|
+
|
|
28
|
+
/** A project's mark: its symbol on its colour, so that projects tell apart at a glance. */
|
|
29
|
+
export function ProjectMark(props: {
|
|
30
|
+
readonly project: {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly color: ColorName | null;
|
|
34
|
+
readonly symbol: string | null;
|
|
35
|
+
/** The project's own image, when it has one; the symbol on the colour otherwise. */
|
|
36
|
+
readonly icon?: { readonly sha256: string } | null | undefined;
|
|
37
|
+
};
|
|
38
|
+
readonly size?: "sm" | "md" | "lg";
|
|
39
|
+
/** Where the image is read from; the console's own path for it when left out. */
|
|
40
|
+
readonly iconSrc?: string | undefined;
|
|
41
|
+
}) {
|
|
42
|
+
const size = props.size ?? "md";
|
|
43
|
+
const icon = props.project.icon ?? null;
|
|
44
|
+
const src =
|
|
45
|
+
props.iconSrc ?? (icon === null ? undefined : projectIconPath(props.project.key, icon));
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
className={cx(
|
|
49
|
+
"sc-mark",
|
|
50
|
+
`sc-mark-${size}`,
|
|
51
|
+
`sc-category-${projectColorOf(props.project)}`,
|
|
52
|
+
src !== undefined && "sc-mark-pictured",
|
|
53
|
+
)}
|
|
54
|
+
aria-hidden="true"
|
|
55
|
+
>
|
|
56
|
+
{src === undefined ? (
|
|
57
|
+
projectSymbolOf(props.project)
|
|
58
|
+
) : (
|
|
59
|
+
<img className="sc-mark-image" src={src} alt="" />
|
|
60
|
+
)}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
20
64
|
|
|
21
65
|
// The projects of the workspace (ADR-0008): the list a person may see, the way to make one,
|
|
22
66
|
// a project's settings for its owners, and those listed in it. Each takes its data as props
|
|
@@ -60,6 +104,7 @@ export function ProjectList(props: ProjectListProps) {
|
|
|
60
104
|
{props.projects.map((project) => (
|
|
61
105
|
<li key={project.id} className="sc-project">
|
|
62
106
|
<div className="sc-project-head">
|
|
107
|
+
<ProjectMark project={project} size="md" />
|
|
63
108
|
<a
|
|
64
109
|
className="sc-project-name"
|
|
65
110
|
href={props.projectHref(project)}
|
|
@@ -79,14 +124,11 @@ export function ProjectList(props: ProjectListProps) {
|
|
|
79
124
|
{project.description.length > 0 && (
|
|
80
125
|
<p className="sc-project-description">{project.description}</p>
|
|
81
126
|
)}
|
|
82
|
-
|
|
83
|
-
|
|
127
|
+
{project.openDecisions > 0 && (
|
|
128
|
+
<p className="sc-project-meta">
|
|
84
129
|
<Badge tone="warning">{words.decisionsWaiting(project.openDecisions)}</Badge>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<Badge tone="accent">{words.agentsAtWork(project.openRuns)}</Badge>
|
|
88
|
-
)}
|
|
89
|
-
</p>
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
90
132
|
</li>
|
|
91
133
|
))}
|
|
92
134
|
</ul>
|
|
@@ -98,7 +140,11 @@ export interface ProjectFormProps {
|
|
|
98
140
|
readonly project?: RestProject | undefined;
|
|
99
141
|
readonly busy?: boolean | undefined;
|
|
100
142
|
readonly error?: string | undefined;
|
|
101
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Called with the settings as they are to be, and the image for the mark: a file chosen,
|
|
145
|
+
* `null` to take the image away, or nothing to keep what the project has.
|
|
146
|
+
*/
|
|
147
|
+
readonly onSubmit: (input: RestProjectInput | RestProjectPatch, icon?: File | null) => void;
|
|
102
148
|
readonly onCancel?: (() => void) | undefined;
|
|
103
149
|
}
|
|
104
150
|
|
|
@@ -121,6 +167,31 @@ export function ProjectForm(props: ProjectFormProps) {
|
|
|
121
167
|
const [description, setDescription] = useState(project?.description ?? "");
|
|
122
168
|
const [visibility, setVisibility] = useState<ProjectVisibility>(project?.visibility ?? "private");
|
|
123
169
|
const [skillsAddress, setSkillsAddress] = useState(project?.skillsAddress ?? "");
|
|
170
|
+
const [color, setColor] = useState<ColorName | "">(project?.color ?? "");
|
|
171
|
+
const [symbol, setSymbol] = useState(project?.symbol ?? "");
|
|
172
|
+
/** The image for the mark: a file chosen, `null` for none any more, nothing to keep the project's. */
|
|
173
|
+
const [icon, setIcon] = useState<File | null | undefined>(undefined);
|
|
174
|
+
const chosenSrc = useMemo(
|
|
175
|
+
() => (icon instanceof File ? URL.createObjectURL(icon) : undefined),
|
|
176
|
+
[icon],
|
|
177
|
+
);
|
|
178
|
+
useEffect(
|
|
179
|
+
() => () => {
|
|
180
|
+
if (chosenSrc !== undefined) {
|
|
181
|
+
URL.revokeObjectURL(chosenSrc);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
[chosenSrc],
|
|
185
|
+
);
|
|
186
|
+
const kept = icon === undefined ? (project?.icon ?? null) : null;
|
|
187
|
+
/** The mark as it will be, from what is typed and chosen so far. */
|
|
188
|
+
const preview = {
|
|
189
|
+
key: key.trim() === "" ? keyOf(name) : key.trim(),
|
|
190
|
+
name: name.trim() === "" ? "?" : name.trim(),
|
|
191
|
+
color: color === "" ? null : color,
|
|
192
|
+
symbol: symbol.trim() === "" ? null : symbol.trim(),
|
|
193
|
+
icon: icon instanceof File ? { sha256: "chosen" } : kept,
|
|
194
|
+
};
|
|
124
195
|
|
|
125
196
|
const submit = (event: FormEvent) => {
|
|
126
197
|
event.preventDefault();
|
|
@@ -129,9 +200,12 @@ export function ProjectForm(props: ProjectFormProps) {
|
|
|
129
200
|
description: description.trim(),
|
|
130
201
|
visibility,
|
|
131
202
|
skillsAddress: skillsAddress.trim() === "" ? null : skillsAddress.trim(),
|
|
203
|
+
color: preview.color,
|
|
204
|
+
symbol: preview.symbol,
|
|
132
205
|
};
|
|
133
206
|
props.onSubmit(
|
|
134
207
|
project === undefined ? { key: key.trim(), ...settings, name: name.trim() } : settings,
|
|
208
|
+
icon,
|
|
135
209
|
);
|
|
136
210
|
};
|
|
137
211
|
|
|
@@ -207,6 +281,50 @@ export function ProjectForm(props: ProjectFormProps) {
|
|
|
207
281
|
/>
|
|
208
282
|
</label>
|
|
209
283
|
</div>
|
|
284
|
+
<div className="sc-field-row sc-mark-row">
|
|
285
|
+
<ProjectMark project={preview} size="lg" iconSrc={chosenSrc} />
|
|
286
|
+
<label className="sc-field">
|
|
287
|
+
<span className="sc-field-label">{form.symbol}</span>
|
|
288
|
+
<input
|
|
289
|
+
className="sc-input"
|
|
290
|
+
value={symbol}
|
|
291
|
+
maxLength={MAX_PROJECT_SYMBOL_LENGTH}
|
|
292
|
+
pattern="\S*"
|
|
293
|
+
placeholder={projectSymbolOf({ name: preview.name, symbol: null })}
|
|
294
|
+
onChange={(event) => setSymbol(event.target.value)}
|
|
295
|
+
/>
|
|
296
|
+
</label>
|
|
297
|
+
<label className="sc-field">
|
|
298
|
+
<span className="sc-field-label">{form.color}</span>
|
|
299
|
+
<select
|
|
300
|
+
className="sc-input"
|
|
301
|
+
value={color}
|
|
302
|
+
onChange={(event) => setColor(event.target.value as ColorName | "")}
|
|
303
|
+
>
|
|
304
|
+
<option value="">{form.colorAuto}</option>
|
|
305
|
+
{COLOR_NAMES.map((candidate) => (
|
|
306
|
+
<option key={candidate} value={candidate}>
|
|
307
|
+
{words.vocabulary.categoryColor[candidate]}
|
|
308
|
+
</option>
|
|
309
|
+
))}
|
|
310
|
+
</select>
|
|
311
|
+
</label>
|
|
312
|
+
<label className="sc-field">
|
|
313
|
+
<span className="sc-field-label">{form.image}</span>
|
|
314
|
+
<input
|
|
315
|
+
key={icon === null ? "none" : "some"}
|
|
316
|
+
className="sc-input sc-input-file"
|
|
317
|
+
type="file"
|
|
318
|
+
accept={PROJECT_ICON_TYPES.join(",")}
|
|
319
|
+
onChange={(event) => setIcon(event.target.files?.[0] ?? undefined)}
|
|
320
|
+
/>
|
|
321
|
+
</label>
|
|
322
|
+
{(icon instanceof File || kept !== null) && (
|
|
323
|
+
<Button variant="ghost" size="sm" onClick={() => setIcon(null)}>
|
|
324
|
+
{form.imageRemove}
|
|
325
|
+
</Button>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
210
328
|
{props.error !== undefined && (
|
|
211
329
|
<p className="sc-form-error" role="alert">
|
|
212
330
|
{props.error}
|
package/src/components/runs.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { Badge, Button, formatBytes, PersonChip, Time } from "./ui.js";
|
|
|
9
9
|
|
|
10
10
|
/** The words of a run's status in English: the default pack's. */
|
|
11
11
|
export const RUN_STATUS_LABELS: Readonly<Record<RunStatus, string>> = {
|
|
12
|
-
running: "
|
|
12
|
+
running: "Open",
|
|
13
13
|
waiting: "Waiting for a decision",
|
|
14
14
|
finished: "Finished",
|
|
15
15
|
failed: "Failed",
|
|
@@ -61,6 +61,12 @@ export function RunCard(props: RunCardProps) {
|
|
|
61
61
|
{words.until} <Time iso={run.endedAt} />
|
|
62
62
|
</>
|
|
63
63
|
)}
|
|
64
|
+
{open && (
|
|
65
|
+
<>
|
|
66
|
+
{" "}
|
|
67
|
+
· {words.lastHeard} <Time iso={run.lastHeardAt} />
|
|
68
|
+
</>
|
|
69
|
+
)}
|
|
64
70
|
</span>
|
|
65
71
|
{open && props.onAbandon !== undefined && (
|
|
66
72
|
<Button
|
package/src/components/shell.tsx
CHANGED
|
@@ -34,8 +34,10 @@ export interface ShellProps {
|
|
|
34
34
|
readonly title: string;
|
|
35
35
|
/** The marks to show beside the name; none shows the name alone. */
|
|
36
36
|
readonly brand?: Brand | undefined;
|
|
37
|
-
/** The project the page is on, named after the workspace, or nothing on the workspace's own pages. */
|
|
38
|
-
readonly project?:
|
|
37
|
+
/** The project the page is on, named after the workspace, with its mark when the page has it, or nothing on the workspace's own pages. */
|
|
38
|
+
readonly project?:
|
|
39
|
+
| { readonly name: string; readonly href: string; readonly mark?: ReactNode }
|
|
40
|
+
| undefined;
|
|
39
41
|
/** The pages of the level the person is on, as tabs: the project's, or the workspace's. */
|
|
40
42
|
readonly nav: readonly NavItem[];
|
|
41
43
|
/** The workspace's pages, reached from anywhere through the person's menu. */
|
|
@@ -228,6 +230,7 @@ export function Shell(props: ShellProps) {
|
|
|
228
230
|
href={props.project.href}
|
|
229
231
|
onClick={follow(props.project.href)}
|
|
230
232
|
>
|
|
233
|
+
{props.project.mark}
|
|
231
234
|
{props.project.name}
|
|
232
235
|
</a>
|
|
233
236
|
</span>
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { RestTask } from "../api.js";
|
|
3
3
|
import { useWords } from "../i18n/index.js";
|
|
4
|
+
import { ShowMore, usePaged } from "./paged.js";
|
|
4
5
|
import { Avatar, Badge, CategoryBadge, DueBadge, StateBadge } from "./ui.js";
|
|
5
6
|
|
|
6
7
|
// A list of tasks as rows, where the board's columns are not the point: the calendar's late
|
|
7
8
|
// and unscheduled tasks, a goal's tasks, a person's own work. Each row says what a card says,
|
|
8
|
-
// in one line. Takes its data as props and nothing from
|
|
9
|
+
// in one line; a long list shows a page at a time. Takes its data as props and nothing from
|
|
10
|
+
// the network.
|
|
9
11
|
|
|
10
12
|
export interface TaskListProps {
|
|
11
13
|
readonly tasks: readonly RestTask[];
|
|
@@ -13,7 +15,7 @@ export interface TaskListProps {
|
|
|
13
15
|
/** Whether each row says the task's state: where the rows are not grouped by it. */
|
|
14
16
|
readonly showState?: boolean | undefined;
|
|
15
17
|
/** What the row says the task belongs to, where tasks of several projects are listed together. */
|
|
16
|
-
readonly projectOf?: ((task: RestTask) =>
|
|
18
|
+
readonly projectOf?: ((task: RestTask) => ReactNode) | undefined;
|
|
17
19
|
/** The day to count due days from; the browser's today when left out. */
|
|
18
20
|
readonly today?: string | undefined;
|
|
19
21
|
readonly label?: string | undefined;
|
|
@@ -22,40 +24,41 @@ export interface TaskListProps {
|
|
|
22
24
|
|
|
23
25
|
export function TaskList(props: TaskListProps) {
|
|
24
26
|
const words = useWords();
|
|
27
|
+
const { shown, rest, more } = usePaged(props.tasks);
|
|
25
28
|
if (props.tasks.length === 0) {
|
|
26
29
|
return <>{props.empty ?? null}</>;
|
|
27
30
|
}
|
|
28
31
|
return (
|
|
29
|
-
|
|
30
|
-
{props.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{props.showState === true && <StateBadge state={task.state} />}
|
|
41
|
-
{task.openDecisions > 0 && (
|
|
42
|
-
<Badge tone="warning" title={words.board.decisionsTitle}>
|
|
43
|
-
{words.board.decisions(task.openDecisions)}
|
|
44
|
-
</Badge>
|
|
32
|
+
<>
|
|
33
|
+
<ul className="sc-task-rows" aria-label={props.label}>
|
|
34
|
+
{shown.map((task) => {
|
|
35
|
+
const project = props.projectOf?.(task);
|
|
36
|
+
return (
|
|
37
|
+
<li key={task.id} className="sc-task-row">
|
|
38
|
+
<a className="sc-task-row-title" href={props.taskHref(task)}>
|
|
39
|
+
<span className="sc-card-number">#{task.number}</span> {task.title}
|
|
40
|
+
</a>
|
|
41
|
+
{project !== undefined && project !== null && (
|
|
42
|
+
<span className="sc-task-row-project">{project}</span>
|
|
45
43
|
)}
|
|
46
|
-
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
44
|
+
<span className="sc-task-row-meta">
|
|
45
|
+
{task.category !== null && <CategoryBadge category={task.category} />}
|
|
46
|
+
{props.showState === true && <StateBadge state={task.state} />}
|
|
47
|
+
{task.openDecisions > 0 && (
|
|
48
|
+
<Badge tone="warning" title={words.board.decisionsTitle}>
|
|
49
|
+
{words.board.decisions(task.openDecisions)}
|
|
50
|
+
</Badge>
|
|
51
|
+
)}
|
|
52
|
+
{task.dueOn !== null && (
|
|
53
|
+
<DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
|
|
54
|
+
)}
|
|
55
|
+
{task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
|
|
56
|
+
</span>
|
|
57
|
+
</li>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</ul>
|
|
61
|
+
<ShowMore rest={rest} onMore={more} />
|
|
62
|
+
</>
|
|
60
63
|
);
|
|
61
64
|
}
|
|
@@ -177,7 +177,7 @@ export function TaskView(props: TaskViewProps) {
|
|
|
177
177
|
)}
|
|
178
178
|
{runs.length > 0 && (
|
|
179
179
|
<section className="sc-task-section" aria-label={words.task.runs}>
|
|
180
|
-
<h2 className="sc-section-title">{words.task.
|
|
180
|
+
<h2 className="sc-section-title">{words.task.runs}</h2>
|
|
181
181
|
<RunList
|
|
182
182
|
runs={runs}
|
|
183
183
|
decisionHref={props.decisionHref}
|
package/src/components/work.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RestTask, RestWork } from "../api.js";
|
|
2
2
|
import { addDays, today as todayOf } from "../dates.js";
|
|
3
3
|
import { useWords } from "../i18n/index.js";
|
|
4
|
+
import { ProjectMark } from "./projects.js";
|
|
4
5
|
import { TaskList } from "./task-list.js";
|
|
5
6
|
import { PersonChip, Time } from "./ui.js";
|
|
6
7
|
|
|
@@ -24,7 +25,14 @@ export function WorkList(props: WorkListProps) {
|
|
|
24
25
|
const { tasks, decisions } = props.work;
|
|
25
26
|
const projectOf = new Map(tasks.map((item) => [item.task.id, item.project]));
|
|
26
27
|
const hrefOf = (task: RestTask) => props.taskHref(projectOf.get(task.id)?.key ?? "", task);
|
|
27
|
-
const nameOf = (task: RestTask) =>
|
|
28
|
+
const nameOf = (task: RestTask) => {
|
|
29
|
+
const project = projectOf.get(task.id);
|
|
30
|
+
return project === undefined ? undefined : (
|
|
31
|
+
<>
|
|
32
|
+
<ProjectMark project={project} size="sm" /> {project.name}
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
28
36
|
const groups: { readonly title: string; readonly tasks: RestTask[] }[] = [
|
|
29
37
|
{ title: work.overdue, tasks: [] },
|
|
30
38
|
{ title: work.thisWeek, tasks: [] },
|
|
@@ -69,7 +77,9 @@ export function WorkList(props: WorkListProps) {
|
|
|
69
77
|
>
|
|
70
78
|
{decision.question}
|
|
71
79
|
</a>
|
|
72
|
-
<span className="sc-task-row-project">
|
|
80
|
+
<span className="sc-task-row-project">
|
|
81
|
+
<ProjectMark project={project} size="sm" /> {work.inProject(project.name)}
|
|
82
|
+
</span>
|
|
73
83
|
<span className="sc-decision-row-meta">
|
|
74
84
|
<PersonChip person={decision.raisedBy} /> <Time iso={decision.createdAt} />
|
|
75
85
|
</span>
|
package/src/console.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
3
3
|
import {
|
|
4
4
|
type ConsoleClient,
|
|
5
5
|
createClient,
|
|
6
|
+
type FileUpload,
|
|
6
7
|
OPEN_TASK_STATES,
|
|
7
8
|
type RestAnswerInput,
|
|
8
9
|
type RestDecision,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
ProjectForm,
|
|
35
36
|
ProjectList,
|
|
36
37
|
type ProjectListProps,
|
|
38
|
+
ProjectMark,
|
|
37
39
|
} from "./components/projects.js";
|
|
38
40
|
import {
|
|
39
41
|
type Brand,
|
|
@@ -354,9 +356,12 @@ function ProjectsPage(props: {
|
|
|
354
356
|
<section className="sc-panel sc-panel-form" aria-label={words.form.aria}>
|
|
355
357
|
<ProjectForm
|
|
356
358
|
busy={busy}
|
|
357
|
-
onSubmit={(input) =>
|
|
359
|
+
onSubmit={(input, icon) =>
|
|
358
360
|
void act(async () => {
|
|
359
|
-
const made = await data.actions.createProject(
|
|
361
|
+
const made = await data.actions.createProject(
|
|
362
|
+
input as RestProjectInput,
|
|
363
|
+
icon instanceof File ? uploadOf(icon) : undefined,
|
|
364
|
+
);
|
|
360
365
|
navigation.replace(projectHref(made.key));
|
|
361
366
|
})
|
|
362
367
|
}
|
|
@@ -532,7 +537,6 @@ function ProjectPage(props: {
|
|
|
532
537
|
)}
|
|
533
538
|
<components.Board
|
|
534
539
|
tasks={shown}
|
|
535
|
-
runs={data.runs}
|
|
536
540
|
taskHref={href}
|
|
537
541
|
onOpen={(task) => navigation.go(href(task))}
|
|
538
542
|
onMove={onMove}
|
|
@@ -592,7 +596,9 @@ function ProjectPage(props: {
|
|
|
592
596
|
if (route.name === "calendar") {
|
|
593
597
|
const now = today();
|
|
594
598
|
const month = route.month ?? monthOf(now);
|
|
595
|
-
const
|
|
599
|
+
const me = data.me?.person ?? undefined;
|
|
600
|
+
const shown = filterTasks(data.tasks, route.filter, me);
|
|
601
|
+
const open = shown.filter((task) => OPEN_TASK_STATES.includes(task.state));
|
|
596
602
|
const overdue = open.filter((task) => task.dueOn !== null && task.dueOn < now).sort(byDue);
|
|
597
603
|
const unscheduled = open.filter((task) => task.dueOn === null).sort(byDue);
|
|
598
604
|
const calendar = words.calendar;
|
|
@@ -605,12 +611,25 @@ function ProjectPage(props: {
|
|
|
605
611
|
</LinkButton>
|
|
606
612
|
</div>
|
|
607
613
|
<p className="sc-lead">{calendar.lead}</p>
|
|
614
|
+
{data.tasks.length > 0 && (
|
|
615
|
+
<BoardFilters
|
|
616
|
+
filter={route.filter}
|
|
617
|
+
categories={data.categories}
|
|
618
|
+
goals={data.goals}
|
|
619
|
+
people={data.people}
|
|
620
|
+
me={me}
|
|
621
|
+
shown={shown.length}
|
|
622
|
+
total={data.tasks.length}
|
|
623
|
+
onChange={(filter) => navigation.replace(calendarHref(key, route.month, filter))}
|
|
624
|
+
/>
|
|
625
|
+
)}
|
|
608
626
|
<components.Calendar
|
|
609
|
-
tasks={
|
|
627
|
+
tasks={shown}
|
|
610
628
|
month={month}
|
|
611
629
|
today={now}
|
|
630
|
+
categories={data.categories}
|
|
612
631
|
taskHref={href}
|
|
613
|
-
monthHref={(wanted) => calendarHref(key, wanted)}
|
|
632
|
+
monthHref={(wanted) => calendarHref(key, wanted, route.filter)}
|
|
614
633
|
/>
|
|
615
634
|
<div className="sc-calendar-below">
|
|
616
635
|
<section className="sc-task-section" aria-label={calendar.overdue}>
|
|
@@ -643,7 +662,7 @@ function ProjectPage(props: {
|
|
|
643
662
|
<h2 className="sc-section-title">{calendar.workload.title}</h2>
|
|
644
663
|
<p className="sc-lead">{calendar.workload.lead}</p>
|
|
645
664
|
<Workload
|
|
646
|
-
tasks={
|
|
665
|
+
tasks={shown}
|
|
647
666
|
today={now}
|
|
648
667
|
personHref={(person) =>
|
|
649
668
|
boardHref(key, person === undefined ? {} : { person: person.id })
|
|
@@ -950,8 +969,15 @@ function ProjectPage(props: {
|
|
|
950
969
|
<ProjectForm
|
|
951
970
|
project={project}
|
|
952
971
|
busy={busy}
|
|
953
|
-
onSubmit={(input) =>
|
|
954
|
-
void act(() =>
|
|
972
|
+
onSubmit={(input, icon) =>
|
|
973
|
+
void act(async () => {
|
|
974
|
+
await data.actions.updateProject(input as RestProjectPatch);
|
|
975
|
+
if (icon === null) {
|
|
976
|
+
await data.actions.removeProjectIcon();
|
|
977
|
+
} else if (icon !== undefined) {
|
|
978
|
+
await data.actions.setProjectIcon(uploadOf(icon));
|
|
979
|
+
}
|
|
980
|
+
})
|
|
955
981
|
}
|
|
956
982
|
/>
|
|
957
983
|
</section>
|
|
@@ -961,6 +987,13 @@ function ProjectPage(props: {
|
|
|
961
987
|
return <NothingHere />;
|
|
962
988
|
}
|
|
963
989
|
|
|
990
|
+
/** A file a person chose, as the client sends it: its name, its bytes and the type the browser says. */
|
|
991
|
+
const uploadOf = (file: File): FileUpload => ({
|
|
992
|
+
name: file.name,
|
|
993
|
+
bytes: file,
|
|
994
|
+
...(file.type === "" ? {} : { contentType: file.type }),
|
|
995
|
+
});
|
|
996
|
+
|
|
964
997
|
function NothingHere() {
|
|
965
998
|
const words = useWords();
|
|
966
999
|
return (
|
|
@@ -1324,16 +1357,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
|
1324
1357
|
current: route.name === "agents" || route.name === "new-token",
|
|
1325
1358
|
},
|
|
1326
1359
|
]),
|
|
1327
|
-
// The categories are read by everyone and kept by an administrator
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
current: route.name === "categories",
|
|
1334
|
-
},
|
|
1335
|
-
]
|
|
1336
|
-
: []),
|
|
1360
|
+
// The categories are read by everyone, for what the colours mean, and kept by an administrator.
|
|
1361
|
+
{
|
|
1362
|
+
href: PATHS.categories,
|
|
1363
|
+
label: words.nav.categories,
|
|
1364
|
+
current: route.name === "categories",
|
|
1365
|
+
},
|
|
1337
1366
|
];
|
|
1338
1367
|
const nav: NavItem[] =
|
|
1339
1368
|
projectKey === undefined
|
|
@@ -1454,7 +1483,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
|
1454
1483
|
project={
|
|
1455
1484
|
projectKey === undefined
|
|
1456
1485
|
? undefined
|
|
1457
|
-
: {
|
|
1486
|
+
: {
|
|
1487
|
+
name: project?.name ?? projectKey,
|
|
1488
|
+
href: projectHref(projectKey),
|
|
1489
|
+
mark:
|
|
1490
|
+
project === undefined ? undefined : <ProjectMark project={project} size="sm" />,
|
|
1491
|
+
}
|
|
1458
1492
|
}
|
|
1459
1493
|
nav={nav}
|
|
1460
1494
|
menu={workspaceNav}
|