@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.
Files changed (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +170 -0
  5. package/src/aggregate.test.ts +231 -0
  6. package/src/aggregate.ts +320 -0
  7. package/src/config-store.test.ts +157 -0
  8. package/src/config-store.ts +295 -0
  9. package/src/csv-export.test.ts +156 -0
  10. package/src/csv-export.ts +129 -0
  11. package/src/dashboard.test.tsx +285 -0
  12. package/src/dashboard.tsx +381 -0
  13. package/src/download.test.ts +14 -0
  14. package/src/download.ts +39 -0
  15. package/src/exports.test.ts +135 -0
  16. package/src/form/autosave.test.ts +186 -0
  17. package/src/form/autosave.ts +194 -0
  18. package/src/form/config.test.tsx +288 -0
  19. package/src/form/config.ts +187 -0
  20. package/src/form/form.test.tsx +102 -0
  21. package/src/form/form.tsx +205 -0
  22. package/src/form/index.ts +3 -0
  23. package/src/format.test.ts +22 -0
  24. package/src/format.ts +36 -0
  25. package/src/index.ts +10 -0
  26. package/src/query/client.tsx +53 -0
  27. package/src/query/collection.test.tsx +278 -0
  28. package/src/query/collection.ts +207 -0
  29. package/src/query/index.ts +4 -0
  30. package/src/query/repository.ts +30 -0
  31. package/src/query/server-table.test.ts +136 -0
  32. package/src/query/server-table.ts +181 -0
  33. package/src/resource/client.test.ts +173 -0
  34. package/src/resource/client.ts +307 -0
  35. package/src/resource/connect-realtime.test.ts +135 -0
  36. package/src/resource/connect-realtime.ts +121 -0
  37. package/src/resource/document.test.ts +151 -0
  38. package/src/resource/document.ts +198 -0
  39. package/src/resource/idb.test.ts +169 -0
  40. package/src/resource/idb.ts +167 -0
  41. package/src/resource/index.ts +77 -0
  42. package/src/resource/list.test.ts +150 -0
  43. package/src/resource/list.ts +230 -0
  44. package/src/resource/local.test.ts +111 -0
  45. package/src/resource/local.ts +155 -0
  46. package/src/resource/realtime.test.ts +331 -0
  47. package/src/resource/realtime.ts +287 -0
  48. package/src/resource/resource.test.ts +186 -0
  49. package/src/resource/resource.ts +213 -0
  50. package/src/resource/useCall.test.ts +60 -0
  51. package/src/resource/useCall.ts +74 -0
  52. package/src/resource/useDoc.test.ts +31 -0
  53. package/src/resource/useDoc.ts +23 -0
  54. package/src/resource/useDoctype.test.ts +104 -0
  55. package/src/resource/useDoctype.ts +128 -0
  56. package/src/resource/useList.test.ts +32 -0
  57. package/src/resource/useList.ts +22 -0
  58. package/src/resource/useNewDoc.test.ts +67 -0
  59. package/src/resource/useNewDoc.ts +78 -0
  60. package/src/select.test.ts +13 -0
  61. package/src/select.ts +19 -0
  62. package/src/table-persist.test.ts +258 -0
  63. package/src/table-persist.ts +371 -0
  64. package/src/undo.test.ts +24 -0
  65. package/src/undo.ts +37 -0
  66. package/src/url-serialize.ts +93 -0
  67. package/src/url-state.test.ts +183 -0
  68. 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
+ })