@talelk/newui-data 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +65 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +170 -0
- 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/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +135 -0
- 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.test.tsx +102 -0
- package/src/form/form.tsx +205 -0
- package/src/form/index.ts +3 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +10 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +278 -0
- package/src/query/collection.ts +207 -0
- package/src/query/index.ts +4 -0
- package/src/query/repository.ts +30 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -0
- package/src/resource/document.test.ts +151 -0
- package/src/resource/document.ts +198 -0
- package/src/resource/idb.test.ts +169 -0
- package/src/resource/idb.ts +167 -0
- package/src/resource/index.ts +77 -0
- package/src/resource/list.test.ts +150 -0
- package/src/resource/list.ts +230 -0
- package/src/resource/local.test.ts +111 -0
- package/src/resource/local.ts +155 -0
- package/src/resource/realtime.test.ts +331 -0
- package/src/resource/realtime.ts +287 -0
- package/src/resource/resource.test.ts +186 -0
- package/src/resource/resource.ts +213 -0
- package/src/resource/useCall.test.ts +60 -0
- package/src/resource/useCall.ts +74 -0
- package/src/resource/useDoc.test.ts +31 -0
- package/src/resource/useDoc.ts +23 -0
- package/src/resource/useDoctype.test.ts +104 -0
- package/src/resource/useDoctype.ts +128 -0
- package/src/resource/useList.test.ts +32 -0
- package/src/resource/useList.ts +22 -0
- package/src/resource/useNewDoc.test.ts +67 -0
- package/src/resource/useNewDoc.ts +78 -0
- package/src/select.test.ts +13 -0
- package/src/select.ts +19 -0
- package/src/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -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,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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { untrack } from 'solid-js'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { createAppForm, Form, fieldError } from './form'
|
|
6
|
+
|
|
7
|
+
function Harness(props: {
|
|
8
|
+
onSubmit: (value: { name: string }) => Promise<void>
|
|
9
|
+
}): import('solid-js').JSX.Element {
|
|
10
|
+
const { form, submitting } = createAppForm({
|
|
11
|
+
defaultValues: { name: '' },
|
|
12
|
+
onSubmit: untrack(() => props.onSubmit),
|
|
13
|
+
})
|
|
14
|
+
return (
|
|
15
|
+
<Form form={form} class="flex flex-col gap-2">
|
|
16
|
+
<form.Field
|
|
17
|
+
name="name"
|
|
18
|
+
validators={{
|
|
19
|
+
onChange: ({ value }) =>
|
|
20
|
+
value.length < 3 ? 'Use at least 3 characters.' : undefined,
|
|
21
|
+
onSubmit: ({ value }) =>
|
|
22
|
+
value.length < 3 ? 'Use at least 3 characters.' : undefined,
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
{(field) => (
|
|
26
|
+
<>
|
|
27
|
+
<input
|
|
28
|
+
aria-label="Name"
|
|
29
|
+
value={field().state.value}
|
|
30
|
+
onInput={(event) =>
|
|
31
|
+
field().handleChange(event.currentTarget.value)
|
|
32
|
+
}
|
|
33
|
+
/>
|
|
34
|
+
<p role="alert">{fieldError(field())}</p>
|
|
35
|
+
<button type="submit" disabled={submitting()}>
|
|
36
|
+
Save
|
|
37
|
+
</button>
|
|
38
|
+
</>
|
|
39
|
+
)}
|
|
40
|
+
</form.Field>
|
|
41
|
+
</Form>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('createAppForm', () => {
|
|
46
|
+
it('submits a valid value and blocks an invalid one', async () => {
|
|
47
|
+
const onSubmit = vi.fn(async (_value: { name: string }) => {})
|
|
48
|
+
render(() => <Harness onSubmit={onSubmit} />)
|
|
49
|
+
const field = screen.getByRole('textbox', { name: 'Name' })
|
|
50
|
+
|
|
51
|
+
await userEvent.type(field, 'ab')
|
|
52
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
53
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
54
|
+
await waitFor(() =>
|
|
55
|
+
expect(screen.getByRole('alert').textContent).toContain(
|
|
56
|
+
'at least 3 characters',
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
await userEvent.type(field, 'c')
|
|
61
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
62
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
63
|
+
expect(onSubmit.mock.calls[0]?.[0]).toEqual({ name: 'abc' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('reports submitting while the handler is pending, and disables the button', async () => {
|
|
67
|
+
let finish!: () => void
|
|
68
|
+
const onSubmit = vi.fn(
|
|
69
|
+
() =>
|
|
70
|
+
new Promise<void>((resolve) => {
|
|
71
|
+
finish = resolve
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
render(() => <Harness onSubmit={onSubmit} />)
|
|
75
|
+
await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'abc')
|
|
76
|
+
const button = screen.getByRole('button', { name: 'Save' })
|
|
77
|
+
await userEvent.click(button)
|
|
78
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
79
|
+
await waitFor(() => expect(button).toBeDisabled())
|
|
80
|
+
|
|
81
|
+
finish()
|
|
82
|
+
await waitFor(() => expect(button).not.toBeDisabled())
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('fieldError', () => {
|
|
87
|
+
it('turns TanStack’s error list into the single string a field takes', () => {
|
|
88
|
+
expect(fieldError({ state: { meta: { errors: [] } } })).toBeUndefined()
|
|
89
|
+
expect(fieldError({ state: { meta: { errors: ['Required.'] } } })).toBe(
|
|
90
|
+
'Required.',
|
|
91
|
+
)
|
|
92
|
+
expect(
|
|
93
|
+
fieldError({
|
|
94
|
+
state: { meta: { errors: [{ message: 'Required.' }, 'Too short.'] } },
|
|
95
|
+
}),
|
|
96
|
+
).toBe('Required. Too short.')
|
|
97
|
+
// The same message from `onChange` and `onSubmit` is one sentence, not two.
|
|
98
|
+
expect(
|
|
99
|
+
fieldError({ state: { meta: { errors: ['Required.', 'Required.'] } } }),
|
|
100
|
+
).toBe('Required.')
|
|
101
|
+
})
|
|
102
|
+
})
|