@spunto/design-system 0.14.2 → 0.15.0
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/package.json
CHANGED
|
@@ -4,7 +4,13 @@ import type { ReactNode } from "react"
|
|
|
4
4
|
import { ChevronDownIcon, Settings2Icon } from "lucide-react"
|
|
5
5
|
|
|
6
6
|
import { cn } from "../../utils"
|
|
7
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
isInternalSectionId,
|
|
9
|
+
type ProjectFormCustomSection,
|
|
10
|
+
type ProjectFormSectionId,
|
|
11
|
+
type ProjectFormValue,
|
|
12
|
+
type ProjectSecretRef,
|
|
13
|
+
} from "./types"
|
|
8
14
|
|
|
9
15
|
export interface AdvancedDisclosureProps {
|
|
10
16
|
open: boolean
|
|
@@ -101,8 +107,8 @@ export function AdvancedDisclosure({
|
|
|
101
107
|
* `lifecycle`, `3 secrets`. Pure, and exported, because a caller rendering its
|
|
102
108
|
* own fold needs the same sentence.
|
|
103
109
|
*/
|
|
104
|
-
export function advancedSummary(
|
|
105
|
-
value:
|
|
110
|
+
export function advancedSummary<TValue extends ProjectFormValue>(
|
|
111
|
+
value: TValue,
|
|
106
112
|
sections: ProjectFormSectionId[],
|
|
107
113
|
/**
|
|
108
114
|
* Secrets already stored server-side. They count: a project whose only
|
|
@@ -110,10 +116,23 @@ export function advancedSummary(
|
|
|
110
116
|
* keep the fold shut over them — exactly the "hides configuration you didn't
|
|
111
117
|
* set yourself" failure this component exists to avoid.
|
|
112
118
|
*/
|
|
113
|
-
savedSecrets: ProjectSecretRef[] = []
|
|
119
|
+
savedSecrets: ProjectSecretRef[] = [],
|
|
120
|
+
/**
|
|
121
|
+
* App-provided sections. Theirs count too, for the very same reason: a fold
|
|
122
|
+
* that shuts over a field it doesn't happen to know about hides it just as
|
|
123
|
+
* well as one it does.
|
|
124
|
+
*/
|
|
125
|
+
customSections: ProjectFormCustomSection<TValue>[] = []
|
|
114
126
|
): string[] {
|
|
127
|
+
const custom = new Map(customSections.filter((s) => !isInternalSectionId(s.id)).map((s) => [s.id, s]))
|
|
115
128
|
const out: string[] = []
|
|
116
129
|
for (const id of sections) {
|
|
130
|
+
const own = custom.get(id)
|
|
131
|
+
if (own) {
|
|
132
|
+
const label = own.summary?.(value)
|
|
133
|
+
if (label) out.push(label)
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
117
136
|
switch (id) {
|
|
118
137
|
case "features":
|
|
119
138
|
if (value.features.length) out.push(`${value.features.length} feature${value.features.length > 1 ? "s" : ""}`)
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
// Controlled by a single `ProjectFormValue`, and nothing in here fetches:
|
|
19
19
|
// catalogs come in as data, the extension search is a callback, the GitHub
|
|
20
20
|
// combobox is a slot, `onSubmit` hands the value back. Which sections show is a
|
|
21
|
-
// prop — creation and editing are one component with a different list
|
|
21
|
+
// prop — creation and editing are one component with a different list, and an
|
|
22
|
+
// app adds a section of its own to that list (`ProjectFormCustomSection`) for a
|
|
23
|
+
// field the package doesn't model, rather than drawing a card beside the form
|
|
24
|
+
// and losing its numbering, its fold and its manifest row.
|
|
22
25
|
//
|
|
23
26
|
// Every brick is exported on its own, because a screen that needs only the repo
|
|
24
27
|
// rows or only the manifest shouldn't have to take the form with it.
|
|
@@ -53,11 +56,15 @@ export type { ExtensionPickerProps } from "./extension-picker"
|
|
|
53
56
|
export {
|
|
54
57
|
CREATE_SECTIONS,
|
|
55
58
|
EDIT_SECTIONS,
|
|
59
|
+
INTERNAL_SECTION_IDS,
|
|
56
60
|
emptyProjectFormValue,
|
|
61
|
+
isInternalSectionId,
|
|
57
62
|
toProjectFormValue,
|
|
58
63
|
} from "./types"
|
|
59
64
|
export type {
|
|
65
|
+
InternalSectionId,
|
|
60
66
|
ProjectFeatureSelection,
|
|
67
|
+
ProjectFormCustomSection,
|
|
61
68
|
ProjectFormSectionId,
|
|
62
69
|
ProjectFormValue,
|
|
63
70
|
ProjectRepo,
|
|
@@ -27,6 +27,9 @@ import { RepoList, type GithubConnection, type GithubRepoOption, type RepoFieldR
|
|
|
27
27
|
import { SecretList } from "./secret-list"
|
|
28
28
|
import {
|
|
29
29
|
CREATE_SECTIONS,
|
|
30
|
+
isInternalSectionId,
|
|
31
|
+
type InternalSectionId,
|
|
32
|
+
type ProjectFormCustomSection,
|
|
30
33
|
type ProjectFormSectionId,
|
|
31
34
|
type ProjectFormValue,
|
|
32
35
|
type ProjectSecretRef,
|
|
@@ -35,7 +38,7 @@ import type { DevcontainerFeatureEntry, DevcontainerImageEntry, VscodeExtensionE
|
|
|
35
38
|
|
|
36
39
|
/** Chrome of each section: number, icon, accent, wording. */
|
|
37
40
|
const SECTION_META: Record<
|
|
38
|
-
|
|
41
|
+
InternalSectionId,
|
|
39
42
|
{ icon: ComponentType<{ className?: string }>; accent: SectionAccent; title: string; hint?: string }
|
|
40
43
|
> = {
|
|
41
44
|
identity: { icon: SquarePenIcon, accent: "build", title: "Identity", hint: "Name your project and what it's for" },
|
|
@@ -56,7 +59,7 @@ const SECTION_META: Record<
|
|
|
56
59
|
* sections in front of someone creating their first project is how a form stops
|
|
57
60
|
* being filled in at all.
|
|
58
61
|
*/
|
|
59
|
-
const DEFAULT_ADVANCED:
|
|
62
|
+
const DEFAULT_ADVANCED: InternalSectionId[] = [
|
|
60
63
|
"features",
|
|
61
64
|
"extensions",
|
|
62
65
|
"lifecycle",
|
|
@@ -66,13 +69,22 @@ const DEFAULT_ADVANCED: ProjectFormSectionId[] = [
|
|
|
66
69
|
"secrets",
|
|
67
70
|
]
|
|
68
71
|
|
|
69
|
-
export interface ProjectFormProps {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
export interface ProjectFormProps<TValue extends ProjectFormValue = ProjectFormValue> {
|
|
73
|
+
/**
|
|
74
|
+
* The complete value. Generic: an app whose project has a field the package
|
|
75
|
+
* doesn't model declares an interface extending `ProjectFormValue` and every
|
|
76
|
+
* callback here — including a custom section's — speaks that type instead.
|
|
77
|
+
*/
|
|
78
|
+
value: TValue
|
|
79
|
+
onChange: (value: TValue) => void
|
|
72
80
|
/** Receives the current value. The app maps it to its own payload and calls its API. */
|
|
73
|
-
onSubmit?: (value:
|
|
81
|
+
onSubmit?: (value: TValue) => void
|
|
74
82
|
|
|
75
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Which sections to show, in order. Defaults to the seven creation ones.
|
|
85
|
+
* A `customSections` id sits in here like any built-in — that's how an app's
|
|
86
|
+
* own card gets a place, a number and a fold.
|
|
87
|
+
*/
|
|
76
88
|
sections?: ProjectFormSectionId[]
|
|
77
89
|
/**
|
|
78
90
|
* Sections folded behind "Advanced options". Defaults to everything past
|
|
@@ -119,6 +131,19 @@ export interface ProjectFormProps {
|
|
|
119
131
|
/** Extra content appended inside a section — the deploy key card, a warning… */
|
|
120
132
|
extras?: Partial<Record<ProjectFormSectionId, ReactNode>>
|
|
121
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Cards the app draws itself, for the fields the package doesn't model.
|
|
136
|
+
*
|
|
137
|
+
* Each one is a full section: put its id in `sections` to place it, in
|
|
138
|
+
* `advancedSections` to fold it, and it gets the same numbered card, the same
|
|
139
|
+
* summary line while the fold is closed, the same manifest row. Unlike
|
|
140
|
+
* `extras` — which appends *inside* an existing section — this is a section of
|
|
141
|
+
* its own, so it doesn't have to pretend to belong to one it has nothing to do
|
|
142
|
+
* with. An id that shadows a built-in is ignored: this extends the form, it
|
|
143
|
+
* doesn't override it.
|
|
144
|
+
*/
|
|
145
|
+
customSections?: ProjectFormCustomSection<TValue>[]
|
|
146
|
+
|
|
122
147
|
/** `false` drops the recap panel entirely and gives the form the full width. */
|
|
123
148
|
manifest?: boolean
|
|
124
149
|
submitLabel?: ReactNode
|
|
@@ -148,8 +173,15 @@ export interface ProjectFormProps {
|
|
|
148
173
|
* for the app to send. Which sections are shown is a prop, so creation and
|
|
149
174
|
* editing are the same component with a different list — not two screens that
|
|
150
175
|
* drift apart.
|
|
176
|
+
*
|
|
177
|
+
* **And that list is open.** An app with a field the package doesn't model
|
|
178
|
+
* describes its own card in `customSections` and drops its id into `sections`:
|
|
179
|
+
* it then gets the numbering, the fold, the summary and the manifest row that a
|
|
180
|
+
* `FormSection` rendered next to the form can't have. Same for the value — the
|
|
181
|
+
* form is generic over it, so the app's extra fields are typed end to end
|
|
182
|
+
* instead of riding along behind a cast.
|
|
151
183
|
*/
|
|
152
|
-
export function ProjectForm({
|
|
184
|
+
export function ProjectForm<TValue extends ProjectFormValue = ProjectFormValue>({
|
|
153
185
|
value,
|
|
154
186
|
onChange,
|
|
155
187
|
onSubmit,
|
|
@@ -170,6 +202,7 @@ export function ProjectForm({
|
|
|
170
202
|
onSearchExtensions,
|
|
171
203
|
savedSecrets,
|
|
172
204
|
extras,
|
|
205
|
+
customSections,
|
|
173
206
|
manifest = true,
|
|
174
207
|
submitLabel = "Save",
|
|
175
208
|
submittingLabel = "Saving…",
|
|
@@ -179,14 +212,31 @@ export function ProjectForm({
|
|
|
179
212
|
manifestFooter,
|
|
180
213
|
manifestTitle,
|
|
181
214
|
className,
|
|
182
|
-
}: ProjectFormProps) {
|
|
183
|
-
|
|
215
|
+
}: ProjectFormProps<TValue>) {
|
|
216
|
+
// The one and only write: a spread. Which is *why* a value carrying fields the
|
|
217
|
+
// package doesn't model survives a form it never taught about them — a
|
|
218
|
+
// contract now, not a lucky implementation detail (see `ProjectFormValue`).
|
|
219
|
+
// The cast lives here, once, so no app has to write one at every boundary:
|
|
220
|
+
// `{ ...TValue, ...Partial<TValue> }` is a TValue, TypeScript just can't say so
|
|
221
|
+
// about a spread of a type parameter.
|
|
222
|
+
const applyPatch = (next: Partial<ProjectFormValue> | Partial<TValue>) =>
|
|
223
|
+
onChange({ ...value, ...next } as TValue)
|
|
224
|
+
const patch: (next: Partial<ProjectFormValue>) => void = applyPatch
|
|
225
|
+
const patchCustom: (next: Partial<TValue>) => void = applyPatch
|
|
226
|
+
|
|
227
|
+
// An id in `sections` that matches neither a built-in nor a custom section is
|
|
228
|
+
// dropped up front rather than skipped at render: numbering counts positions,
|
|
229
|
+
// so leaving the hole in would shift every section after it.
|
|
230
|
+
const custom = new Map<string, ProjectFormCustomSection<TValue>>(
|
|
231
|
+
(customSections ?? []).filter((s) => !isInternalSectionId(s.id)).map((s) => [s.id, s])
|
|
232
|
+
)
|
|
233
|
+
const shown = sections.filter((id) => isInternalSectionId(id) || custom.has(id))
|
|
184
234
|
|
|
185
|
-
const rows = manifestRows(value,
|
|
235
|
+
const rows = manifestRows(value, shown, savedSecrets, customSections)
|
|
186
236
|
|
|
187
|
-
const advanced =
|
|
188
|
-
const primary =
|
|
189
|
-
const summary = advancedSummary(value, advanced, savedSecrets)
|
|
237
|
+
const advanced = shown.filter((id) => advancedSections.includes(id))
|
|
238
|
+
const primary = shown.filter((id) => !advancedSections.includes(id))
|
|
239
|
+
const summary = advancedSummary(value, advanced, savedSecrets, customSections)
|
|
190
240
|
// Start open when there's already something in there — the initial value is
|
|
191
241
|
// read once, on mount, so a later edit can't yank the fold open under the
|
|
192
242
|
// user's cursor.
|
|
@@ -251,7 +301,12 @@ export function ProjectForm({
|
|
|
251
301
|
|
|
252
302
|
/** One section, numbered by its position in the whole form (folded or not). */
|
|
253
303
|
function section(id: ProjectFormSectionId, index: number) {
|
|
254
|
-
const
|
|
304
|
+
const own = custom.get(id)
|
|
305
|
+
// A custom card is chrome-identical to a built-in one: same `FormSection`,
|
|
306
|
+
// same badge, same accent — only its body comes from the app.
|
|
307
|
+
const meta = own
|
|
308
|
+
? { icon: own.icon, accent: own.accent, title: own.title, hint: own.hint }
|
|
309
|
+
: SECTION_META[id as InternalSectionId]
|
|
255
310
|
return (
|
|
256
311
|
<FormSection
|
|
257
312
|
key={id}
|
|
@@ -265,13 +320,13 @@ export function ProjectForm({
|
|
|
265
320
|
// you've already scrolled to it.
|
|
266
321
|
style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
|
|
267
322
|
>
|
|
268
|
-
{renderSection(id)}
|
|
323
|
+
{own ? own.render(value, patchCustom) : renderSection(id as InternalSectionId)}
|
|
269
324
|
{extras?.[id]}
|
|
270
325
|
</FormSection>
|
|
271
326
|
)
|
|
272
327
|
}
|
|
273
328
|
|
|
274
|
-
function renderSection(id:
|
|
329
|
+
function renderSection(id: InternalSectionId): ReactNode {
|
|
275
330
|
switch (id) {
|
|
276
331
|
case "identity":
|
|
277
332
|
return (
|
|
@@ -450,11 +505,17 @@ function parsePorts(raw: string): number[] {
|
|
|
450
505
|
* actually shown get a line: a creation form that hides ports shouldn't recap
|
|
451
506
|
* an empty "ports" row.
|
|
452
507
|
*/
|
|
453
|
-
export function manifestRows(
|
|
454
|
-
value:
|
|
508
|
+
export function manifestRows<TValue extends ProjectFormValue>(
|
|
509
|
+
value: TValue,
|
|
455
510
|
sections: ProjectFormSectionId[],
|
|
456
511
|
/** Counted alongside the drafts — an existing secret is still a secret. */
|
|
457
|
-
savedSecrets: ProjectSecretRef[] = []
|
|
512
|
+
savedSecrets: ProjectSecretRef[] = [],
|
|
513
|
+
/**
|
|
514
|
+
* App-provided sections. Their rows come after the built-in ones, in
|
|
515
|
+
* `sections` order — the built-in lines follow a fixed reading order rather
|
|
516
|
+
* than the form's, so there's no position to interleave them at.
|
|
517
|
+
*/
|
|
518
|
+
customSections: ProjectFormCustomSection<TValue>[] = []
|
|
458
519
|
): ManifestRow[] {
|
|
459
520
|
const shown = new Set(sections)
|
|
460
521
|
const rows: ManifestRow[] = []
|
|
@@ -491,5 +552,13 @@ export function manifestRows(
|
|
|
491
552
|
rows.push({ label: "secrets", value: `${n}`, done: n > 0 })
|
|
492
553
|
}
|
|
493
554
|
|
|
555
|
+
if (customSections.length) {
|
|
556
|
+
const byId = new Map(customSections.filter((s) => !isInternalSectionId(s.id)).map((s) => [s.id, s]))
|
|
557
|
+
for (const id of sections) {
|
|
558
|
+
const row = byId.get(id)?.manifestRow?.(value)
|
|
559
|
+
if (row) rows.push(row)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
494
563
|
return rows
|
|
495
564
|
}
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
// payload on submit. A complete value is what makes the form controllable
|
|
9
9
|
// without a single `?? ""` at every input.
|
|
10
10
|
|
|
11
|
+
import type { ComponentType, ReactNode } from "react"
|
|
12
|
+
|
|
13
|
+
import type { ManifestRow } from "./build-manifest"
|
|
14
|
+
import type { SectionAccent } from "./form-section"
|
|
15
|
+
|
|
11
16
|
/** One repository to clone into the workspace. */
|
|
12
17
|
export interface ProjectRepo {
|
|
13
18
|
/** Client-side identity, stable across re-renders. Not the API's id. */
|
|
@@ -58,7 +63,25 @@ export interface ProjectSecretRef {
|
|
|
58
63
|
name: string
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Everything the form edits. Every field is present — see the note at the top.
|
|
68
|
+
*
|
|
69
|
+
* **An app may carry more, and that is a supported contract, not a trick.** The
|
|
70
|
+
* form only ever patches at the spread (`onChange({ ...value, ...next })`), so
|
|
71
|
+
* any field it doesn't model travels through untouched. Declare an interface
|
|
72
|
+
* that extends this one and pass it to the form — `<ProjectForm<MyValue> …>`, or
|
|
73
|
+
* just let it infer from `value` — and every callback (`onChange`, `onSubmit`,
|
|
74
|
+
* and a custom section's `render` / `summary` / `manifestRow`) speaks *your*
|
|
75
|
+
* type, with no cast at the boundary:
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* interface LiteFormValue extends ProjectFormValue { sharedVolumes: Volume[] }
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* That's the whole extension story for the value, deliberately: a `custom`
|
|
82
|
+
* sub-bag would be a second place to put a field, and the fields apps actually
|
|
83
|
+
* add (a branch, a volume list) belong next to the others, not in a drawer.
|
|
84
|
+
*/
|
|
62
85
|
export interface ProjectFormValue {
|
|
63
86
|
name: string
|
|
64
87
|
description: string
|
|
@@ -77,7 +100,8 @@ export interface ProjectFormValue {
|
|
|
77
100
|
dockerInDocker: boolean
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
|
|
103
|
+
/** The ten sections the package models and draws itself. */
|
|
104
|
+
export type InternalSectionId =
|
|
81
105
|
| "identity"
|
|
82
106
|
| "image"
|
|
83
107
|
| "repositories"
|
|
@@ -89,6 +113,72 @@ export type ProjectFormSectionId =
|
|
|
89
113
|
| "docker"
|
|
90
114
|
| "secrets"
|
|
91
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A section of the form: one of the ten above, or the id of a card the app
|
|
118
|
+
* brings itself through `customSections`.
|
|
119
|
+
*
|
|
120
|
+
* Open on purpose, and `(string & {})` rather than plain `string` so the ten
|
|
121
|
+
* known ids keep their autocompletion — a closed union meant an app with a field
|
|
122
|
+
* of its own had to either hang it under an unrelated section via `extras` or
|
|
123
|
+
* draw its own card outside the form, losing the numbering, the fold and the
|
|
124
|
+
* manifest along the way.
|
|
125
|
+
*/
|
|
126
|
+
export type ProjectFormSectionId = InternalSectionId | (string & {})
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A card the app draws itself, numbered and folded like the package's own.
|
|
130
|
+
*
|
|
131
|
+
* Same contract as an internal section, no more: `render` gets the value and a
|
|
132
|
+
* patch, and that's it. Nothing here fetches, nothing here holds state — this is
|
|
133
|
+
* a render callback that happens to be recognised by the form's chrome, not a
|
|
134
|
+
* plugin. What it buys over a hand-rolled `FormSection` rendered after the form
|
|
135
|
+
* is everything the chrome owns: its place in `sections` (inside the "Advanced
|
|
136
|
+
* options" fold if the app puts its id in `advancedSections`), its number in the
|
|
137
|
+
* shared sequence, its line in the fold's summary, and its row in the manifest.
|
|
138
|
+
*
|
|
139
|
+
* Generic over the value so an app that extends `ProjectFormValue` reads and
|
|
140
|
+
* patches its own fields without a cast (see the note on `ProjectFormValue`).
|
|
141
|
+
*/
|
|
142
|
+
export interface ProjectFormCustomSection<TValue extends ProjectFormValue = ProjectFormValue> {
|
|
143
|
+
/** Used in `sections` / `advancedSections` / `extras`. Ignored if it shadows an internal id. */
|
|
144
|
+
id: string
|
|
145
|
+
title: string
|
|
146
|
+
hint?: string
|
|
147
|
+
icon?: ComponentType<{ className?: string }>
|
|
148
|
+
/** Defaults to `build`, like `FormSection` itself. */
|
|
149
|
+
accent?: SectionAccent
|
|
150
|
+
/** The card's body. `patch` merges into the value, exactly like an internal section. */
|
|
151
|
+
render: (value: TValue, patch: (next: Partial<TValue>) => void) => ReactNode
|
|
152
|
+
/**
|
|
153
|
+
* One short label — `2 volumes` — when this section holds something, `null`
|
|
154
|
+
* otherwise. Feeds the closed fold's summary, and with it the form's decision
|
|
155
|
+
* to start open. Skipping it means the fold can shut over a value the user
|
|
156
|
+
* typed, which is the failure the disclosure exists to prevent.
|
|
157
|
+
*/
|
|
158
|
+
summary?: (value: TValue) => string | null
|
|
159
|
+
/** A line in the build manifest, or `null`. Appended after the built-in rows. */
|
|
160
|
+
manifestRow?: (value: TValue) => ManifestRow | null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Internal ids, as a runtime set — what tells a custom id from a built-in one. */
|
|
164
|
+
export const INTERNAL_SECTION_IDS: readonly InternalSectionId[] = [
|
|
165
|
+
"identity",
|
|
166
|
+
"image",
|
|
167
|
+
"repositories",
|
|
168
|
+
"features",
|
|
169
|
+
"extensions",
|
|
170
|
+
"lifecycle",
|
|
171
|
+
"ports",
|
|
172
|
+
"prewarm",
|
|
173
|
+
"docker",
|
|
174
|
+
"secrets",
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
/** True for the ten sections the package draws itself. */
|
|
178
|
+
export function isInternalSectionId(id: ProjectFormSectionId): id is InternalSectionId {
|
|
179
|
+
return (INTERNAL_SECTION_IDS as readonly string[]).includes(id)
|
|
180
|
+
}
|
|
181
|
+
|
|
92
182
|
/** Creation: the seven sections that matter before a project exists. */
|
|
93
183
|
export const CREATE_SECTIONS: ProjectFormSectionId[] = [
|
|
94
184
|
"identity",
|