@skillcdn/console 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/api.d.ts +4 -4
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +5 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +290 -46
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/credentials.d.ts +5 -0
- package/dist/cli/credentials.d.ts.map +1 -1
- package/dist/cli/credentials.js +2 -0
- package/dist/cli/credentials.js.map +1 -1
- package/dist/cli/directory.d.ts +7 -0
- package/dist/cli/directory.d.ts.map +1 -1
- package/dist/cli/directory.js +7 -0
- package/dist/cli/directory.js.map +1 -1
- package/dist/cli/format.d.ts +10 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +76 -20
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +42 -23
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/client.d.ts +39 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +28 -6
- package/dist/compat.d.ts.map +1 -1
- package/dist/compat.js +55 -14
- package/dist/compat.js.map +1 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +6 -4
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +18 -8
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +39 -27
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +2 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/pack-connect.d.ts +23 -0
- package/dist/components/pack-connect.d.ts.map +1 -0
- package/dist/components/pack-connect.js +86 -0
- package/dist/components/pack-connect.js.map +1 -0
- package/dist/components/pack-token.d.ts +27 -0
- package/dist/components/pack-token.d.ts.map +1 -0
- package/dist/components/pack-token.js +11 -0
- package/dist/components/pack-token.js.map +1 -0
- package/dist/components/picker.d.ts +39 -0
- package/dist/components/picker.d.ts.map +1 -0
- package/dist/components/picker.js +155 -0
- package/dist/components/picker.js.map +1 -0
- package/dist/components/projects.d.ts +13 -0
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +37 -5
- package/dist/components/projects.js.map +1 -1
- package/dist/components/task-form.d.ts +18 -4
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +56 -13
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/task-list.js +2 -2
- package/dist/components/task-list.js.map +1 -1
- package/dist/components/task-view.d.ts +7 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -5
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +7 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +11 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts +3 -1
- package/dist/components/work.d.ts.map +1 -1
- package/dist/components/work.js +6 -4
- package/dist/components/work.js.map +1 -1
- package/dist/components/workspace.d.ts +46 -0
- package/dist/components/workspace.d.ts.map +1 -0
- package/dist/components/workspace.js +124 -0
- package/dist/components/workspace.js.map +1 -0
- package/dist/console.css +319 -11
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +83 -24
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +26 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +59 -1
- package/dist/data.js.map +1 -1
- package/dist/dates.d.ts +12 -2
- package/dist/dates.d.ts.map +1 -1
- package/dist/dates.js +20 -2
- package/dist/dates.js.map +1 -1
- package/dist/i18n/en.d.ts +64 -3
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +81 -16
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +77 -16
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +2 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +2 -0
- package/dist/limits.js.map +1 -1
- package/dist/router.d.ts +4 -4
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +11 -2
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +4 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +4 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +194 -13
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +121 -21
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +21 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +18 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +25 -20
- package/docs/specs/rest.md +42 -19
- package/package.json +2 -2
- package/skills/building-a-console/SKILL.md +6 -6
- package/skills/building-a-console/references/the-look-from-a-website.md +39 -0
- package/skills/deploying-a-console/SKILL.md +9 -8
- package/skills/deploying-a-console/references/resources.md +1 -1
- package/skills/working-the-board/SKILL.md +5 -5
- package/skills/working-the-board/references/setting-up.md +1 -1
- package/skills/working-the-board/references/terms.md +3 -1
- package/skills/working-the-board/references/the-sentence.md +1 -1
- package/src/api.ts +21 -0
- package/src/cli/cli.ts +384 -51
- package/src/cli/credentials.ts +7 -0
- package/src/cli/directory.ts +9 -0
- package/src/cli/format.ts +83 -20
- package/src/cli/help.ts +50 -23
- package/src/cli/main.ts +1 -0
- package/src/client.ts +107 -10
- package/src/compat.ts +63 -14
- package/src/components/board.tsx +10 -4
- package/src/components/calendar.tsx +76 -34
- package/src/components/event-feed.tsx +2 -0
- package/src/components/pack-connect.tsx +179 -0
- package/src/components/pack-token.tsx +74 -0
- package/src/components/picker.tsx +297 -0
- package/src/components/projects.tsx +108 -9
- package/src/components/task-form.tsx +149 -63
- package/src/components/task-list.tsx +2 -2
- package/src/components/task-view.tsx +23 -9
- package/src/components/ui.tsx +25 -0
- package/src/components/work.tsx +10 -5
- package/src/components/workspace.tsx +298 -0
- package/src/console.tsx +202 -32
- package/src/data.ts +101 -1
- package/src/dates.ts +23 -2
- package/src/i18n/en.ts +92 -16
- package/src/i18n/ko.ts +84 -16
- package/src/index.ts +32 -1
- package/src/limits.ts +2 -0
- package/src/router.ts +17 -5
- package/src/routes.ts +4 -0
- package/src/schemas.ts +140 -19
- package/src/styles/console.css +319 -11
- package/src/vocabulary.ts +28 -0
|
@@ -305,7 +305,7 @@ export function ProjectForm(props: ProjectFormProps) {
|
|
|
305
305
|
onChange={(event) => setSkillsAddress(event.target.value)}
|
|
306
306
|
/>
|
|
307
307
|
</span>
|
|
308
|
-
<span className="sc-
|
|
308
|
+
<span className="sc-field-hint">
|
|
309
309
|
{form.skillsAddressHint(props.organizationAddress ?? null)}
|
|
310
310
|
</span>
|
|
311
311
|
</label>
|
|
@@ -383,8 +383,19 @@ export interface MemberListProps {
|
|
|
383
383
|
readonly people: readonly RestPerson[];
|
|
384
384
|
/** Whoever is looking, so that their own row says so. */
|
|
385
385
|
readonly me?: RestPerson | undefined;
|
|
386
|
+
/**
|
|
387
|
+
* Whether the project is open to the workspace (ADR-0035): everyone of it is a member, the
|
|
388
|
+
* list is everyone, nobody is added or removed, and an owner is made from a row.
|
|
389
|
+
*/
|
|
390
|
+
readonly open?: boolean | undefined;
|
|
386
391
|
/** Called with a person to list, and what they are to be. Left out, the list is read here, not changed. */
|
|
387
392
|
readonly onAdd?: ((input: RestMemberInput) => void) | undefined;
|
|
393
|
+
/**
|
|
394
|
+
* Finds a person of the workspace by their exact login, or nobody: given to an owner whose
|
|
395
|
+
* People page shows only whom they share a project with, so that they list someone it does
|
|
396
|
+
* not show (ADR-0035). With `onAdd` only.
|
|
397
|
+
*/
|
|
398
|
+
readonly lookup?: ((login: string) => Promise<RestPerson | undefined>) | undefined;
|
|
388
399
|
readonly onChangeRole?: ((member: RestMember, role: ProjectRole) => void) | undefined;
|
|
389
400
|
readonly onRemove?: ((member: RestMember) => void) | undefined;
|
|
390
401
|
/** Where a member's work is seen; given to the project's owners. */
|
|
@@ -400,6 +411,30 @@ export function MemberList(props: MemberListProps) {
|
|
|
400
411
|
const candidates = props.people.filter((person) => !listed.has(person.id));
|
|
401
412
|
const [personId, setPersonId] = useState("");
|
|
402
413
|
const [role, setRole] = useState<ProjectRole>("member");
|
|
414
|
+
const [login, setLogin] = useState("");
|
|
415
|
+
const [notFound, setNotFound] = useState<string | undefined>(undefined);
|
|
416
|
+
const [looking, setLooking] = useState(false);
|
|
417
|
+
const addByLogin = async () => {
|
|
418
|
+
const wanted = login.trim();
|
|
419
|
+
if (wanted === "" || props.lookup === undefined) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
setLooking(true);
|
|
423
|
+
try {
|
|
424
|
+
const found = await props.lookup(wanted);
|
|
425
|
+
if (found === undefined) {
|
|
426
|
+
setNotFound(wanted);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
setNotFound(undefined);
|
|
430
|
+
setLogin("");
|
|
431
|
+
props.onAdd?.({ personId: found.id, role });
|
|
432
|
+
} catch {
|
|
433
|
+
setNotFound(wanted);
|
|
434
|
+
} finally {
|
|
435
|
+
setLooking(false);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
403
438
|
return (
|
|
404
439
|
<div className="sc-members">
|
|
405
440
|
{props.members.length === 0 ? (
|
|
@@ -419,8 +454,14 @@ export function MemberList(props: MemberListProps) {
|
|
|
419
454
|
{members.since} <Time iso={member.addedAt} />
|
|
420
455
|
</span>
|
|
421
456
|
</span>
|
|
422
|
-
{props.onChangeRole === undefined ? (
|
|
423
|
-
|
|
457
|
+
{props.onChangeRole === undefined || member.person.role === "admin" ? (
|
|
458
|
+
// An administrator owns every project by office: nothing here changes that.
|
|
459
|
+
<span className="sc-member-role">
|
|
460
|
+
<ProjectRoleBadge role={member.role} />
|
|
461
|
+
{member.person.role === "admin" && (
|
|
462
|
+
<span className="sc-muted"> {words.vocabulary.role.admin}</span>
|
|
463
|
+
)}
|
|
464
|
+
</span>
|
|
424
465
|
) : (
|
|
425
466
|
<label className="sc-field sc-field-inline">
|
|
426
467
|
<span className="sc-visually-hidden">
|
|
@@ -447,7 +488,7 @@ export function MemberList(props: MemberListProps) {
|
|
|
447
488
|
{words.people.work}
|
|
448
489
|
</a>
|
|
449
490
|
)}
|
|
450
|
-
{props.onRemove !== undefined && (
|
|
491
|
+
{props.onRemove !== undefined && props.open !== true && (
|
|
451
492
|
<Button
|
|
452
493
|
variant="danger"
|
|
453
494
|
size="sm"
|
|
@@ -461,7 +502,7 @@ export function MemberList(props: MemberListProps) {
|
|
|
461
502
|
))}
|
|
462
503
|
</ul>
|
|
463
504
|
)}
|
|
464
|
-
{props.onAdd !== undefined && candidates.length > 0 && (
|
|
505
|
+
{props.onAdd !== undefined && props.open !== true && candidates.length > 0 && (
|
|
465
506
|
<form
|
|
466
507
|
className="sc-form sc-member-form"
|
|
467
508
|
aria-label={members.addAria}
|
|
@@ -515,6 +556,57 @@ export function MemberList(props: MemberListProps) {
|
|
|
515
556
|
</div>
|
|
516
557
|
</form>
|
|
517
558
|
)}
|
|
559
|
+
{props.onAdd !== undefined && props.lookup !== undefined && props.open !== true && (
|
|
560
|
+
<form
|
|
561
|
+
className="sc-form sc-member-form sc-member-lookup"
|
|
562
|
+
aria-label={members.addByLoginAria}
|
|
563
|
+
onSubmit={(event) => {
|
|
564
|
+
event.preventDefault();
|
|
565
|
+
void addByLogin();
|
|
566
|
+
}}
|
|
567
|
+
>
|
|
568
|
+
<div className="sc-field-row">
|
|
569
|
+
<label className="sc-field">
|
|
570
|
+
<span className="sc-field-label">{members.login}</span>
|
|
571
|
+
<input
|
|
572
|
+
className="sc-input"
|
|
573
|
+
value={login}
|
|
574
|
+
autoComplete="off"
|
|
575
|
+
onChange={(event) => {
|
|
576
|
+
setLogin(event.target.value);
|
|
577
|
+
setNotFound(undefined);
|
|
578
|
+
}}
|
|
579
|
+
/>
|
|
580
|
+
</label>
|
|
581
|
+
<label className="sc-field">
|
|
582
|
+
<span className="sc-field-label">{members.as}</span>
|
|
583
|
+
<select
|
|
584
|
+
className="sc-input"
|
|
585
|
+
value={role}
|
|
586
|
+
onChange={(event) => setRole(event.target.value as ProjectRole)}
|
|
587
|
+
>
|
|
588
|
+
{PROJECT_ROLES.map((candidate) => (
|
|
589
|
+
<option key={candidate} value={candidate}>
|
|
590
|
+
{words.vocabulary.projectRole[candidate]}
|
|
591
|
+
</option>
|
|
592
|
+
))}
|
|
593
|
+
</select>
|
|
594
|
+
</label>
|
|
595
|
+
<div className="sc-form-actions">
|
|
596
|
+
<Button
|
|
597
|
+
type="submit"
|
|
598
|
+
variant="primary"
|
|
599
|
+
disabled={props.busy === true || looking || login.trim() === ""}
|
|
600
|
+
>
|
|
601
|
+
{members.addByLogin}
|
|
602
|
+
</Button>
|
|
603
|
+
</div>
|
|
604
|
+
</div>
|
|
605
|
+
<p className="sc-field-hint">
|
|
606
|
+
{notFound === undefined ? members.lookupHint : members.nobodyCalled(notFound)}
|
|
607
|
+
</p>
|
|
608
|
+
</form>
|
|
609
|
+
)}
|
|
518
610
|
</div>
|
|
519
611
|
);
|
|
520
612
|
}
|
|
@@ -522,6 +614,8 @@ export function MemberList(props: MemberListProps) {
|
|
|
522
614
|
export interface PackTokenFormProps {
|
|
523
615
|
/** Whether the project holds a pack token now, so that it can be taken away. */
|
|
524
616
|
readonly set: boolean;
|
|
617
|
+
/** When the token held now was set, so that an owner knows what they replace. */
|
|
618
|
+
readonly setAt?: string | undefined;
|
|
525
619
|
readonly busy?: boolean | undefined;
|
|
526
620
|
readonly error?: string | undefined;
|
|
527
621
|
/** Called with the token as typed or pasted; it travels to the console once and is shown nowhere after. */
|
|
@@ -539,8 +633,8 @@ export function PackTokenForm(props: PackTokenFormProps) {
|
|
|
539
633
|
const ready = typed.length >= 8 && !/\s/.test(typed);
|
|
540
634
|
return (
|
|
541
635
|
<form
|
|
542
|
-
className="sc-form"
|
|
543
|
-
aria-label={pack.
|
|
636
|
+
className="sc-form sc-pack-token-form"
|
|
637
|
+
aria-label={pack.pasteAria}
|
|
544
638
|
onSubmit={(event: FormEvent) => {
|
|
545
639
|
event.preventDefault();
|
|
546
640
|
if (ready && props.busy !== true) {
|
|
@@ -550,6 +644,11 @@ export function PackTokenForm(props: PackTokenFormProps) {
|
|
|
550
644
|
}}
|
|
551
645
|
>
|
|
552
646
|
<p className="sc-field-hint">{pack.hint}</p>
|
|
647
|
+
{props.set && props.setAt !== undefined && (
|
|
648
|
+
<p className="sc-muted">
|
|
649
|
+
{pack.setSince} <Time iso={props.setAt} />
|
|
650
|
+
</p>
|
|
651
|
+
)}
|
|
553
652
|
<label className="sc-field">
|
|
554
653
|
<span className="sc-field-label">{pack.label}</span>
|
|
555
654
|
<input
|
|
@@ -569,8 +668,8 @@ export function PackTokenForm(props: PackTokenFormProps) {
|
|
|
569
668
|
</p>
|
|
570
669
|
)}
|
|
571
670
|
<div className="sc-form-actions">
|
|
572
|
-
<Button type="submit" variant="
|
|
573
|
-
{pack.set}
|
|
671
|
+
<Button type="submit" variant="secondary" disabled={!ready || props.busy === true}>
|
|
672
|
+
{props.set ? pack.replace : pack.set}
|
|
574
673
|
</Button>
|
|
575
674
|
{props.set && props.onRemove !== undefined && (
|
|
576
675
|
<Button variant="danger" disabled={props.busy === true} onClick={props.onRemove}>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { type FormEvent, useState } from "react";
|
|
1
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
MAX_BODY_LENGTH,
|
|
4
4
|
MAX_LINK_LABEL_LENGTH,
|
|
5
5
|
MAX_LINKS,
|
|
6
6
|
MAX_TITLE_LENGTH,
|
|
7
7
|
MAX_URL_LENGTH,
|
|
8
|
+
MAX_WORKERS,
|
|
8
9
|
type RestCategory,
|
|
9
10
|
type RestGoal,
|
|
10
11
|
type RestPerson,
|
|
@@ -14,22 +15,33 @@ import {
|
|
|
14
15
|
TASK_STATES,
|
|
15
16
|
} from "../api.js";
|
|
16
17
|
import { useWords } from "../i18n/index.js";
|
|
17
|
-
import {
|
|
18
|
+
import { Picker, type PickerSearch } from "./picker.js";
|
|
19
|
+
import { Button, GoalStateBadge, PersonChip, personName, StateBadge } from "./ui.js";
|
|
18
20
|
|
|
19
21
|
// Writing a task, or changing one. The bounds are the package's, said before a request is made.
|
|
20
22
|
// The plan is two days (ADR-0019); what happened to the task is the board's to keep, not a
|
|
21
|
-
// field here.
|
|
23
|
+
// field here. The workers are any number of the people (ADR-0037); the task this one is part of
|
|
24
|
+
// and the goal it works towards are found by searching, a page at a time, never offered whole.
|
|
25
|
+
|
|
26
|
+
/** What words find among the tasks of the project, a page at a time. */
|
|
27
|
+
export type TaskSearch = PickerSearch<RestTask>;
|
|
28
|
+
/** What words find among the open goals of the project, a page at a time. */
|
|
29
|
+
export type GoalSearch = PickerSearch<RestGoal>;
|
|
22
30
|
|
|
23
31
|
export interface TaskFormProps {
|
|
24
32
|
readonly people: readonly RestPerson[];
|
|
25
33
|
/** The task to change; left out, the form writes a new one. */
|
|
26
34
|
readonly task?: RestTask | undefined;
|
|
27
|
-
/** The
|
|
28
|
-
readonly
|
|
35
|
+
/** The task this one is part of, as it stands, for the form to show it; the search finds another. */
|
|
36
|
+
readonly parent?: RestTask | undefined;
|
|
37
|
+
/** Left out, a task cannot be made part of one here. */
|
|
38
|
+
readonly findTasks?: TaskSearch | undefined;
|
|
29
39
|
/** The categories of the workspace, to sort the task into one. */
|
|
30
40
|
readonly categories?: readonly RestCategory[] | undefined;
|
|
31
|
-
/** The
|
|
32
|
-
readonly
|
|
41
|
+
/** The goal the task works towards, as it stands, for the form to show it; the search finds another. */
|
|
42
|
+
readonly goal?: RestGoal | undefined;
|
|
43
|
+
/** Left out, no goal is offered. */
|
|
44
|
+
readonly findGoals?: GoalSearch | undefined;
|
|
33
45
|
readonly busy?: boolean | undefined;
|
|
34
46
|
readonly error?: string | undefined;
|
|
35
47
|
readonly onSubmit: (input: RestTaskInput) => void;
|
|
@@ -41,6 +53,25 @@ interface LinkDraft {
|
|
|
41
53
|
readonly label: string;
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
/** A task as a picker names one: its number and its title, with its state. */
|
|
57
|
+
export function taskLabel(task: RestTask): ReactNode {
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<span className="sc-card-number">#{task.number}</span> {task.title}{" "}
|
|
61
|
+
<StateBadge state={task.state} />
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A goal as a picker names one: its title, with its state. */
|
|
67
|
+
export function goalLabel(goal: RestGoal): ReactNode {
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
{goal.title} <GoalStateBadge state={goal.state} />
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
44
75
|
export function TaskForm(props: TaskFormProps) {
|
|
45
76
|
const words = useWords();
|
|
46
77
|
const form = words.task.form;
|
|
@@ -49,10 +80,16 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
49
80
|
const [body, setBody] = useState(task?.body ?? "");
|
|
50
81
|
const [state, setState] = useState(task?.state ?? "idea");
|
|
51
82
|
const [priority, setPriority] = useState(task?.priority ?? "normal");
|
|
52
|
-
const [
|
|
53
|
-
|
|
83
|
+
const [workerIds, setWorkerIds] = useState<readonly string[]>(
|
|
84
|
+
task?.workers.map((worker) => worker.id) ?? [],
|
|
85
|
+
);
|
|
86
|
+
// What is chosen is kept by id, so that a parent or a goal the page has not read back yet is
|
|
87
|
+
// kept as it is rather than dropped; the one chosen here is kept whole, to be shown.
|
|
88
|
+
const [parentId, setParentId] = useState<string | null>(task?.parentId ?? null);
|
|
89
|
+
const [parentChosen, setParentChosen] = useState<RestTask | undefined>(undefined);
|
|
90
|
+
const [goalId, setGoalId] = useState<string | null>(task?.goalId ?? null);
|
|
91
|
+
const [goalChosen, setGoalChosen] = useState<RestGoal | undefined>(undefined);
|
|
54
92
|
const [category, setCategory] = useState(task?.category?.key ?? "");
|
|
55
|
-
const [goalId, setGoalId] = useState(task?.goalId ?? "");
|
|
56
93
|
const [startsOn, setStartsOn] = useState(task?.startsOn ?? "");
|
|
57
94
|
const [dueOn, setDueOn] = useState(task?.dueOn ?? "");
|
|
58
95
|
const [links, setLinks] = useState<readonly LinkDraft[]>(
|
|
@@ -66,10 +103,10 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
66
103
|
body,
|
|
67
104
|
state,
|
|
68
105
|
priority,
|
|
69
|
-
|
|
70
|
-
parentId
|
|
106
|
+
workerIds: [...workerIds],
|
|
107
|
+
parentId,
|
|
71
108
|
category: category === "" ? null : category,
|
|
72
|
-
goalId
|
|
109
|
+
goalId,
|
|
73
110
|
startsOn: startsOn === "" ? null : startsOn,
|
|
74
111
|
dueOn: dueOn === "" ? null : dueOn,
|
|
75
112
|
links: links
|
|
@@ -81,12 +118,33 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
81
118
|
});
|
|
82
119
|
};
|
|
83
120
|
|
|
84
|
-
|
|
121
|
+
// A worker is one of the people, or one the task had who is no longer among them.
|
|
122
|
+
const personOf = (id: string): RestPerson | undefined =>
|
|
123
|
+
props.people.find((person) => person.id === id) ??
|
|
124
|
+
task?.workers.find((worker) => worker.id === id);
|
|
125
|
+
const chosenWorkers = workerIds.flatMap((id) => {
|
|
126
|
+
const person = personOf(id);
|
|
127
|
+
return person === undefined ? [] : [person];
|
|
128
|
+
});
|
|
129
|
+
const otherPeople = props.people.filter((person) => !workerIds.includes(person.id));
|
|
130
|
+
const parent =
|
|
131
|
+
parentId === null
|
|
132
|
+
? undefined
|
|
133
|
+
: parentChosen?.id === parentId
|
|
134
|
+
? parentChosen
|
|
135
|
+
: props.parent?.id === parentId
|
|
136
|
+
? props.parent
|
|
137
|
+
: undefined;
|
|
138
|
+
const goal =
|
|
139
|
+
goalId === null
|
|
140
|
+
? undefined
|
|
141
|
+
: goalChosen?.id === goalId
|
|
142
|
+
? goalChosen
|
|
143
|
+
: props.goal?.id === goalId
|
|
144
|
+
? props.goal
|
|
145
|
+
: undefined;
|
|
146
|
+
const held = <span className="sc-muted">…</span>;
|
|
85
147
|
const categories = props.categories ?? [];
|
|
86
|
-
// A goal let go is not offered, unless the task is on it already.
|
|
87
|
-
const goals = (props.goals ?? []).filter(
|
|
88
|
-
(goal) => goal.state !== "dropped" || goal.id === task?.goalId,
|
|
89
|
-
);
|
|
90
148
|
const datesInOrder = startsOn === "" || dueOn === "" || startsOn <= dueOn;
|
|
91
149
|
|
|
92
150
|
return (
|
|
@@ -142,21 +200,6 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
142
200
|
))}
|
|
143
201
|
</select>
|
|
144
202
|
</label>
|
|
145
|
-
<label className="sc-field">
|
|
146
|
-
<span className="sc-field-label">{form.assignee}</span>
|
|
147
|
-
<select
|
|
148
|
-
className="sc-input"
|
|
149
|
-
value={assigneeId}
|
|
150
|
-
onChange={(event) => setAssigneeId(event.target.value)}
|
|
151
|
-
>
|
|
152
|
-
<option value="">{words.common.nobody}</option>
|
|
153
|
-
{props.people.map((person) => (
|
|
154
|
-
<option key={person.id} value={person.id}>
|
|
155
|
-
{personName(person)}
|
|
156
|
-
</option>
|
|
157
|
-
))}
|
|
158
|
-
</select>
|
|
159
|
-
</label>
|
|
160
203
|
{categories.length > 0 && (
|
|
161
204
|
<label className="sc-field">
|
|
162
205
|
<span className="sc-field-label">{form.category}</span>
|
|
@@ -175,6 +218,44 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
175
218
|
</label>
|
|
176
219
|
)}
|
|
177
220
|
</div>
|
|
221
|
+
<fieldset className="sc-field sc-workers-field">
|
|
222
|
+
<legend className="sc-field-label">{form.workers}</legend>
|
|
223
|
+
<div className="sc-chips">
|
|
224
|
+
{chosenWorkers.map((person) => (
|
|
225
|
+
<span key={person.id} className="sc-chip">
|
|
226
|
+
<PersonChip person={person} />
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
className="sc-chip-remove"
|
|
230
|
+
aria-label={`${words.common.remove}: ${personName(person)}`}
|
|
231
|
+
onClick={() => setWorkerIds(workerIds.filter((id) => id !== person.id))}
|
|
232
|
+
>
|
|
233
|
+
<span aria-hidden="true">×</span>
|
|
234
|
+
</button>
|
|
235
|
+
</span>
|
|
236
|
+
))}
|
|
237
|
+
{chosenWorkers.length === 0 && <span className="sc-muted">{words.common.nobody}</span>}
|
|
238
|
+
{otherPeople.length > 0 && workerIds.length < MAX_WORKERS && (
|
|
239
|
+
<select
|
|
240
|
+
className="sc-input sc-chip-add"
|
|
241
|
+
aria-label={form.addWorker}
|
|
242
|
+
value=""
|
|
243
|
+
onChange={(event) => {
|
|
244
|
+
if (event.target.value !== "") {
|
|
245
|
+
setWorkerIds([...workerIds, event.target.value]);
|
|
246
|
+
}
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
<option value="">{form.addWorker}</option>
|
|
250
|
+
{otherPeople.map((person) => (
|
|
251
|
+
<option key={person.id} value={person.id}>
|
|
252
|
+
{personName(person)}
|
|
253
|
+
</option>
|
|
254
|
+
))}
|
|
255
|
+
</select>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
</fieldset>
|
|
178
259
|
<div className="sc-field-row">
|
|
179
260
|
<label className="sc-field">
|
|
180
261
|
<span className="sc-field-label">{form.startsOn}</span>
|
|
@@ -195,39 +276,44 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
195
276
|
onChange={(event) => setDueOn(event.target.value)}
|
|
196
277
|
/>
|
|
197
278
|
</label>
|
|
198
|
-
{
|
|
199
|
-
<
|
|
279
|
+
{props.findGoals !== undefined && (
|
|
280
|
+
<div className="sc-field">
|
|
200
281
|
<span className="sc-field-label">{form.goal}</span>
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
{
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
282
|
+
<Picker
|
|
283
|
+
value={goal}
|
|
284
|
+
held={goalId === null ? undefined : held}
|
|
285
|
+
search={props.findGoals}
|
|
286
|
+
keyOf={(candidate) => candidate.id}
|
|
287
|
+
render={goalLabel}
|
|
288
|
+
label={form.goal}
|
|
289
|
+
placeholder={form.searchGoals}
|
|
290
|
+
disabled={props.busy}
|
|
291
|
+
onChange={(chosen) => {
|
|
292
|
+
setGoalChosen(chosen);
|
|
293
|
+
setGoalId(chosen?.id ?? null);
|
|
294
|
+
}}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
214
297
|
)}
|
|
215
|
-
{
|
|
216
|
-
<
|
|
298
|
+
{props.findTasks !== undefined && (
|
|
299
|
+
<div className="sc-field">
|
|
217
300
|
<span className="sc-field-label">{form.partOf}</span>
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
{
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
|
|
301
|
+
<Picker
|
|
302
|
+
value={parent}
|
|
303
|
+
held={parentId === null ? undefined : held}
|
|
304
|
+
search={props.findTasks}
|
|
305
|
+
keyOf={(candidate) => candidate.id}
|
|
306
|
+
render={taskLabel}
|
|
307
|
+
label={form.partOf}
|
|
308
|
+
placeholder={form.searchTasks}
|
|
309
|
+
disabled={props.busy}
|
|
310
|
+
exclude={(candidate) => candidate.id === task?.id}
|
|
311
|
+
onChange={(chosen) => {
|
|
312
|
+
setParentChosen(chosen);
|
|
313
|
+
setParentId(chosen?.id ?? null);
|
|
314
|
+
}}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
231
317
|
)}
|
|
232
318
|
</div>
|
|
233
319
|
<fieldset className="sc-field sc-links">
|
|
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { RestTask } from "../api.js";
|
|
3
3
|
import { useWords } from "../i18n/index.js";
|
|
4
4
|
import { ShowMore, usePaged } from "./paged.js";
|
|
5
|
-
import {
|
|
5
|
+
import { Badge, CategoryBadge, DueBadge, StateBadge, Workers } from "./ui.js";
|
|
6
6
|
|
|
7
7
|
// A list of tasks as rows, where the board's columns are not the point: the calendar's late
|
|
8
8
|
// and unscheduled tasks, a goal's tasks, a person's own work. Each row says what a card says,
|
|
@@ -52,7 +52,7 @@ export function TaskList(props: TaskListProps) {
|
|
|
52
52
|
{task.dueOn !== null && (
|
|
53
53
|
<DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
|
|
54
54
|
)}
|
|
55
|
-
|
|
55
|
+
<Workers workers={task.workers} size="sm" />
|
|
56
56
|
</span>
|
|
57
57
|
</li>
|
|
58
58
|
);
|
|
@@ -21,7 +21,7 @@ import { type CommentDraft, TaskThread } from "./comments.js";
|
|
|
21
21
|
import { DecisionForm } from "./decision-form.js";
|
|
22
22
|
import { EventFeed } from "./event-feed.js";
|
|
23
23
|
import { Markdown } from "./markdown.js";
|
|
24
|
-
import { TaskForm } from "./task-form.js";
|
|
24
|
+
import { type GoalSearch, TaskForm, type TaskSearch } from "./task-form.js";
|
|
25
25
|
import {
|
|
26
26
|
Badge,
|
|
27
27
|
Button,
|
|
@@ -44,8 +44,13 @@ import {
|
|
|
44
44
|
export interface TaskViewProps {
|
|
45
45
|
readonly task: RestTask;
|
|
46
46
|
readonly people: readonly RestPerson[];
|
|
47
|
-
/**
|
|
47
|
+
/** The current tasks of the board, for the breakdown and for naming what this one is part of. */
|
|
48
48
|
readonly tasks: readonly RestTask[];
|
|
49
|
+
/** The task this one is part of, when the page has it: one put away is not among `tasks`. Left out, it is looked for among them. */
|
|
50
|
+
readonly parent?: RestTask | undefined;
|
|
51
|
+
/** What words find among the tasks and the open goals of the project, for the form (ADR-0037); left out, the form offers neither. */
|
|
52
|
+
readonly findTasks?: TaskSearch | undefined;
|
|
53
|
+
readonly findGoals?: GoalSearch | undefined;
|
|
49
54
|
readonly decisions: readonly RestDecision[];
|
|
50
55
|
/** The runs of the board, or of this task; the ones on this task are shown. */
|
|
51
56
|
readonly runs?: readonly RestRun[] | undefined;
|
|
@@ -97,9 +102,10 @@ export function TaskView(props: TaskViewProps) {
|
|
|
97
102
|
const { tag } = useLanguage();
|
|
98
103
|
const { task } = props;
|
|
99
104
|
const parent =
|
|
100
|
-
|
|
105
|
+
props.parent ??
|
|
106
|
+
(task.parentId === null
|
|
101
107
|
? undefined
|
|
102
|
-
: props.tasks.find((candidate) => candidate.id === task.parentId);
|
|
108
|
+
: props.tasks.find((candidate) => candidate.id === task.parentId));
|
|
103
109
|
const subtasks = props.tasks.filter((candidate) => candidate.parentId === task.id);
|
|
104
110
|
const about = props.decisions.filter((decision) => decision.taskId === task.id);
|
|
105
111
|
const runs = (props.runs ?? []).filter((run) => run.taskId === task.id);
|
|
@@ -119,9 +125,11 @@ export function TaskView(props: TaskViewProps) {
|
|
|
119
125
|
<TaskForm
|
|
120
126
|
people={props.people}
|
|
121
127
|
task={task}
|
|
122
|
-
|
|
128
|
+
parent={parent}
|
|
129
|
+
findTasks={props.findTasks}
|
|
123
130
|
categories={props.categories}
|
|
124
|
-
|
|
131
|
+
goal={goal}
|
|
132
|
+
findGoals={props.findGoals}
|
|
125
133
|
busy={props.busy}
|
|
126
134
|
error={props.error}
|
|
127
135
|
onSubmit={(input) => props.onChange(task, input)}
|
|
@@ -289,12 +297,18 @@ export function TaskView(props: TaskViewProps) {
|
|
|
289
297
|
</dd>
|
|
290
298
|
</div>
|
|
291
299
|
<div>
|
|
292
|
-
<dt>{words.task.
|
|
300
|
+
<dt>{words.task.workers}</dt>
|
|
293
301
|
<dd>
|
|
294
|
-
{task.
|
|
302
|
+
{task.workers.length === 0 ? (
|
|
295
303
|
<span className="sc-muted">{words.common.nobody}</span>
|
|
296
304
|
) : (
|
|
297
|
-
<
|
|
305
|
+
<ul className="sc-worker-list">
|
|
306
|
+
{task.workers.map((worker) => (
|
|
307
|
+
<li key={worker.id}>
|
|
308
|
+
<PersonChip person={worker} />
|
|
309
|
+
</li>
|
|
310
|
+
))}
|
|
311
|
+
</ul>
|
|
298
312
|
)}
|
|
299
313
|
</dd>
|
|
300
314
|
</div>
|
package/src/components/ui.tsx
CHANGED
|
@@ -145,6 +145,31 @@ export function PersonChip(props: { readonly person: RestPerson; readonly size?:
|
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** How many of a task's workers are shown as pictures before the rest are a count. */
|
|
149
|
+
const WORKERS_SHOWN = 3;
|
|
150
|
+
|
|
151
|
+
/** The people at work on a task (ADR-0037), as pictures side by side with a count of the rest; nothing while nobody is. */
|
|
152
|
+
export function Workers(props: {
|
|
153
|
+
readonly workers: readonly RestPerson[];
|
|
154
|
+
readonly size?: "xs" | "sm";
|
|
155
|
+
/** How many are shown as pictures; the rest are a count. */
|
|
156
|
+
readonly shown?: number;
|
|
157
|
+
}) {
|
|
158
|
+
if (props.workers.length === 0) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const shown = props.shown ?? WORKERS_SHOWN;
|
|
162
|
+
const rest = props.workers.length - shown;
|
|
163
|
+
return (
|
|
164
|
+
<span className="sc-workers" title={props.workers.map(personName).join(", ")}>
|
|
165
|
+
{props.workers.slice(0, shown).map((worker) => (
|
|
166
|
+
<Avatar key={worker.id} person={worker} size={props.size ?? "sm"} />
|
|
167
|
+
))}
|
|
168
|
+
{rest > 0 && <span className="sc-workers-more">+{rest}</span>}
|
|
169
|
+
</span>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
148
173
|
/** The words of the vocabulary as people read them, in English: the default pack's. */
|
|
149
174
|
export const STATE_LABELS: Readonly<Record<TaskState, string>> = {
|
|
150
175
|
idea: "Idea",
|
package/src/components/work.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { RestTask, RestWork } from "../api.js";
|
|
2
|
-
import {
|
|
3
|
-
import { useWords } from "../i18n/index.js";
|
|
1
|
+
import type { RestTask, RestWork, WeekStart } from "../api.js";
|
|
2
|
+
import { deviceLanguage, firstDayOfWeek, today as todayOf, weekOf } from "../dates.js";
|
|
3
|
+
import { useLanguage, useWords } from "../i18n/index.js";
|
|
4
4
|
import { Week } from "./calendar.js";
|
|
5
5
|
import { ProjectMark } from "./projects.js";
|
|
6
6
|
import { TaskList } from "./task-list.js";
|
|
7
7
|
import { PersonChip, Time } from "./ui.js";
|
|
8
8
|
|
|
9
9
|
// What is on a person's plate (ADR-0019), across the projects they may see: the week at the
|
|
10
|
-
// top, with the open tasks
|
|
10
|
+
// top, with the open tasks they work on on its days, and what of theirs was done or
|
|
11
11
|
// dropped on them, as a project's calendar shows it; below, the open tasks by when they are
|
|
12
12
|
// due, and the decisions that wait for a person. Takes its data as props and nothing from the
|
|
13
13
|
// network; the week shown is the address's (ADR-0010).
|
|
@@ -28,13 +28,17 @@ export interface WorkListProps {
|
|
|
28
28
|
readonly decisionHref: (projectKey: string, decisionId: string) => string;
|
|
29
29
|
/** Where the page is with the week of a day at the top. */
|
|
30
30
|
readonly weekHref: (day: string) => string;
|
|
31
|
+
/** The day the week begins on, as the workspace says (ADR-0035); the language's when left out. */
|
|
32
|
+
readonly weekStart?: WeekStart | undefined;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export function WorkList(props: WorkListProps) {
|
|
34
36
|
const words = useWords();
|
|
35
37
|
const work = words.work;
|
|
38
|
+
const { tag } = useLanguage();
|
|
36
39
|
const now = props.today ?? todayOf();
|
|
37
|
-
|
|
40
|
+
// This week ends with the week's last day, as the workspace begins its weeks (ADR-0039).
|
|
41
|
+
const weekEnd = weekOf(now, firstDayOfWeek(deviceLanguage(tag), props.weekStart)).at(-1) ?? now;
|
|
38
42
|
const { tasks, decisions } = props.work;
|
|
39
43
|
const closed = props.work.closed ?? [];
|
|
40
44
|
const projectOf = new Map([...tasks, ...closed].map((item) => [item.task.id, item.project]));
|
|
@@ -70,6 +74,7 @@ export function WorkList(props: WorkListProps) {
|
|
|
70
74
|
taskHref={hrefOf}
|
|
71
75
|
weekHref={props.weekHref}
|
|
72
76
|
markOf={markOf}
|
|
77
|
+
weekStart={props.weekStart}
|
|
73
78
|
/>
|
|
74
79
|
<div className="sc-work-lists">
|
|
75
80
|
{groups
|