@skillcdn/console 0.3.0 → 0.3.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.
Files changed (129) hide show
  1. package/README.md +3 -3
  2. package/dist/api.d.ts +2 -2
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +2 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +1 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +185 -28
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/credentials.d.ts +5 -0
  11. package/dist/cli/credentials.d.ts.map +1 -1
  12. package/dist/cli/credentials.js +2 -0
  13. package/dist/cli/credentials.js.map +1 -1
  14. package/dist/cli/directory.d.ts +7 -0
  15. package/dist/cli/directory.d.ts.map +1 -1
  16. package/dist/cli/directory.js +7 -0
  17. package/dist/cli/directory.js.map +1 -1
  18. package/dist/cli/format.d.ts +10 -1
  19. package/dist/cli/format.d.ts.map +1 -1
  20. package/dist/cli/format.js +67 -13
  21. package/dist/cli/format.js.map +1 -1
  22. package/dist/cli/help.d.ts.map +1 -1
  23. package/dist/cli/help.js +30 -13
  24. package/dist/cli/help.js.map +1 -1
  25. package/dist/client.d.ts +26 -1
  26. package/dist/client.d.ts.map +1 -1
  27. package/dist/client.js +14 -1
  28. package/dist/client.js.map +1 -1
  29. package/dist/components/calendar.d.ts +5 -1
  30. package/dist/components/calendar.d.ts.map +1 -1
  31. package/dist/components/calendar.js +3 -3
  32. package/dist/components/calendar.js.map +1 -1
  33. package/dist/components/event-feed.d.ts.map +1 -1
  34. package/dist/components/event-feed.js +2 -0
  35. package/dist/components/event-feed.js.map +1 -1
  36. package/dist/components/pack-connect.d.ts +23 -0
  37. package/dist/components/pack-connect.d.ts.map +1 -0
  38. package/dist/components/pack-connect.js +86 -0
  39. package/dist/components/pack-connect.js.map +1 -0
  40. package/dist/components/pack-token.d.ts +27 -0
  41. package/dist/components/pack-token.d.ts.map +1 -0
  42. package/dist/components/pack-token.js +11 -0
  43. package/dist/components/pack-token.js.map +1 -0
  44. package/dist/components/projects.d.ts +13 -0
  45. package/dist/components/projects.d.ts.map +1 -1
  46. package/dist/components/projects.js +37 -5
  47. package/dist/components/projects.js.map +1 -1
  48. package/dist/components/work.d.ts +3 -1
  49. package/dist/components/work.d.ts.map +1 -1
  50. package/dist/components/work.js +1 -1
  51. package/dist/components/work.js.map +1 -1
  52. package/dist/components/workspace.d.ts +46 -0
  53. package/dist/components/workspace.d.ts.map +1 -0
  54. package/dist/components/workspace.js +124 -0
  55. package/dist/components/workspace.js.map +1 -0
  56. package/dist/console.css +156 -0
  57. package/dist/console.d.ts.map +1 -1
  58. package/dist/console.js +60 -21
  59. package/dist/console.js.map +1 -1
  60. package/dist/data.d.ts +16 -1
  61. package/dist/data.d.ts.map +1 -1
  62. package/dist/data.js +37 -0
  63. package/dist/data.js.map +1 -1
  64. package/dist/dates.d.ts +12 -2
  65. package/dist/dates.d.ts.map +1 -1
  66. package/dist/dates.js +20 -2
  67. package/dist/dates.js.map +1 -1
  68. package/dist/i18n/en.d.ts +53 -0
  69. package/dist/i18n/en.d.ts.map +1 -1
  70. package/dist/i18n/en.js +65 -8
  71. package/dist/i18n/en.js.map +1 -1
  72. package/dist/i18n/ko.d.ts.map +1 -1
  73. package/dist/i18n/ko.js +60 -7
  74. package/dist/i18n/ko.js.map +1 -1
  75. package/dist/index.d.ts +3 -0
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +3 -0
  78. package/dist/index.js.map +1 -1
  79. package/dist/router.d.ts +4 -4
  80. package/dist/router.d.ts.map +1 -1
  81. package/dist/router.js +11 -2
  82. package/dist/router.js.map +1 -1
  83. package/dist/routes.d.ts +4 -0
  84. package/dist/routes.d.ts.map +1 -1
  85. package/dist/routes.js +4 -0
  86. package/dist/routes.js.map +1 -1
  87. package/dist/schemas.d.ts +182 -2
  88. package/dist/schemas.d.ts.map +1 -1
  89. package/dist/schemas.js +110 -13
  90. package/dist/schemas.js.map +1 -1
  91. package/dist/vocabulary.d.ts +19 -1
  92. package/dist/vocabulary.d.ts.map +1 -1
  93. package/dist/vocabulary.js +16 -0
  94. package/dist/vocabulary.js.map +1 -1
  95. package/docs/specs/cli.md +16 -11
  96. package/docs/specs/rest.md +31 -8
  97. package/package.json +2 -2
  98. package/skills/building-a-console/SKILL.md +5 -5
  99. package/skills/building-a-console/references/the-look-from-a-website.md +39 -0
  100. package/skills/deploying-a-console/SKILL.md +8 -7
  101. package/skills/working-the-board/SKILL.md +2 -2
  102. package/skills/working-the-board/references/setting-up.md +1 -1
  103. package/skills/working-the-board/references/terms.md +2 -0
  104. package/skills/working-the-board/references/the-sentence.md +1 -1
  105. package/src/api.ts +16 -0
  106. package/src/cli/cli.ts +253 -33
  107. package/src/cli/credentials.ts +7 -0
  108. package/src/cli/directory.ts +9 -0
  109. package/src/cli/format.ts +72 -13
  110. package/src/cli/help.ts +36 -13
  111. package/src/client.ts +84 -0
  112. package/src/components/calendar.tsx +8 -2
  113. package/src/components/event-feed.tsx +2 -0
  114. package/src/components/pack-connect.tsx +179 -0
  115. package/src/components/pack-token.tsx +74 -0
  116. package/src/components/projects.tsx +108 -9
  117. package/src/components/work.tsx +4 -1
  118. package/src/components/workspace.tsx +298 -0
  119. package/src/console.tsx +173 -29
  120. package/src/data.ts +57 -0
  121. package/src/dates.ts +23 -2
  122. package/src/i18n/en.ts +76 -8
  123. package/src/i18n/ko.ts +67 -7
  124. package/src/index.ts +16 -0
  125. package/src/router.ts +17 -5
  126. package/src/routes.ts +4 -0
  127. package/src/schemas.ts +129 -12
  128. package/src/styles/console.css +156 -0
  129. package/src/vocabulary.ts +25 -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-muted">
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
- <ProjectRoleBadge role={member.role} />
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.aria}
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="primary" disabled={!ready || props.busy === true}>
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,4 +1,4 @@
1
- import type { RestTask, RestWork } from "../api.js";
1
+ import type { RestTask, RestWork, WeekStart } from "../api.js";
2
2
  import { addDays, today as todayOf } from "../dates.js";
3
3
  import { useWords } from "../i18n/index.js";
4
4
  import { Week } from "./calendar.js";
@@ -28,6 +28,8 @@ 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) {
@@ -70,6 +72,7 @@ export function WorkList(props: WorkListProps) {
70
72
  taskHref={hrefOf}
71
73
  weekHref={props.weekHref}
72
74
  markOf={markOf}
75
+ weekStart={props.weekStart}
73
76
  />
74
77
  <div className="sc-work-lists">
75
78
  {groups
@@ -0,0 +1,298 @@
1
+ import { type FormEvent, useId, useMemo, useState } from "react";
2
+ import {
3
+ PEOPLE_VISIBILITIES,
4
+ type PeopleVisibility,
5
+ type RestWorkspace,
6
+ type RestWorkspacePatch,
7
+ WEEK_STARTS,
8
+ type WeekStart,
9
+ } from "../api.js";
10
+ import { useWords } from "../i18n/index.js";
11
+ import { Button } from "./ui.js";
12
+
13
+ // The workspace's own settings (ADR-0035), as an administrator keeps them on the Settings page:
14
+ // read by everyone, so that a member knows what the board shows them; changed on a form of
15
+ // their own, each setting a row with its words beside its control. Takes its data as props and
16
+ // nothing from the network.
17
+
18
+ /** What a workspace is set to until an administrator says otherwise, for a console that answers no setting. */
19
+ export const DEFAULT_PEOPLE_VISIBILITY: PeopleVisibility = "everyone";
20
+
21
+ /** Who sees whom, as the workspace says, or as a console older than the setting would have it. */
22
+ export function peopleVisibilityOf(workspace: RestWorkspace | undefined): PeopleVisibility {
23
+ return workspace?.peopleVisibility ?? DEFAULT_PEOPLE_VISIBILITY;
24
+ }
25
+
26
+ /** The day the calendars begin a week on, as the workspace says, or the system default. */
27
+ export function weekStartOf(workspace: RestWorkspace | undefined): WeekStart {
28
+ return workspace?.weekStart ?? "auto";
29
+ }
30
+
31
+ /** The workspace's time zone, or none. */
32
+ export function timeZoneOf(workspace: RestWorkspace | undefined): string | undefined {
33
+ return workspace?.timeZone ?? undefined;
34
+ }
35
+
36
+ /** The zones this browser knows, for the picker to offer; none where it cannot say. */
37
+ export function knownTimeZones(): readonly string[] {
38
+ try {
39
+ const intl = Intl as typeof Intl & {
40
+ readonly supportedValuesOf?: (key: "timeZone") => string[];
41
+ };
42
+ return intl.supportedValuesOf?.("timeZone") ?? [];
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ /** The zone this device keeps time in, as the browser says; none where it cannot say. */
49
+ export function deviceTimeZone(): string | undefined {
50
+ try {
51
+ const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
52
+ return zone === undefined || zone === "" ? undefined : zone;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ /** A zone's offset from UTC at a moment, as `UTC+09:00`; none where the browser cannot say. */
59
+ export function offsetOf(zone: string, at: Date): string | undefined {
60
+ try {
61
+ const name = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset" })
62
+ .formatToParts(at)
63
+ .find((part) => part.type === "timeZoneName")?.value;
64
+ if (name === undefined) {
65
+ return undefined;
66
+ }
67
+ return name === "GMT" ? "UTC+00:00" : name.replace(/^GMT/, "UTC");
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ export interface TimeZoneOption {
74
+ readonly name: string;
75
+ /** The offset now, for the person to tell zones apart by; none where the browser cannot say. */
76
+ readonly offset: string | undefined;
77
+ }
78
+
79
+ export interface TimeZoneGroup {
80
+ /** The area before the slash, `Asia` of `Asia/Seoul`; empty for a zone with none, such as `UTC`. */
81
+ readonly area: string;
82
+ readonly zones: readonly TimeZoneOption[];
83
+ }
84
+
85
+ /**
86
+ * Zones for a picker: by their area, the areas in order, each zone with its offset at `at`; the
87
+ * zones with no area first, since `UTC` is one. `extra` zones the browser does not list, the one
88
+ * set and the device's, are offered too, so that the picker always shows what is chosen.
89
+ */
90
+ export function timeZoneGroups(
91
+ zones: readonly string[],
92
+ at: Date,
93
+ extra: readonly (string | undefined)[] = [],
94
+ ): TimeZoneGroup[] {
95
+ const names = new Set(zones);
96
+ for (const name of extra) {
97
+ if (name !== undefined && name !== "") {
98
+ names.add(name);
99
+ }
100
+ }
101
+ const groups = new Map<string, TimeZoneOption[]>();
102
+ for (const name of [...names].sort((a, b) => a.localeCompare(b, "en"))) {
103
+ const slash = name.indexOf("/");
104
+ const area = slash < 0 ? "" : name.slice(0, slash);
105
+ const list = groups.get(area) ?? [];
106
+ list.push({ name, offset: offsetOf(name, at) });
107
+ groups.set(area, list);
108
+ }
109
+ return [...groups.entries()]
110
+ .sort(([a], [b]) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b, "en")))
111
+ .map(([area, options]) => ({ area, zones: options }));
112
+ }
113
+
114
+ /** A zone as the picker shows it: its name, and its offset where known. */
115
+ const labelOf = (zone: TimeZoneOption): string =>
116
+ zone.offset === undefined ? zone.name : `${zone.name} (${zone.offset})`;
117
+
118
+ export interface WorkspaceSettingsViewProps {
119
+ readonly workspace: RestWorkspace;
120
+ }
121
+
122
+ /** The settings as they stand, one row each; where they are changed is the page's business. */
123
+ export function WorkspaceSettingsView(props: WorkspaceSettingsViewProps) {
124
+ const words = useWords();
125
+ const settings = words.settings.workspace;
126
+ const zone = timeZoneOf(props.workspace);
127
+ const device = deviceTimeZone();
128
+ const offset = zone === undefined ? undefined : offsetOf(zone, new Date());
129
+ return (
130
+ <dl className="sc-settings-list">
131
+ <div className="sc-settings-item">
132
+ <dt>{settings.peopleVisibility}</dt>
133
+ <dd>{words.vocabulary.peopleVisibility[peopleVisibilityOf(props.workspace)]}</dd>
134
+ </div>
135
+ <div className="sc-settings-item">
136
+ <dt>{settings.weekStart}</dt>
137
+ <dd>{words.vocabulary.weekStart[weekStartOf(props.workspace)]}</dd>
138
+ </div>
139
+ <div className="sc-settings-item">
140
+ <dt>{settings.timeZone}</dt>
141
+ <dd>
142
+ {zone === undefined ? (
143
+ settings.timeZoneNone
144
+ ) : (
145
+ <>
146
+ {zone}
147
+ {offset !== undefined && <span className="sc-muted"> ({offset})</span>}
148
+ </>
149
+ )}
150
+ {device !== undefined && device !== zone && (
151
+ <span className="sc-settings-note">{settings.timeZoneDevice(device)}</span>
152
+ )}
153
+ </dd>
154
+ </div>
155
+ </dl>
156
+ );
157
+ }
158
+
159
+ export interface WorkspaceSettingsFormProps {
160
+ readonly workspace: RestWorkspace;
161
+ readonly busy?: boolean | undefined;
162
+ /** Called with what changed; nothing is sent when nothing did. */
163
+ readonly onSubmit: (patch: RestWorkspacePatch) => void;
164
+ readonly onCancel?: (() => void) | undefined;
165
+ }
166
+
167
+ /** Where an administrator changes the workspace's settings (ADR-0035). */
168
+ export function WorkspaceSettingsForm(props: WorkspaceSettingsFormProps) {
169
+ const words = useWords();
170
+ const settings = words.settings.workspace;
171
+ const ids = { people: useId(), week: useId(), zone: useId() };
172
+ const current = {
173
+ peopleVisibility: peopleVisibilityOf(props.workspace),
174
+ weekStart: weekStartOf(props.workspace),
175
+ timeZone: timeZoneOf(props.workspace) ?? "",
176
+ };
177
+ const [peopleVisibility, setPeopleVisibility] = useState<PeopleVisibility>(
178
+ current.peopleVisibility,
179
+ );
180
+ const [weekStart, setWeekStart] = useState<WeekStart>(current.weekStart);
181
+ const [timeZone, setTimeZone] = useState(current.timeZone);
182
+ const device = useMemo(deviceTimeZone, []);
183
+ // UTC is offered whatever the browser lists, beside the zone set and the device's.
184
+ const groups = useMemo(
185
+ () => timeZoneGroups(knownTimeZones(), new Date(), ["UTC", current.timeZone, device]),
186
+ [current.timeZone, device],
187
+ );
188
+ const patch: RestWorkspacePatch = {
189
+ ...(peopleVisibility === current.peopleVisibility ? {} : { peopleVisibility }),
190
+ ...(weekStart === current.weekStart ? {} : { weekStart }),
191
+ ...(timeZone === current.timeZone ? {} : { timeZone: timeZone === "" ? null : timeZone }),
192
+ };
193
+ const changed = Object.keys(patch).length > 0;
194
+ const submit = (event: FormEvent) => {
195
+ event.preventDefault();
196
+ if (changed) {
197
+ props.onSubmit(patch);
198
+ }
199
+ };
200
+ return (
201
+ <form className="sc-form sc-settings-form" aria-label={settings.aria} onSubmit={submit}>
202
+ <div className="sc-settings-row">
203
+ <div className="sc-settings-words">
204
+ <label className="sc-settings-label" htmlFor={ids.people}>
205
+ {settings.peopleVisibility}
206
+ </label>
207
+ <p className="sc-settings-hint">{settings.peopleVisibilityHint}</p>
208
+ </div>
209
+ <div className="sc-settings-control">
210
+ <select
211
+ id={ids.people}
212
+ className="sc-input"
213
+ value={peopleVisibility}
214
+ onChange={(event) => setPeopleVisibility(event.target.value as PeopleVisibility)}
215
+ >
216
+ {PEOPLE_VISIBILITIES.map((candidate) => (
217
+ <option key={candidate} value={candidate}>
218
+ {words.vocabulary.peopleVisibility[candidate]}
219
+ </option>
220
+ ))}
221
+ </select>
222
+ </div>
223
+ </div>
224
+ <div className="sc-settings-row">
225
+ <div className="sc-settings-words">
226
+ <label className="sc-settings-label" htmlFor={ids.week}>
227
+ {settings.weekStart}
228
+ </label>
229
+ <p className="sc-settings-hint">{settings.weekStartHint}</p>
230
+ </div>
231
+ <div className="sc-settings-control">
232
+ <select
233
+ id={ids.week}
234
+ className="sc-input"
235
+ value={weekStart}
236
+ onChange={(event) => setWeekStart(event.target.value as WeekStart)}
237
+ >
238
+ {WEEK_STARTS.map((candidate) => (
239
+ <option key={candidate} value={candidate}>
240
+ {words.vocabulary.weekStart[candidate]}
241
+ </option>
242
+ ))}
243
+ </select>
244
+ </div>
245
+ </div>
246
+ <div className="sc-settings-row">
247
+ <div className="sc-settings-words">
248
+ <label className="sc-settings-label" htmlFor={ids.zone}>
249
+ {settings.timeZone}
250
+ </label>
251
+ <p className="sc-settings-hint">{settings.timeZoneHint}</p>
252
+ </div>
253
+ <div className="sc-settings-control">
254
+ <select
255
+ id={ids.zone}
256
+ className="sc-input"
257
+ value={timeZone}
258
+ onChange={(event) => setTimeZone(event.target.value)}
259
+ >
260
+ <option value="">{settings.timeZoneNone}</option>
261
+ {groups.map((group) =>
262
+ group.area === "" ? (
263
+ group.zones.map((zone) => (
264
+ <option key={zone.name} value={zone.name}>
265
+ {labelOf(zone)}
266
+ </option>
267
+ ))
268
+ ) : (
269
+ <optgroup key={group.area} label={group.area}>
270
+ {group.zones.map((zone) => (
271
+ <option key={zone.name} value={zone.name}>
272
+ {labelOf(zone)}
273
+ </option>
274
+ ))}
275
+ </optgroup>
276
+ ),
277
+ )}
278
+ </select>
279
+ {device !== undefined && device !== timeZone && (
280
+ <Button variant="ghost" size="sm" onClick={() => setTimeZone(device)}>
281
+ {settings.timeZoneUseDevice(device)}
282
+ </Button>
283
+ )}
284
+ </div>
285
+ </div>
286
+ <div className="sc-form-actions">
287
+ <Button type="submit" variant="primary" disabled={props.busy === true || !changed}>
288
+ {settings.save}
289
+ </Button>
290
+ {props.onCancel !== undefined && (
291
+ <Button type="button" variant="ghost" onClick={props.onCancel}>
292
+ {words.common.cancel}
293
+ </Button>
294
+ )}
295
+ </div>
296
+ </form>
297
+ );
298
+ }