@talelk/newui-data 1.0.0 → 1.1.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/README.md +13 -2
- package/package.json +1 -1
- package/skills/newui-data/SKILL.md +59 -5
- package/src/aggregate.test.ts +231 -0
- package/src/aggregate.ts +320 -0
- package/src/config-store.test.ts +157 -0
- package/src/config-store.ts +295 -0
- package/src/csv-export.test.ts +156 -0
- package/src/csv-export.ts +129 -0
- package/src/dashboard.test.tsx +285 -0
- package/src/dashboard.tsx +381 -0
- package/src/exports.test.ts +33 -3
- package/src/form/autosave.test.ts +186 -0
- package/src/form/autosave.ts +194 -0
- package/src/form/config.test.tsx +288 -0
- package/src/form/config.ts +187 -0
- package/src/form/form.tsx +122 -2
- package/src/form/index.ts +2 -0
- package/src/index.ts +6 -0
- package/src/query/collection.test.tsx +130 -3
- package/src/query/collection.ts +15 -0
- package/src/query/index.ts +1 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -0
- package/src/resource/index.ts +7 -0
- package/src/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/url-serialize.ts +93 -0
- package/src/url-state.test.ts +183 -0
- package/src/url-state.ts +168 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { AnyFormApi } from '@tanstack/solid-form'
|
|
2
|
+
import { type Accessor, createSignal, onCleanup } from 'solid-js'
|
|
3
|
+
import { type ConfigStorage, createConfigStore } from '../config-store'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Autosave and drafts for a live form.
|
|
7
|
+
*
|
|
8
|
+
* Two stores, two rules. The **autosave** is a debounced backend write of a
|
|
9
|
+
* valid, pruned document: every change re-arms the timer, and when it fires
|
|
10
|
+
* the form validates itself before anything is written — an invalid document
|
|
11
|
+
* is never saved behind the user's back. It is not a submit; the submit
|
|
12
|
+
* lifecycle does not move. The **draft** is the local, unpruned copy written
|
|
13
|
+
* on every change (through the config store under `newui.form-draft`, document
|
|
14
|
+
* name = `id`), so a closed tab loses nothing and re-showing a hidden field
|
|
15
|
+
* gives back what was typed. A successful autosave or submit drops the draft.
|
|
16
|
+
*/
|
|
17
|
+
export interface AutoSaveOptions<T extends object> {
|
|
18
|
+
/** Persists the valid, dirty document — usually a query mutation. */
|
|
19
|
+
save: (values: T) => void | Promise<void>
|
|
20
|
+
/**
|
|
21
|
+
* Milliseconds a change waits before it is saved.
|
|
22
|
+
* @default 800
|
|
23
|
+
*/
|
|
24
|
+
debounce?: number
|
|
25
|
+
/** Skips the write when it returns `false` (an unfinished required section). */
|
|
26
|
+
filter?: (values: T) => boolean
|
|
27
|
+
/** Draft identity. Omit it to autosave without persisting drafts. */
|
|
28
|
+
id?: string
|
|
29
|
+
/** Draft storage backend. Defaults to `localStorage`, like the config store. */
|
|
30
|
+
storage?: ConfigStorage
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What autosave watches: a TanStack form, or the `createAppForm` result —
|
|
35
|
+
* pass the latter so `save` receives the same pruned document submit sends.
|
|
36
|
+
*/
|
|
37
|
+
export type AutoSaveSource<T extends object> =
|
|
38
|
+
| AnyFormApi
|
|
39
|
+
| { form: AnyFormApi; document: (values: T) => T }
|
|
40
|
+
|
|
41
|
+
/** What `createAutoSave` returns; the chrome reads the accessors. */
|
|
42
|
+
export interface AutoSave<T extends object = Record<string, unknown>> {
|
|
43
|
+
/** True while a save is in flight. */
|
|
44
|
+
saving: Accessor<boolean>
|
|
45
|
+
/** True once a save has landed and nothing has changed since. */
|
|
46
|
+
saved: Accessor<boolean>
|
|
47
|
+
/** True when the form holds an unsaved change. */
|
|
48
|
+
dirty: Accessor<boolean>
|
|
49
|
+
/** Save now instead of waiting out the debounce. */
|
|
50
|
+
flush: () => Promise<void>
|
|
51
|
+
/** Whether a stored draft exists, reactive. */
|
|
52
|
+
hasDraft: Accessor<boolean>
|
|
53
|
+
/**
|
|
54
|
+
* The stored draft, if any — the app decides whether to apply it. A draft
|
|
55
|
+
* may predate the current schema or field map: treat it as untrusted input
|
|
56
|
+
* and let the form's validation report what no longer fits.
|
|
57
|
+
*/
|
|
58
|
+
restoreDraft: () => T | undefined
|
|
59
|
+
/** Drop the stored draft (on submit, discard, or after `restoreDraft`). */
|
|
60
|
+
discardDraft: () => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clone<T>(value: T): T {
|
|
64
|
+
return structuredClone(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
68
|
+
if (a === b) return true
|
|
69
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
|
|
70
|
+
if (
|
|
71
|
+
a === null ||
|
|
72
|
+
b === null ||
|
|
73
|
+
typeof a !== 'object' ||
|
|
74
|
+
typeof b !== 'object'
|
|
75
|
+
)
|
|
76
|
+
return false
|
|
77
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
78
|
+
const aKeys = Object.keys(a as object)
|
|
79
|
+
const bKeys = Object.keys(b as object)
|
|
80
|
+
if (aKeys.length !== bKeys.length) return false
|
|
81
|
+
return aKeys.every(
|
|
82
|
+
(key) =>
|
|
83
|
+
Object.hasOwn(b, key) &&
|
|
84
|
+
deepEqual(
|
|
85
|
+
(a as Record<string, unknown>)[key],
|
|
86
|
+
(b as Record<string, unknown>)[key],
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isAppForm<T extends object>(
|
|
92
|
+
source: AutoSaveSource<T>,
|
|
93
|
+
): source is { form: AnyFormApi; document: (values: T) => T } {
|
|
94
|
+
return 'document' in source && typeof source.document === 'function'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createAutoSave<T extends object>(
|
|
98
|
+
source: AutoSaveSource<T>,
|
|
99
|
+
options: AutoSaveOptions<T>,
|
|
100
|
+
): AutoSave<T> {
|
|
101
|
+
const form = isAppForm(source) ? source.form : source
|
|
102
|
+
const document = isAppForm(source) ? source.document : (values: T) => values
|
|
103
|
+
const debounce = options.debounce ?? 800
|
|
104
|
+
const store = options.id
|
|
105
|
+
? createConfigStore({ key: 'newui.form-draft', storage: options.storage })
|
|
106
|
+
: undefined
|
|
107
|
+
const id = options.id
|
|
108
|
+
|
|
109
|
+
let lastSaved: T = clone(form.state.values as T)
|
|
110
|
+
let running: Promise<void> = Promise.resolve()
|
|
111
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
112
|
+
const [saving, setSaving] = createSignal(false)
|
|
113
|
+
const [saved, setSaved] = createSignal(false)
|
|
114
|
+
const [dirty, setDirty] = createSignal(false)
|
|
115
|
+
const [hasDraft, setHasDraft] = createSignal(
|
|
116
|
+
store !== undefined && id !== undefined && store.load(id) !== undefined,
|
|
117
|
+
)
|
|
118
|
+
const writeDraft = (values: T) => {
|
|
119
|
+
if (!store || !id) return
|
|
120
|
+
store.save(id, values, { type: 'form-draft' })
|
|
121
|
+
setHasDraft(true)
|
|
122
|
+
}
|
|
123
|
+
const dropDraft = () => {
|
|
124
|
+
if (!store || !id) return
|
|
125
|
+
store.remove(id)
|
|
126
|
+
setHasDraft(false)
|
|
127
|
+
}
|
|
128
|
+
let submissions = form.state.submissionAttempts as number
|
|
129
|
+
|
|
130
|
+
const run = async (): Promise<void> => {
|
|
131
|
+
const values = clone(form.state.values as T)
|
|
132
|
+
if (deepEqual(values, lastSaved)) return
|
|
133
|
+
if (options.filter && !options.filter(values)) return
|
|
134
|
+
setSaving(true)
|
|
135
|
+
try {
|
|
136
|
+
await form.validateAllFields('submit')
|
|
137
|
+
if ((form.state.errors as unknown[]).length === 0) {
|
|
138
|
+
await options.save(document(values))
|
|
139
|
+
lastSaved = values
|
|
140
|
+
setSaved(true)
|
|
141
|
+
setDirty(false)
|
|
142
|
+
dropDraft()
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
setSaving(false)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const subscription = form.store.subscribe(() => {
|
|
150
|
+
const state = form.state
|
|
151
|
+
// A successful submit wrote the document: nothing is unsaved, the draft
|
|
152
|
+
// goes, and the values it sent are the new baseline.
|
|
153
|
+
if (
|
|
154
|
+
state.isSubmitSuccessful &&
|
|
155
|
+
(state.submissionAttempts as number) !== submissions
|
|
156
|
+
) {
|
|
157
|
+
submissions = state.submissionAttempts as number
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
lastSaved = clone(state.values as T)
|
|
160
|
+
setDirty(false)
|
|
161
|
+
dropDraft()
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
// Mid-submit changes (an `onSubmit` reset) are the submit's, not edits.
|
|
165
|
+
if (state.isSubmitting) return
|
|
166
|
+
const values = state.values as T
|
|
167
|
+
if (deepEqual(values, lastSaved)) return
|
|
168
|
+
setDirty(true)
|
|
169
|
+
setSaved(false)
|
|
170
|
+
writeDraft(values)
|
|
171
|
+
clearTimeout(timer)
|
|
172
|
+
timer = setTimeout(() => {
|
|
173
|
+
running = run()
|
|
174
|
+
}, debounce)
|
|
175
|
+
})
|
|
176
|
+
onCleanup(() => {
|
|
177
|
+
clearTimeout(timer)
|
|
178
|
+
subscription.unsubscribe()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
saving,
|
|
183
|
+
saved,
|
|
184
|
+
dirty,
|
|
185
|
+
async flush() {
|
|
186
|
+
clearTimeout(timer)
|
|
187
|
+
await running
|
|
188
|
+
await run()
|
|
189
|
+
},
|
|
190
|
+
hasDraft,
|
|
191
|
+
restoreDraft: () => (store && id ? store.load<T>(id)?.doc : undefined),
|
|
192
|
+
discardDraft: dropDraft,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
2
|
+
import type { StandardSchemaV1 } from '@tanstack/solid-form'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { createRoot } from 'solid-js'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { type FieldConfig, hiddenPaths, pruneHiddenValues } from './config'
|
|
7
|
+
import { createAppForm, Form } from './form'
|
|
8
|
+
|
|
9
|
+
/** A minimal Standard Schema: `name` must be non-empty, `city` must be a URL. */
|
|
10
|
+
function schema(): StandardSchemaV1<
|
|
11
|
+
Record<string, unknown>,
|
|
12
|
+
Record<string, unknown>
|
|
13
|
+
> {
|
|
14
|
+
return {
|
|
15
|
+
'~standard': {
|
|
16
|
+
version: 1,
|
|
17
|
+
vendor: 'test',
|
|
18
|
+
validate: (value) => {
|
|
19
|
+
const issues: { message: string; path: { key: string }[] }[] = []
|
|
20
|
+
const doc = value as Record<string, unknown>
|
|
21
|
+
if (!doc['name'])
|
|
22
|
+
issues.push({ message: 'Name is required.', path: [{ key: 'name' }] })
|
|
23
|
+
if (doc['city'] && !String(doc['city']).startsWith('http'))
|
|
24
|
+
issues.push({
|
|
25
|
+
message: 'City must be a URL.',
|
|
26
|
+
path: [{ key: 'city' }],
|
|
27
|
+
})
|
|
28
|
+
return issues.length
|
|
29
|
+
? { issues }
|
|
30
|
+
: { value: value as Record<string, unknown> }
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('pruneHiddenValues', () => {
|
|
37
|
+
it('drops a hidden nested field and leaves the rest', () => {
|
|
38
|
+
const values = { mode: 'simple', smtp: { host: 'a', port: 25 } }
|
|
39
|
+
const pruned = pruneHiddenValues(values, {
|
|
40
|
+
smtp: { when: (v) => v.mode === 'advanced' },
|
|
41
|
+
})
|
|
42
|
+
expect(pruned).toEqual({ mode: 'simple' })
|
|
43
|
+
// The original is never mutated.
|
|
44
|
+
expect(values.smtp.host).toBe('a')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('drops a hidden row field from its row and keeps the row', () => {
|
|
48
|
+
const values = {
|
|
49
|
+
items: [
|
|
50
|
+
{ kind: 'file', url: 'x' },
|
|
51
|
+
{ kind: 'link', url: 'y' },
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
const pruned = pruneHiddenValues(values, rowUi)
|
|
55
|
+
expect(pruned.items).toEqual([{ kind: 'file' }, { kind: 'link', url: 'y' }])
|
|
56
|
+
expect(values.items[0]).toEqual({ kind: 'file', url: 'x' })
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** A repeater whose `url` row field shows only for `link` rows. */
|
|
61
|
+
// biome-ignore lint/suspicious/noExplicitAny: fixtures shared across documents of different shapes.
|
|
62
|
+
const rowUi: Record<string, FieldConfig<any>> = {
|
|
63
|
+
items: {
|
|
64
|
+
type: 'repeater',
|
|
65
|
+
fields: {
|
|
66
|
+
kind: { type: 'text' },
|
|
67
|
+
url: { when: (row: { kind: string }) => row.kind === 'link' },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('hiddenPaths', () => {
|
|
73
|
+
it('lists the paths whose when is false', () => {
|
|
74
|
+
const ui = {
|
|
75
|
+
a: { when: (v: { on: boolean }) => v.on },
|
|
76
|
+
b: { when: () => true },
|
|
77
|
+
}
|
|
78
|
+
expect(hiddenPaths({ on: false }, ui)).toEqual(['a'])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('expands a row field per row, passing the row and the form values', () => {
|
|
82
|
+
const values = {
|
|
83
|
+
flag: true,
|
|
84
|
+
items: [{ kind: 'file' }, { kind: 'link' }, { kind: 'file' }],
|
|
85
|
+
}
|
|
86
|
+
expect(hiddenPaths(values, rowUi)).toEqual(['items[0].url', 'items[2].url'])
|
|
87
|
+
const seen: unknown[] = []
|
|
88
|
+
hiddenPaths(values, {
|
|
89
|
+
items: {
|
|
90
|
+
fields: {
|
|
91
|
+
url: {
|
|
92
|
+
when: (row: unknown, all: unknown) => {
|
|
93
|
+
seen.push([row, all])
|
|
94
|
+
return true
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
expect(seen[0]).toEqual([{ kind: 'file' }, values])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('does not expand the rows of a hidden repeater', () => {
|
|
104
|
+
const values = { on: false, items: [{ kind: 'file' }] }
|
|
105
|
+
expect(
|
|
106
|
+
hiddenPaths(values, {
|
|
107
|
+
items: { ...rowUi['items'], when: (v: typeof values) => v.on },
|
|
108
|
+
}),
|
|
109
|
+
).toEqual(['items'])
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('createAppForm field-map keys', () => {
|
|
114
|
+
it('rejects a key that names an array position', () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
createRoot(() =>
|
|
117
|
+
createAppForm({
|
|
118
|
+
defaultValues: { items: [] as unknown[] },
|
|
119
|
+
ui: { 'items[1]': { when: () => false } },
|
|
120
|
+
onSubmit: () => {},
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
123
|
+
).toThrow(/static path/)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does not block submit on an issue under a hidden row field', async () => {
|
|
127
|
+
const onSubmit = vi.fn()
|
|
128
|
+
const rowSchema: StandardSchemaV1<RowDoc, RowDoc> = {
|
|
129
|
+
'~standard': {
|
|
130
|
+
version: 1,
|
|
131
|
+
vendor: 'test',
|
|
132
|
+
validate: (value) => {
|
|
133
|
+
const doc = value as RowDoc
|
|
134
|
+
const issues = doc.items.flatMap((row, index) =>
|
|
135
|
+
row.url === undefined || row.url === ''
|
|
136
|
+
? [
|
|
137
|
+
{
|
|
138
|
+
message: 'URL is required.',
|
|
139
|
+
path: [{ key: 'items' }, { key: index }, { key: 'url' }],
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
: [],
|
|
143
|
+
)
|
|
144
|
+
return issues.length ? { issues } : { value: doc }
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
let app!: ReturnType<typeof createAppForm<RowDoc>>
|
|
149
|
+
createRoot(() => {
|
|
150
|
+
app = createAppForm<RowDoc>({
|
|
151
|
+
defaultValues: {
|
|
152
|
+
items: [
|
|
153
|
+
{ kind: 'file', url: '' },
|
|
154
|
+
{ kind: 'link', url: 'https://a' },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
schema: rowSchema,
|
|
158
|
+
ui: rowUi,
|
|
159
|
+
onSubmit,
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
await app.form.handleSubmit()
|
|
163
|
+
expect(onSubmit).toHaveBeenCalledWith(
|
|
164
|
+
{ items: [{ kind: 'file' }, { kind: 'link', url: 'https://a' }] },
|
|
165
|
+
expect.anything(),
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('createAppForm hidden requirements', () => {
|
|
171
|
+
it('does not block submit when the schema requires a hidden top-level field', async () => {
|
|
172
|
+
type Doc = { mode: string; host?: string }
|
|
173
|
+
const onSubmit = vi.fn()
|
|
174
|
+
let app!: ReturnType<typeof createAppForm<Doc>>
|
|
175
|
+
createRoot(() => {
|
|
176
|
+
app = createAppForm<Doc>({
|
|
177
|
+
defaultValues: { mode: 'simple', host: '' },
|
|
178
|
+
schema: {
|
|
179
|
+
'~standard': {
|
|
180
|
+
version: 1,
|
|
181
|
+
vendor: 'test',
|
|
182
|
+
validate: (value) =>
|
|
183
|
+
(value as Doc).host
|
|
184
|
+
? { value: value as Doc }
|
|
185
|
+
: {
|
|
186
|
+
issues: [{ message: 'Host is required.', path: ['host'] }],
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
ui: { host: { when: (values) => values.mode === 'advanced' } },
|
|
191
|
+
onSubmit,
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
await app.form.handleSubmit()
|
|
195
|
+
expect(onSubmit).toHaveBeenCalledWith({ mode: 'simple' }, expect.anything())
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
interface RowDoc {
|
|
200
|
+
items: { kind: string; url?: string }[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function Harness(props: {
|
|
204
|
+
onSubmit: (value: Record<string, unknown>) => void
|
|
205
|
+
pruneHidden?: boolean
|
|
206
|
+
}): import('solid-js').JSX.Element {
|
|
207
|
+
// A test harness: `onSubmit`/`pruneHidden` configure the form once, so these
|
|
208
|
+
// are setup-time reads of static props, not tracked ones.
|
|
209
|
+
/* eslint-disable solid/reactivity */
|
|
210
|
+
const { form } = createAppForm<Record<string, unknown>>({
|
|
211
|
+
defaultValues: { mode: 'simple', name: '', city: '' },
|
|
212
|
+
schema: schema(),
|
|
213
|
+
ui: {
|
|
214
|
+
mode: { type: 'text' },
|
|
215
|
+
name: { type: 'text' },
|
|
216
|
+
city: {
|
|
217
|
+
type: 'text',
|
|
218
|
+
dependsOn: ['mode'],
|
|
219
|
+
when: (values) => values['mode'] === 'advanced',
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
pruneHidden: props.pruneHidden,
|
|
223
|
+
onSubmit: props.onSubmit,
|
|
224
|
+
})
|
|
225
|
+
/* eslint-enable solid/reactivity */
|
|
226
|
+
return (
|
|
227
|
+
<Form form={form}>
|
|
228
|
+
<form.Field name="mode">
|
|
229
|
+
{(field) => (
|
|
230
|
+
<select
|
|
231
|
+
aria-label="Mode"
|
|
232
|
+
value={field().state.value as string}
|
|
233
|
+
onChange={(event) =>
|
|
234
|
+
field().handleChange(event.currentTarget.value)
|
|
235
|
+
}
|
|
236
|
+
>
|
|
237
|
+
<option value="simple">simple</option>
|
|
238
|
+
<option value="advanced">advanced</option>
|
|
239
|
+
</select>
|
|
240
|
+
)}
|
|
241
|
+
</form.Field>
|
|
242
|
+
<form.Field name="name">
|
|
243
|
+
{(field) => (
|
|
244
|
+
<input
|
|
245
|
+
aria-label="Name"
|
|
246
|
+
value={field().state.value as string}
|
|
247
|
+
onInput={(event) => field().handleChange(event.currentTarget.value)}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
</form.Field>
|
|
251
|
+
<button type="submit">Save</button>
|
|
252
|
+
</Form>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
describe('createAppForm with schema + ui', () => {
|
|
257
|
+
it('prunes a hidden field from the submitted document', async () => {
|
|
258
|
+
const onSubmit = vi.fn()
|
|
259
|
+
render(() => <Harness onSubmit={onSubmit} />)
|
|
260
|
+
await userEvent.type(screen.getByLabelText('Name'), 'Ada')
|
|
261
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
262
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
263
|
+
// city is hidden under mode=simple, so it is not part of the document.
|
|
264
|
+
expect(onSubmit.mock.calls[0]?.[0]).toEqual({ mode: 'simple', name: 'Ada' })
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('keeps the field when pruneHidden is false', async () => {
|
|
268
|
+
const onSubmit = vi.fn()
|
|
269
|
+
render(() => <Harness onSubmit={onSubmit} pruneHidden={false} />)
|
|
270
|
+
await userEvent.type(screen.getByLabelText('Name'), 'Ada')
|
|
271
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
272
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
273
|
+
expect(onSubmit.mock.calls[0]?.[0]).toEqual({
|
|
274
|
+
mode: 'simple',
|
|
275
|
+
name: 'Ada',
|
|
276
|
+
city: '',
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('blocks submit while a visible required field is empty', async () => {
|
|
281
|
+
const onSubmit = vi.fn()
|
|
282
|
+
render(() => <Harness onSubmit={onSubmit} />)
|
|
283
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
284
|
+
// The schema rejects the empty name; onSubmit never runs.
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
286
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
287
|
+
})
|
|
288
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { JSX } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One entry of a config form's field map: what the engine reads, and what the
|
|
5
|
+
* `ConfigForm` organism renders.
|
|
6
|
+
*
|
|
7
|
+
* The schema validates; the field map says how to render. A Standard Schema
|
|
8
|
+
* carries zero UI information, so `createAppForm` accepts a map of static
|
|
9
|
+
* field paths to these entries and reads only `when` and `dependsOn` from
|
|
10
|
+
* them — everything else is renderer vocabulary, and the same object serves
|
|
11
|
+
* both sides.
|
|
12
|
+
*
|
|
13
|
+
* `TScope` is what `when` sees first: the form's values for a top-level entry,
|
|
14
|
+
* the row for a repeater's row field.
|
|
15
|
+
*/
|
|
16
|
+
export interface FieldConfig<
|
|
17
|
+
TValues = Record<string, unknown>,
|
|
18
|
+
TScope = TValues,
|
|
19
|
+
> {
|
|
20
|
+
/**
|
|
21
|
+
* Which control renders. The default mapping is the molecule
|
|
22
|
+
* `FormControl` vocabulary (`text`, `select`, `checkbox`, `date`, …), plus
|
|
23
|
+
* `repeater` for arrays of rows.
|
|
24
|
+
*/
|
|
25
|
+
type?: string
|
|
26
|
+
/** Drawn by the resolved control (P5). */
|
|
27
|
+
label?: JSX.Element
|
|
28
|
+
/** Helper text (P5). */
|
|
29
|
+
description?: JSX.Element
|
|
30
|
+
/** Error text is drawn from the field's own state, not from here. */
|
|
31
|
+
placeholder?: string
|
|
32
|
+
/** Marks the control required (the label's asterisk); validation is the schema's. */
|
|
33
|
+
required?: boolean
|
|
34
|
+
disabled?: boolean
|
|
35
|
+
/** Choices for the choice controls. */
|
|
36
|
+
options?: readonly { value: string; label?: string }[]
|
|
37
|
+
/** Extra props handed to the resolved control. */
|
|
38
|
+
props?: Record<string, unknown>
|
|
39
|
+
/** Rendered instead of the `type` mapping. */
|
|
40
|
+
component?: (props: FieldControlProps) => JSX.Element
|
|
41
|
+
/**
|
|
42
|
+
* Other field paths whose changes revalidate this field (compiled onto
|
|
43
|
+
* TanStack's listeners with the `dynamic` cause). Visibility itself is
|
|
44
|
+
* reactive without this — `when` reads the form's values.
|
|
45
|
+
*/
|
|
46
|
+
dependsOn?: readonly string[]
|
|
47
|
+
/**
|
|
48
|
+
* Returning `false` hides the field. Unless `pruneHidden` is off, a hidden
|
|
49
|
+
* field is also dropped from the submitted document — submitting a value
|
|
50
|
+
* for a setting that is not shown is a lie to the backend.
|
|
51
|
+
*
|
|
52
|
+
* `scope` is the form's values for a top-level entry and the row for a
|
|
53
|
+
* repeater's row field; `values` is always the whole form.
|
|
54
|
+
*/
|
|
55
|
+
when?: (scope: TScope, values: TValues) => boolean
|
|
56
|
+
/**
|
|
57
|
+
* For `type: 'repeater'`: the row's fields, keyed by subfield name. Their
|
|
58
|
+
* `when` receives the row first.
|
|
59
|
+
*/
|
|
60
|
+
// biome-ignore lint/suspicious/noExplicitAny: row fields erase their scope type.
|
|
61
|
+
fields?: Record<string, FieldConfig<TValues, any>>
|
|
62
|
+
/** For `type: 'repeater'`: the ceiling on rows. */
|
|
63
|
+
maxItems?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The binding a custom `component` receives for one field. */
|
|
67
|
+
export interface FieldControlProps {
|
|
68
|
+
/** The field's path in the form. */
|
|
69
|
+
name: string
|
|
70
|
+
value: unknown
|
|
71
|
+
/** Commits the value and runs the `onChange` validators. */
|
|
72
|
+
onChange: (value: unknown) => void
|
|
73
|
+
/** Marks the field touched and runs the `onBlur` validators. */
|
|
74
|
+
onBlur: () => void
|
|
75
|
+
/** The field's error, joined to one string — the `Field` contract. */
|
|
76
|
+
error?: string
|
|
77
|
+
disabled?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The slice of a field-map entry the engine reads for visibility. */
|
|
81
|
+
interface VisibilityEntry {
|
|
82
|
+
// biome-ignore lint/suspicious/noExplicitAny: the erased mirror of FieldConfig.
|
|
83
|
+
when?: (scope: any, values: any) => boolean
|
|
84
|
+
fields?: Record<string, VisibilityEntry>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every path the field map currently hides.
|
|
89
|
+
*
|
|
90
|
+
* Top-level entries are called `when(values, values)`. A repeater's row fields
|
|
91
|
+
* are expanded per row — `when(row, values)` — and reported as
|
|
92
|
+
* `items[1].url`; a hidden repeater hides its rows with it, so they are not
|
|
93
|
+
* expanded.
|
|
94
|
+
*/
|
|
95
|
+
export function hiddenPaths<T>(
|
|
96
|
+
values: T,
|
|
97
|
+
ui: Record<string, VisibilityEntry>,
|
|
98
|
+
): string[] {
|
|
99
|
+
const hidden: string[] = []
|
|
100
|
+
const walk = (
|
|
101
|
+
scope: unknown,
|
|
102
|
+
entries: Record<string, VisibilityEntry>,
|
|
103
|
+
prefix: string,
|
|
104
|
+
) => {
|
|
105
|
+
for (const [key, config] of Object.entries(entries)) {
|
|
106
|
+
const path = prefix ? `${prefix}.${key}` : key
|
|
107
|
+
if (config.when && !config.when(scope, values)) {
|
|
108
|
+
hidden.push(path)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (!config.fields) continue
|
|
112
|
+
const rows = readPath(scope, key)
|
|
113
|
+
if (!Array.isArray(rows)) continue
|
|
114
|
+
rows.forEach((row, index) => {
|
|
115
|
+
walk(row, config.fields ?? {}, `${path}[${index}]`)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
walk(values, ui, '')
|
|
120
|
+
return hidden
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Drop every hidden field from `values`. Paths may nest (`smtp.host`) and,
|
|
125
|
+
* inside a repeater, address a row field (`items[1].url`) — the field leaves
|
|
126
|
+
* its row, the row stays. The result is a copy, never a mutation of the
|
|
127
|
+
* form's state.
|
|
128
|
+
*/
|
|
129
|
+
export function pruneHiddenValues<T extends object>(
|
|
130
|
+
values: T,
|
|
131
|
+
ui: Record<string, VisibilityEntry>,
|
|
132
|
+
): T {
|
|
133
|
+
let pruned: T = values
|
|
134
|
+
for (const path of hiddenPaths(values, ui)) {
|
|
135
|
+
pruned = deletePath(pruned, path) as T
|
|
136
|
+
}
|
|
137
|
+
return pruned
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** True when `candidate` is the hidden path itself or lives under it. */
|
|
141
|
+
export function isUnderHidden(
|
|
142
|
+
candidate: string,
|
|
143
|
+
hidden: readonly string[],
|
|
144
|
+
): boolean {
|
|
145
|
+
return hidden.some(
|
|
146
|
+
(path) =>
|
|
147
|
+
candidate === path ||
|
|
148
|
+
candidate.startsWith(`${path}.`) ||
|
|
149
|
+
candidate.startsWith(`${path}[`),
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function splitPath(path: string): string[] {
|
|
154
|
+
return path.replace(/\[/g, '.').replace(/\]/g, '').split('.').filter(Boolean)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readPath(root: unknown, path: string): unknown {
|
|
158
|
+
let node = root
|
|
159
|
+
for (const key of splitPath(path)) {
|
|
160
|
+
if (node === null || typeof node !== 'object') return undefined
|
|
161
|
+
node = (node as Record<string, unknown>)[key]
|
|
162
|
+
}
|
|
163
|
+
return node
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function deletePath(root: unknown, path: string): unknown {
|
|
167
|
+
const keys = splitPath(path)
|
|
168
|
+
if (keys.length === 0) return root
|
|
169
|
+
const [head, ...rest] = keys as [string, ...string[]]
|
|
170
|
+
if (root === null || typeof root !== 'object') return root
|
|
171
|
+
if (Array.isArray(root)) {
|
|
172
|
+
// A static field-map path never ends on a position: rows are not removed.
|
|
173
|
+
if (rest.length === 0) return root
|
|
174
|
+
const copy = [...(root as unknown[])]
|
|
175
|
+
copy[Number(head)] = deletePath(copy[Number(head)], rest.join('.'))
|
|
176
|
+
return copy
|
|
177
|
+
}
|
|
178
|
+
const copy: Record<string, unknown> = { ...(root as Record<string, unknown>) }
|
|
179
|
+
if (rest.length === 0) {
|
|
180
|
+
delete copy[head]
|
|
181
|
+
return copy
|
|
182
|
+
}
|
|
183
|
+
const child = (root as Record<string, unknown>)[head]
|
|
184
|
+
if (child === undefined) return root
|
|
185
|
+
copy[head] = deletePath(child, rest.join('.'))
|
|
186
|
+
return copy
|
|
187
|
+
}
|