@skillcdn/console 0.1.13 → 0.1.15

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 (170) hide show
  1. package/README.md +5 -4
  2. package/SKILLCDN.md +2 -0
  3. package/dist/agent-name.d.ts +3 -0
  4. package/dist/agent-name.d.ts.map +1 -0
  5. package/dist/agent-name.js +9 -0
  6. package/dist/agent-name.js.map +1 -0
  7. package/dist/api.d.ts +6 -4
  8. package/dist/api.d.ts.map +1 -1
  9. package/dist/api.js +6 -4
  10. package/dist/api.js.map +1 -1
  11. package/dist/cli/cli.d.ts +2 -0
  12. package/dist/cli/cli.d.ts.map +1 -1
  13. package/dist/cli/cli.js +215 -22
  14. package/dist/cli/cli.js.map +1 -1
  15. package/dist/cli/format.d.ts +14 -4
  16. package/dist/cli/format.d.ts.map +1 -1
  17. package/dist/cli/format.js +59 -10
  18. package/dist/cli/format.js.map +1 -1
  19. package/dist/cli/help.d.ts.map +1 -1
  20. package/dist/cli/help.js +27 -11
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/main.js +5 -0
  23. package/dist/cli/main.js.map +1 -1
  24. package/dist/client.d.ts +20 -2
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +11 -2
  27. package/dist/client.js.map +1 -1
  28. package/dist/components/calendar.d.ts +15 -0
  29. package/dist/components/calendar.d.ts.map +1 -1
  30. package/dist/components/calendar.js +78 -41
  31. package/dist/components/calendar.js.map +1 -1
  32. package/dist/components/connect.d.ts +4 -1
  33. package/dist/components/connect.d.ts.map +1 -1
  34. package/dist/components/connect.js +7 -3
  35. package/dist/components/connect.js.map +1 -1
  36. package/dist/components/decision-list.d.ts.map +1 -1
  37. package/dist/components/decision-list.js +3 -3
  38. package/dist/components/decision-list.js.map +1 -1
  39. package/dist/components/documents.d.ts +5 -0
  40. package/dist/components/documents.d.ts.map +1 -1
  41. package/dist/components/documents.js +3 -2
  42. package/dist/components/documents.js.map +1 -1
  43. package/dist/components/event-feed.d.ts +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +10 -1
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/people.d.ts +24 -1
  48. package/dist/components/people.d.ts.map +1 -1
  49. package/dist/components/people.js +35 -3
  50. package/dist/components/people.js.map +1 -1
  51. package/dist/components/projects.d.ts +6 -0
  52. package/dist/components/projects.d.ts.map +1 -1
  53. package/dist/components/projects.js +3 -3
  54. package/dist/components/projects.js.map +1 -1
  55. package/dist/components/rules.d.ts +40 -0
  56. package/dist/components/rules.d.ts.map +1 -0
  57. package/dist/components/rules.js +46 -0
  58. package/dist/components/rules.js.map +1 -0
  59. package/dist/components/runs.d.ts +1 -1
  60. package/dist/components/runs.d.ts.map +1 -1
  61. package/dist/components/runs.js +2 -1
  62. package/dist/components/runs.js.map +1 -1
  63. package/dist/components/skills.d.ts.map +1 -1
  64. package/dist/components/skills.js +5 -1
  65. package/dist/components/skills.js.map +1 -1
  66. package/dist/components/tokens.d.ts.map +1 -1
  67. package/dist/components/tokens.js +2 -9
  68. package/dist/components/tokens.js.map +1 -1
  69. package/dist/components/ui.d.ts +7 -2
  70. package/dist/components/ui.d.ts.map +1 -1
  71. package/dist/components/ui.js +13 -0
  72. package/dist/components/ui.js.map +1 -1
  73. package/dist/components/work.d.ts +9 -1
  74. package/dist/components/work.d.ts.map +1 -1
  75. package/dist/components/work.js +8 -3
  76. package/dist/components/work.js.map +1 -1
  77. package/dist/connect-page.d.ts.map +1 -1
  78. package/dist/connect-page.js +1 -1
  79. package/dist/connect-page.js.map +1 -1
  80. package/dist/console.css +253 -13
  81. package/dist/console.d.ts.map +1 -1
  82. package/dist/console.js +203 -49
  83. package/dist/console.js.map +1 -1
  84. package/dist/data.d.ts +19 -2
  85. package/dist/data.d.ts.map +1 -1
  86. package/dist/data.js +27 -1
  87. package/dist/data.js.map +1 -1
  88. package/dist/dates.d.ts +4 -0
  89. package/dist/dates.d.ts.map +1 -1
  90. package/dist/dates.js +26 -0
  91. package/dist/dates.js.map +1 -1
  92. package/dist/i18n/en.d.ts +90 -28
  93. package/dist/i18n/en.d.ts.map +1 -1
  94. package/dist/i18n/en.js +89 -31
  95. package/dist/i18n/en.js.map +1 -1
  96. package/dist/i18n/ko.d.ts.map +1 -1
  97. package/dist/i18n/ko.js +89 -31
  98. package/dist/i18n/ko.js.map +1 -1
  99. package/dist/index.d.ts +6 -5
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +6 -5
  102. package/dist/index.js.map +1 -1
  103. package/dist/limits.d.ts +4 -0
  104. package/dist/limits.d.ts.map +1 -1
  105. package/dist/limits.js +4 -0
  106. package/dist/limits.js.map +1 -1
  107. package/dist/router.d.ts +34 -2
  108. package/dist/router.d.ts.map +1 -1
  109. package/dist/router.js +55 -7
  110. package/dist/router.js.map +1 -1
  111. package/dist/routes.d.ts +14 -4
  112. package/dist/routes.d.ts.map +1 -1
  113. package/dist/routes.js +17 -2
  114. package/dist/routes.js.map +1 -1
  115. package/dist/rules.d.ts +28 -0
  116. package/dist/rules.d.ts.map +1 -0
  117. package/dist/rules.js +102 -0
  118. package/dist/rules.js.map +1 -0
  119. package/dist/schemas.d.ts +652 -4
  120. package/dist/schemas.d.ts.map +1 -1
  121. package/dist/schemas.js +96 -6
  122. package/dist/schemas.js.map +1 -1
  123. package/dist/vocabulary.d.ts +21 -1
  124. package/dist/vocabulary.d.ts.map +1 -1
  125. package/dist/vocabulary.js +21 -0
  126. package/dist/vocabulary.js.map +1 -1
  127. package/docs/specs/cli.md +13 -9
  128. package/docs/specs/rest.md +32 -8
  129. package/package.json +1 -1
  130. package/skills/building-a-console/SKILL.md +1 -1
  131. package/skills/deploying-a-console/SKILL.md +5 -5
  132. package/skills/deploying-a-console/assets/secrets.example.json +2 -1
  133. package/skills/deploying-a-console/references/access.md +10 -6
  134. package/skills/deploying-a-console/references/resources.md +10 -7
  135. package/skills/working-the-board/SKILL.md +10 -9
  136. package/skills/working-the-board/references/project-rules.md +36 -0
  137. package/src/agent-name.ts +9 -0
  138. package/src/api.ts +37 -0
  139. package/src/cli/cli.ts +253 -24
  140. package/src/cli/format.ts +74 -10
  141. package/src/cli/help.ts +30 -10
  142. package/src/cli/main.ts +5 -0
  143. package/src/client.ts +47 -1
  144. package/src/components/calendar.tsx +242 -101
  145. package/src/components/connect.tsx +12 -6
  146. package/src/components/decision-list.tsx +2 -2
  147. package/src/components/documents.tsx +7 -3
  148. package/src/components/event-feed.tsx +16 -3
  149. package/src/components/people.tsx +244 -35
  150. package/src/components/projects.tsx +31 -8
  151. package/src/components/rules.tsx +167 -0
  152. package/src/components/runs.tsx +4 -2
  153. package/src/components/skills.tsx +22 -11
  154. package/src/components/tokens.tsx +2 -14
  155. package/src/components/ui.tsx +25 -2
  156. package/src/components/work.tsx +69 -43
  157. package/src/connect-page.tsx +0 -1
  158. package/src/console.tsx +531 -67
  159. package/src/data.ts +52 -2
  160. package/src/dates.ts +28 -0
  161. package/src/i18n/en.ts +99 -38
  162. package/src/i18n/ko.ts +95 -38
  163. package/src/index.ts +21 -1
  164. package/src/limits.ts +4 -0
  165. package/src/router.ts +77 -12
  166. package/src/routes.ts +23 -3
  167. package/src/rules.ts +109 -0
  168. package/src/schemas.ts +121 -4
  169. package/src/styles/console.css +253 -13
  170. package/src/vocabulary.ts +30 -0
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import type { RestEvent } from "../api.js";
2
+ import { agentLabel, type RestEvent } from "../api.js";
3
3
  import { en, type Messages } from "../i18n/en.js";
4
4
  import { useWords } from "../i18n/index.js";
5
5
  import { ShowMore, usePaged } from "./paged.js";
@@ -38,6 +38,14 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
38
38
  return feed.event.personJoined();
39
39
  case "person.role_changed":
40
40
  return feed.event.roleChanged(login, role(data.role));
41
+ case "person.listed":
42
+ return feed.event.personListed(login);
43
+ case "person.unlisted":
44
+ return feed.event.personUnlisted(login);
45
+ case "person.deactivated":
46
+ return feed.event.personDeactivated(login);
47
+ case "person.reactivated":
48
+ return feed.event.personReactivated(login);
41
49
  case "project.created":
42
50
  return feed.event.projectCreated(project);
43
51
  case "project.updated":
@@ -129,8 +137,13 @@ export function EventFeed(props: EventFeedProps) {
129
137
  <PersonChip person={event.actor} />
130
138
  )}
131
139
  {event.agent !== null && (
132
- <span className="sc-feed-agent" title={words.feed.asTitle}>
133
- {words.feed.as(event.agent)}
140
+ <span
141
+ className="sc-feed-agent"
142
+ title={
143
+ agentLabel(event.agent) === event.agent ? words.feed.asTitle : event.agent
144
+ }
145
+ >
146
+ {words.feed.as(agentLabel(event.agent))}
134
147
  </span>
135
148
  )}
136
149
  </span>
@@ -1,17 +1,46 @@
1
- import type { PersonRole, RestPerson } from "../api.js";
1
+ import { type FormEvent, useState } from "react";
2
+ import {
3
+ hostOfLogin,
4
+ isLoginAt,
5
+ type PersonRole,
6
+ type PersonStatus,
7
+ type ProviderKey,
8
+ type RestListing,
9
+ type RestListingInput,
10
+ type RestOrganization,
11
+ type RestPerson,
12
+ type RestProvider,
13
+ } from "../api.js";
2
14
  import { useWords } from "../i18n/index.js";
15
+ import { MAX_LOGIN_LENGTH } from "../limits.js";
3
16
  import { PERSON_ROLES } from "../vocabulary.js";
4
- import { PersonChip, personName, RoleBadge } from "./ui.js";
17
+ import { Badge, Button, PersonChip, personName, RoleBadge, Time } from "./ui.js";
5
18
 
6
- // The people of the board, with what each may do. An administrator changes roles here; a
7
- // member reads. Takes its data as props and nothing from the network.
19
+ // The people of the board, with what each may do and whether they are in (ADR-0022): those in,
20
+ // the logins let in that nobody has signed in with yet, and those deactivated. An administrator
21
+ // changes roles here, takes people out and back in, and takes a listing away; a member reads.
22
+ // Takes its data as props and nothing from the network.
8
23
 
9
24
  export interface PeopleListProps {
10
25
  readonly people: readonly RestPerson[];
26
+ /** The logins let in that nobody has signed in with yet. */
27
+ readonly listed?: readonly RestListing[] | undefined;
28
+ /** The organization at the git host that says who is in, where the deployment names one. */
29
+ readonly organization?: RestOrganization | null | undefined;
11
30
  /** Whoever is looking, so that their own row says so. */
12
31
  readonly me?: RestPerson | undefined;
13
32
  /** Called with the role an administrator chose. Left out, roles are read here, not changed. */
14
33
  readonly onChangeRole?: ((person: RestPerson, role: PersonRole) => void) | undefined;
34
+ /**
35
+ * Called when an administrator takes a person out, or back in. Left out, standings are read
36
+ * here, not changed. Never offered for a person the organization let in, who comes and goes
37
+ * with it.
38
+ */
39
+ readonly onChangeStatus?: ((person: RestPerson, status: PersonStatus) => void) | undefined;
40
+ /** Called when an administrator takes a listing away. Left out, listings are read here. */
41
+ readonly onUnlist?: ((listing: RestListing) => void) | undefined;
42
+ /** Where a person's work is seen; given to an administrator, or an owner of a project. */
43
+ readonly workHref?: ((person: RestPerson) => string) | undefined;
15
44
  /** Where a person's agents are seen; given to an administrator. */
16
45
  readonly agentsHref?: ((person: RestPerson) => string) | undefined;
17
46
  readonly busy?: boolean | undefined;
@@ -19,41 +48,221 @@ export interface PeopleListProps {
19
48
 
20
49
  export function PeopleList(props: PeopleListProps) {
21
50
  const words = useWords();
51
+ const people = words.people;
52
+ const active = props.people.filter((person) => person.status === "active");
53
+ const deactivated = props.people.filter((person) => person.status === "deactivated");
54
+ const listed = props.listed ?? [];
55
+ const mayChange = (person: RestPerson): boolean =>
56
+ props.onChangeStatus !== undefined &&
57
+ person.source !== "organization" &&
58
+ props.me?.id !== person.id;
59
+ const organization = props.organization ?? undefined;
22
60
  return (
23
- <ul className="sc-people" aria-label={words.people.list}>
24
- {props.people.map((person) => (
25
- <li key={person.id} className="sc-person-row">
26
- <PersonChip person={person} size="md" />
27
- <span className="sc-person-name sc-muted">
28
- {person.name === null ? "" : person.login}
29
- {props.me?.id === person.id && <span> {words.common.you}</span>}
30
- </span>
31
- {props.onChangeRole === undefined ? (
32
- <RoleBadge role={person.role} />
61
+ <div className="sc-people-page">
62
+ {organization !== undefined && (
63
+ <p className="sc-people-organization sc-muted">
64
+ {people.organization(organization.name)}{" "}
65
+ {organization.syncedAt === null ? (
66
+ people.organizationNotRead
33
67
  ) : (
34
- <label className="sc-field sc-field-inline">
35
- <span className="sc-visually-hidden">{words.people.roleOf(personName(person))}</span>
36
- <select
37
- className="sc-input"
38
- value={person.role}
68
+ <>
69
+ {people.organizationRead} <Time iso={organization.syncedAt} />
70
+ </>
71
+ )}
72
+ </p>
73
+ )}
74
+ <ul className="sc-people" aria-label={people.list}>
75
+ {active.map((person) => (
76
+ <li key={person.id} className="sc-person-row">
77
+ <PersonChip person={person} size="md" />
78
+ <span className="sc-person-name sc-muted">
79
+ {person.name === null ? "" : person.login}
80
+ {props.me?.id === person.id && <span> {words.common.you}</span>}
81
+ <span className="sc-person-note"> {people.source[person.source]}</span>
82
+ </span>
83
+ {props.onChangeRole === undefined ? (
84
+ <RoleBadge role={person.role} />
85
+ ) : (
86
+ <label className="sc-field sc-field-inline">
87
+ <span className="sc-visually-hidden">{people.roleOf(personName(person))}</span>
88
+ <select
89
+ className="sc-input"
90
+ value={person.role}
91
+ disabled={props.busy === true}
92
+ onChange={(event) =>
93
+ props.onChangeRole?.(person, event.target.value as PersonRole)
94
+ }
95
+ >
96
+ {PERSON_ROLES.map((role) => (
97
+ <option key={role} value={role}>
98
+ {words.vocabulary.role[role]}
99
+ </option>
100
+ ))}
101
+ </select>
102
+ </label>
103
+ )}
104
+ {props.workHref !== undefined && (
105
+ <a className="sc-button sc-button-ghost sc-button-sm" href={props.workHref(person)}>
106
+ {people.work}
107
+ </a>
108
+ )}
109
+ {props.agentsHref !== undefined && (
110
+ <a className="sc-button sc-button-ghost sc-button-sm" href={props.agentsHref(person)}>
111
+ {people.agents}
112
+ </a>
113
+ )}
114
+ {mayChange(person) && (
115
+ <Button
116
+ variant="ghost"
117
+ size="sm"
39
118
  disabled={props.busy === true}
40
- onChange={(event) => props.onChangeRole?.(person, event.target.value as PersonRole)}
119
+ onClick={() => props.onChangeStatus?.(person, "deactivated")}
41
120
  >
42
- {PERSON_ROLES.map((role) => (
43
- <option key={role} value={role}>
44
- {words.vocabulary.role[role]}
45
- </option>
46
- ))}
47
- </select>
48
- </label>
49
- )}
50
- {props.agentsHref !== undefined && (
51
- <a className="sc-button sc-button-ghost sc-button-sm" href={props.agentsHref(person)}>
52
- {words.people.agents}
53
- </a>
121
+ {people.deactivate}
122
+ </Button>
123
+ )}
124
+ </li>
125
+ ))}
126
+ </ul>
127
+ {listed.length > 0 && (
128
+ <section className="sc-people-section" aria-label={people.notSignedIn}>
129
+ <h2 className="sc-section-title">{people.notSignedIn}</h2>
130
+ <ul className="sc-people">
131
+ {listed.map((listing) => (
132
+ <li key={listing.id} className="sc-person-row">
133
+ <span className="sc-person-login">{listing.login}</span>
134
+ <span className="sc-person-name sc-muted">
135
+ {people.at[listing.host]}
136
+ <span className="sc-person-note">
137
+ {" "}
138
+ <Time iso={listing.createdAt} />
139
+ </span>
140
+ </span>
141
+ {props.onUnlist !== undefined && (
142
+ <Button
143
+ variant="ghost"
144
+ size="sm"
145
+ disabled={props.busy === true}
146
+ onClick={() => props.onUnlist?.(listing)}
147
+ >
148
+ {words.common.remove}
149
+ </Button>
150
+ )}
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ </section>
155
+ )}
156
+ {deactivated.length > 0 && (
157
+ <section className="sc-people-section" aria-label={people.deactivated}>
158
+ <h2 className="sc-section-title">{people.deactivated}</h2>
159
+ <ul className="sc-people">
160
+ {deactivated.map((person) => (
161
+ <li key={person.id} className="sc-person-row sc-person-row-out">
162
+ <PersonChip person={person} size="md" />
163
+ <span className="sc-person-name sc-muted">
164
+ {person.name === null ? "" : person.login}
165
+ <span className="sc-person-note">
166
+ {" "}
167
+ {person.source === "organization"
168
+ ? people.leftOrganization
169
+ : people.source[person.source]}
170
+ {person.deactivatedAt !== null && (
171
+ <>
172
+ {" "}
173
+ <Time iso={person.deactivatedAt} />
174
+ </>
175
+ )}
176
+ </span>
177
+ </span>
178
+ <Badge tone="neutral">{words.vocabulary.status.deactivated}</Badge>
179
+ {mayChange(person) && (
180
+ <Button
181
+ variant="ghost"
182
+ size="sm"
183
+ disabled={props.busy === true}
184
+ onClick={() => props.onChangeStatus?.(person, "active")}
185
+ >
186
+ {people.reactivate}
187
+ </Button>
188
+ )}
189
+ </li>
190
+ ))}
191
+ </ul>
192
+ </section>
193
+ )}
194
+ </div>
195
+ );
196
+ }
197
+
198
+ export interface ListingFormProps {
199
+ /** The providers people sign in through here: what a listing may name. */
200
+ readonly providers: readonly RestProvider[];
201
+ readonly busy?: boolean | undefined;
202
+ readonly onSubmit: (input: RestListingInput) => void;
203
+ readonly onCancel?: (() => void) | undefined;
204
+ }
205
+
206
+ /** Where an administrator lets someone in by login: whoever signs in with that account is in. */
207
+ export function ListingForm(props: ListingFormProps) {
208
+ const words = useWords();
209
+ const form = words.people.form;
210
+ const [login, setLogin] = useState("");
211
+ const [chosen, setChosen] = useState<ProviderKey | "">("");
212
+ const only = props.providers.length === 1 ? props.providers[0]?.key : undefined;
213
+ /** The provider named, else the one the login's shape says, else the only one. */
214
+ const host: ProviderKey | undefined =
215
+ chosen !== "" ? chosen : only !== undefined ? only : hostOfLogin(login.trim());
216
+ const offered = props.providers.some((provider) => provider.key === host);
217
+ const valid = host !== undefined && offered && isLoginAt(host, login.trim());
218
+ const submit = (event: FormEvent) => {
219
+ event.preventDefault();
220
+ if (valid && host !== undefined) {
221
+ props.onSubmit({ host, login: login.trim() });
222
+ }
223
+ };
224
+ return (
225
+ <form className="sc-form sc-listing-form" aria-label={words.people.letInAria} onSubmit={submit}>
226
+ <div className="sc-field-row">
227
+ <label className="sc-field">
228
+ <span className="sc-field-label">{form.login}</span>
229
+ <input
230
+ className="sc-input"
231
+ value={login}
232
+ maxLength={MAX_LOGIN_LENGTH}
233
+ autoComplete="off"
234
+ onChange={(event) => setLogin(event.target.value)}
235
+ />
236
+ </label>
237
+ {props.providers.length > 1 && (
238
+ <label className="sc-field">
239
+ <span className="sc-field-label">{form.provider}</span>
240
+ <select
241
+ className="sc-input"
242
+ value={chosen}
243
+ onChange={(event) => setChosen(event.target.value as ProviderKey | "")}
244
+ >
245
+ <option value="">{form.byShape}</option>
246
+ {props.providers.map((provider) => (
247
+ <option key={provider.key} value={provider.key}>
248
+ {provider.label}
249
+ </option>
250
+ ))}
251
+ </select>
252
+ </label>
253
+ )}
254
+ <div className="sc-form-actions">
255
+ <Button type="submit" variant="primary" disabled={props.busy === true || !valid}>
256
+ {form.submit}
257
+ </Button>
258
+ {props.onCancel !== undefined && (
259
+ <Button type="button" variant="ghost" onClick={props.onCancel}>
260
+ {words.common.cancel}
261
+ </Button>
54
262
  )}
55
- </li>
56
- ))}
57
- </ul>
263
+ </div>
264
+ </div>
265
+ <p className="sc-field-hint">{form.hint}</p>
266
+ </form>
58
267
  );
59
268
  }
@@ -119,7 +119,11 @@ export function ProjectList(props: ProjectListProps) {
119
119
  </a>
120
120
  <code className="sc-project-key">{project.key}</code>
121
121
  <ProjectRoleBadge role={project.role} />
122
- {project.visibility === "private" && <Badge tone="neutral">{words.private}</Badge>}
122
+ {project.visibility === "private" ? (
123
+ <Badge tone="neutral">{words.private}</Badge>
124
+ ) : (
125
+ <Badge tone="warning">{words.public}</Badge>
126
+ )}
123
127
  </div>
124
128
  {project.description.length > 0 && (
125
129
  <p className="sc-project-description">{project.description}</p>
@@ -146,6 +150,10 @@ export interface ProjectFormProps {
146
150
  */
147
151
  readonly onSubmit: (input: RestProjectInput | RestProjectPatch, icon?: File | null) => void;
148
152
  readonly onCancel?: (() => void) | undefined;
153
+ /** The origin of the SkillCDN deployment the skills are read through, shown before the address typed. */
154
+ readonly skillsSource?: string | undefined;
155
+ /** The organization's address, which a project without one of its own reads; `null` for none. */
156
+ readonly organizationAddress?: string | null | undefined;
149
157
  }
150
158
 
151
159
  /** A key as a person types a name: lowercase letters, digits and hyphens, from what they typed. */
@@ -272,13 +280,21 @@ export function ProjectForm(props: ProjectFormProps) {
272
280
  </label>
273
281
  <label className="sc-field">
274
282
  <span className="sc-field-label">{form.skillsAddress}</span>
275
- <input
276
- className="sc-input"
277
- value={skillsAddress}
278
- maxLength={MAX_SKILLS_ADDRESS_LENGTH}
279
- placeholder={form.skillsAddressPlaceholder}
280
- onChange={(event) => setSkillsAddress(event.target.value)}
281
- />
283
+ <span className="sc-input-group">
284
+ {props.skillsSource !== undefined && (
285
+ <span className="sc-input-prefix">{props.skillsSource}</span>
286
+ )}
287
+ <input
288
+ className="sc-input"
289
+ value={skillsAddress}
290
+ maxLength={MAX_SKILLS_ADDRESS_LENGTH}
291
+ placeholder={form.skillsAddressPlaceholder}
292
+ onChange={(event) => setSkillsAddress(event.target.value)}
293
+ />
294
+ </span>
295
+ <span className="sc-muted">
296
+ {form.skillsAddressHint(props.organizationAddress ?? null)}
297
+ </span>
282
298
  </label>
283
299
  </div>
284
300
  <div className="sc-field-row sc-mark-row">
@@ -358,6 +374,8 @@ export interface MemberListProps {
358
374
  readonly onAdd?: ((input: RestMemberInput) => void) | undefined;
359
375
  readonly onChangeRole?: ((member: RestMember, role: ProjectRole) => void) | undefined;
360
376
  readonly onRemove?: ((member: RestMember) => void) | undefined;
377
+ /** Where a member's work is seen; given to the project's owners. */
378
+ readonly workHref?: ((member: RestMember) => string) | undefined;
361
379
  readonly busy?: boolean | undefined;
362
380
  readonly empty?: ReactNode;
363
381
  }
@@ -411,6 +429,11 @@ export function MemberList(props: MemberListProps) {
411
429
  </select>
412
430
  </label>
413
431
  )}
432
+ {props.workHref !== undefined && (
433
+ <a className="sc-button sc-button-ghost sc-button-sm" href={props.workHref(member)}>
434
+ {words.people.work}
435
+ </a>
436
+ )}
414
437
  {props.onRemove !== undefined && (
415
438
  <Button
416
439
  variant="danger"
@@ -0,0 +1,167 @@
1
+ import { type FormEvent, useState } from "react";
2
+ import { fillRules, isReferenceRules, MAX_RULES_LENGTH, type RestRules } from "../api.js";
3
+ import { useLanguage, useWords } from "../i18n/index.js";
4
+ import { Writer } from "./documents.js";
5
+ import { Markdown } from "./markdown.js";
6
+ import { Button, Callout, Time } from "./ui.js";
7
+
8
+ // The rules of the board (ADR-0023), as the settings pages show them: what holds, where it
9
+ // comes from (the console's own, a page written, or none), who wrote the page and when, and
10
+ // the ways to change them for whoever may. Each takes its data as props and nothing from the
11
+ // network; the rules are Markdown rendered to elements, never HTML, with `{language}` filled
12
+ // with the language the pages speak, as the command fills it with the person's.
13
+
14
+ export interface RulesViewProps {
15
+ readonly rules: RestRules;
16
+ /** Whose rules: the workspace's, or a project's, which add to the workspace's. */
17
+ readonly scope: "workspace" | "project";
18
+ /** Where the rules are written; left out, they are read here and not changed. */
19
+ readonly editHref?: string | undefined;
20
+ /** Called to put the written rules away; left out, they stay. */
21
+ readonly onArchive?: (() => void) | undefined;
22
+ readonly busy?: boolean | undefined;
23
+ /** Where a page of the project is read, by its path, for the links a project's rules make. */
24
+ readonly docHref?: ((path: string) => string) | undefined;
25
+ /** Whether the text stays folded under where it comes from, on a page where it is not the point. */
26
+ readonly folded?: boolean | undefined;
27
+ }
28
+
29
+ export function RulesView(props: RulesViewProps) {
30
+ const words = useWords();
31
+ const language = useLanguage().tag;
32
+ const { rules, scope } = props;
33
+ const page = rules.page;
34
+ if (rules.source === "none" || rules.body === null) {
35
+ return (
36
+ <div className="sc-rules">
37
+ <p className="sc-muted">{words.rules.none}</p>
38
+ {props.editHref !== undefined && (
39
+ <div className="sc-rules-actions">
40
+ <a className="sc-button sc-button-secondary" href={props.editHref}>
41
+ {words.rules.write}
42
+ </a>
43
+ </div>
44
+ )}
45
+ </div>
46
+ );
47
+ }
48
+ const text = <Markdown source={fillRules(rules.body, language)} docHref={props.docHref} />;
49
+ return (
50
+ <div className="sc-rules">
51
+ {rules.source === "reference" ? (
52
+ <Callout tone="info">{words.rules.reference}</Callout>
53
+ ) : (
54
+ page !== null && (
55
+ <p className="sc-rules-facts">
56
+ {words.docs.version(page.version)} · {words.docs.writtenBy}{" "}
57
+ <Writer by={page.updatedBy} agent={page.agent} /> <Time iso={page.updatedAt} />
58
+ </p>
59
+ )
60
+ )}
61
+ {props.folded === true ? (
62
+ <details className="sc-rules-fold">
63
+ <summary>{words.rules.read}</summary>
64
+ {text}
65
+ </details>
66
+ ) : (
67
+ text
68
+ )}
69
+ {(props.editHref !== undefined || props.onArchive !== undefined) && (
70
+ <div className="sc-rules-actions">
71
+ {props.editHref !== undefined && (
72
+ <a className="sc-button sc-button-secondary" href={props.editHref}>
73
+ {rules.source === "reference" ? words.rules.writeOrganizations : words.common.edit}
74
+ </a>
75
+ )}
76
+ {rules.source === "written" && props.onArchive !== undefined && (
77
+ <Button variant="ghost" onClick={props.onArchive} disabled={props.busy === true}>
78
+ {scope === "workspace" ? words.rules.useReference : words.common.remove}
79
+ </Button>
80
+ )}
81
+ </div>
82
+ )}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ export interface RulesFormProps {
88
+ /** The rules as they hold now: what the form starts from when a page is written. */
89
+ readonly rules: RestRules;
90
+ readonly scope: "workspace" | "project";
91
+ /** What the form starts from when a project adds none yet: the headings, and nothing an agent could mistake for a rule. */
92
+ readonly template: string;
93
+ readonly busy?: boolean | undefined;
94
+ readonly error?: string | undefined;
95
+ readonly onSubmit: (input: {
96
+ readonly body: string;
97
+ readonly baseVersion?: number | undefined;
98
+ }) => void;
99
+ readonly onCancel?: (() => void) | undefined;
100
+ /** Called to put the organization's page away, so that the console's own rules hold again; offered while one is written. */
101
+ readonly onReset?: (() => void) | undefined;
102
+ }
103
+
104
+ /**
105
+ * The form the rules are written on: prefilled with what holds (the console's own, for a
106
+ * workspace that wrote none), and saving nothing that would change nothing: the text as it
107
+ * is, or the console's own rules where they hold already. Where the organization's are
108
+ * written, the console's own saved as they stand put the page away, as the reset does.
109
+ */
110
+ export function RulesForm(props: RulesFormProps) {
111
+ const words = useWords();
112
+ const { rules, scope } = props;
113
+ /** What holds now: the page written, or the console's own; nothing for a project that adds none. */
114
+ const current = rules.source === "none" ? undefined : (rules.body ?? undefined);
115
+ const [body, setBody] = useState(current ?? props.template);
116
+ const unchanged =
117
+ body.trim() === (current ?? "").trim() ||
118
+ (scope === "workspace" && rules.source === "reference" && isReferenceRules(body));
119
+ const ready = !unchanged && body.trim().length > 0;
120
+
121
+ const submit = (event: FormEvent) => {
122
+ event.preventDefault();
123
+ if (!ready) {
124
+ return;
125
+ }
126
+ props.onSubmit({
127
+ body,
128
+ ...(rules.page === null ? {} : { baseVersion: rules.page.version }),
129
+ });
130
+ };
131
+
132
+ return (
133
+ <form className="sc-form" onSubmit={submit}>
134
+ <label className="sc-field">
135
+ <span className="sc-field-label">{words.rules.form.body}</span>
136
+ <textarea
137
+ className="sc-input sc-textarea sc-doc-editor"
138
+ value={body}
139
+ maxLength={MAX_RULES_LENGTH}
140
+ rows={20}
141
+ onChange={(event) => setBody(event.target.value)}
142
+ />
143
+ <span className="sc-muted">{words.rules.form.hint(MAX_RULES_LENGTH)}</span>
144
+ </label>
145
+ {props.error !== undefined && (
146
+ <p className="sc-form-error" role="alert">
147
+ {props.error}
148
+ </p>
149
+ )}
150
+ <div className="sc-form-actions">
151
+ <Button type="submit" variant="primary" disabled={props.busy === true || !ready}>
152
+ {words.rules.form.save}
153
+ </Button>
154
+ {props.onCancel !== undefined && (
155
+ <Button variant="ghost" onClick={props.onCancel}>
156
+ {words.common.cancel}
157
+ </Button>
158
+ )}
159
+ {props.onReset !== undefined && rules.source === "written" && (
160
+ <Button variant="ghost" onClick={props.onReset} disabled={props.busy === true}>
161
+ {words.rules.useReference}
162
+ </Button>
163
+ )}
164
+ </div>
165
+ </form>
166
+ );
167
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import type { RestArtifact, RestRun, RunStatus } from "../api.js";
2
+ import { agentLabel, type RestArtifact, type RestRun, type RunStatus } from "../api.js";
3
3
  import { useWords } from "../i18n/index.js";
4
4
  import { Markdown } from "./markdown.js";
5
5
  import { Badge, Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
@@ -51,7 +51,9 @@ export function RunCard(props: RunCardProps) {
51
51
  return (
52
52
  <article className="sc-run" aria-label={words.label(run.agent, personName(run.person))}>
53
53
  <header className="sc-run-header">
54
- <span className="sc-run-agent">{run.agent}</span>
54
+ <span className="sc-run-agent" title={run.agent}>
55
+ {agentLabel(run.agent)}
56
+ </span>
55
57
  <RunStatusBadge status={run.status} />
56
58
  <span className="sc-run-meta">
57
59
  {words.for} <PersonChip person={run.person} /> {words.since} <Time iso={run.startedAt} />
@@ -17,21 +17,32 @@ export function SkillList(props: SkillListProps) {
17
17
  if (skills === undefined) {
18
18
  return <Spinner label={words.loading} />;
19
19
  }
20
+ // The console could not answer for the deployment at all: said as such, not as no address.
21
+ if (skills.status === "unavailable" && skills.address === null) {
22
+ return <Callout tone="warning">{words.status.unavailable}</Callout>;
23
+ }
20
24
  if (skills.status === "none" || skills.address === null || skills.page === null) {
21
25
  return <EmptyState title={words.noAddress.title} body={words.noAddress.body} />;
22
26
  }
23
27
  const where = (
24
- <p className="sc-lead">
25
- {words.servedBy}{" "}
26
- <a href={skills.source} target="_blank" rel="noopener noreferrer">
27
- SkillCDN
28
- </a>{" "}
29
- {words.at}{" "}
30
- <a href={skills.page} target="_blank" rel="noopener noreferrer">
31
- <code>{skills.address}</code>
32
- </a>
33
- {words.howLoaded}
34
- </p>
28
+ <>
29
+ <p className="sc-lead">
30
+ {words.servedBy}{" "}
31
+ <a href={skills.source} target="_blank" rel="noopener noreferrer">
32
+ SkillCDN
33
+ </a>{" "}
34
+ {words.at}{" "}
35
+ <a href={skills.page} target="_blank" rel="noopener noreferrer">
36
+ <code>{skills.address}</code>
37
+ </a>
38
+ </p>
39
+ <p className="sc-muted">
40
+ {words.connect}{" "}
41
+ <a href={skills.page} target="_blank" rel="noopener noreferrer">
42
+ {words.connectLink}
43
+ </a>
44
+ </p>
45
+ </>
35
46
  );
36
47
  if (skills.status !== "ready") {
37
48
  return (