@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,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createForm,
|
|
3
|
+
type StandardSchemaV1,
|
|
4
|
+
standardSchemaValidators,
|
|
5
|
+
} from '@tanstack/solid-form'
|
|
6
|
+
import { type Accessor, type JSX, splitProps } from 'solid-js'
|
|
7
|
+
import {
|
|
8
|
+
type FieldConfig,
|
|
9
|
+
hiddenPaths,
|
|
10
|
+
isUnderHidden,
|
|
11
|
+
pruneHiddenValues,
|
|
12
|
+
} from './config'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The form surface: the kit's answer to "how does a page submit".
|
|
16
|
+
*
|
|
17
|
+
* TanStack Form stays the engine — validators, field state, reset. What this
|
|
18
|
+
* adds is the three things every page was writing by hand: a submit lifecycle
|
|
19
|
+
* the button can read, a `<form>` that never lets the browser's native
|
|
20
|
+
* validation paint first, and one place that turns a field's error list into the
|
|
21
|
+
* single `error` string the kit's `Field` contract takes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface AppFormOptions<T extends object> {
|
|
25
|
+
defaultValues: T
|
|
26
|
+
/**
|
|
27
|
+
* Runs on submit. While the returned promise is pending the form reports
|
|
28
|
+
* `submitting()`, which is what a submit button's `loading` reads.
|
|
29
|
+
*/
|
|
30
|
+
onSubmit: (value: T, api: { reset: () => void }) => void | Promise<void>
|
|
31
|
+
/**
|
|
32
|
+
* A Standard Schema (Zod, Valibot, Ark) — the document, validated on change
|
|
33
|
+
* and on submit, with issues mapped onto fields. The schema must validate
|
|
34
|
+
* synchronously; an async one belongs in the submit handler.
|
|
35
|
+
*/
|
|
36
|
+
schema?: StandardSchemaV1<T, T>
|
|
37
|
+
/**
|
|
38
|
+
* The field map: how each field renders, keyed by static path (`smtp.host`,
|
|
39
|
+
* never `items[1]` — a repeater's row fields go under its `fields`). The
|
|
40
|
+
* engine reads `when` (for pruning and for dropping issues on hidden paths)
|
|
41
|
+
* and `dependsOn` (for revalidation); the rest is renderer vocabulary that
|
|
42
|
+
* the `ConfigForm` organism consumes from the same object.
|
|
43
|
+
*/
|
|
44
|
+
ui?: Record<string, FieldConfig<T>>
|
|
45
|
+
/**
|
|
46
|
+
* Drop fields hidden by `when` from the submitted document.
|
|
47
|
+
* @default true
|
|
48
|
+
*/
|
|
49
|
+
pruneHidden?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The `error` prop of a field, from TanStack's error list. */
|
|
53
|
+
export function fieldError(field: {
|
|
54
|
+
state: { meta: { errors: unknown[] } }
|
|
55
|
+
}): string | undefined {
|
|
56
|
+
const { errors } = field.state.meta
|
|
57
|
+
if (errors.length === 0) return undefined
|
|
58
|
+
const messages = errors.map((error) =>
|
|
59
|
+
typeof error === 'string'
|
|
60
|
+
? error
|
|
61
|
+
: error && typeof error === 'object' && 'message' in error
|
|
62
|
+
? String((error as { message: unknown }).message)
|
|
63
|
+
: String(error),
|
|
64
|
+
)
|
|
65
|
+
// A field usually declares the same message for `onChange` and `onSubmit`
|
|
66
|
+
// (validate as you type, and again on the way out). TanStack keeps one entry
|
|
67
|
+
// per validator, so the naive join prints the sentence twice.
|
|
68
|
+
return [...new Set(messages)].join(' ')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Throws on a field-map key that names an array position (`items[1]`). Keys
|
|
73
|
+
* are static paths; positions only arise when a repeater expands its rows.
|
|
74
|
+
*/
|
|
75
|
+
function assertStaticPaths(
|
|
76
|
+
ui: Record<string, { fields?: Record<string, unknown> }>,
|
|
77
|
+
): void {
|
|
78
|
+
for (const [key, config] of Object.entries(ui)) {
|
|
79
|
+
if (key.includes('['))
|
|
80
|
+
throw new Error(
|
|
81
|
+
`newui form: field-map key "${key}" must be a static path — declare row fields under a repeater's \`fields\` instead of indexing.`,
|
|
82
|
+
)
|
|
83
|
+
if (config.fields)
|
|
84
|
+
assertStaticPaths(
|
|
85
|
+
config.fields as Record<string, { fields?: Record<string, unknown> }>,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createAppForm<T extends object>(options: AppFormOptions<T>) {
|
|
91
|
+
const ui = options.ui ?? {}
|
|
92
|
+
assertStaticPaths(ui)
|
|
93
|
+
const prune = options.pruneHidden !== false
|
|
94
|
+
const hasDeps = Object.values(ui).some((config) => config.dependsOn?.length)
|
|
95
|
+
|
|
96
|
+
/** The document the backend should see: hidden fields are not part of it. */
|
|
97
|
+
const submitted = (values: T): T =>
|
|
98
|
+
prune ? pruneHiddenValues(values, ui) : values
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The schema validates the document that would be submitted, and issues on
|
|
102
|
+
* hidden paths are dropped: a field the user cannot see must not block the
|
|
103
|
+
* submit, and a hidden setting is not required of the document.
|
|
104
|
+
*/
|
|
105
|
+
const schema = options.schema
|
|
106
|
+
const schemaValidator = schema
|
|
107
|
+
? ({ value }: { value: unknown }) => {
|
|
108
|
+
const values = value as T
|
|
109
|
+
const issues = standardSchemaValidators.validate(
|
|
110
|
+
{ value: submitted(values), validationSource: 'form' },
|
|
111
|
+
schema,
|
|
112
|
+
)
|
|
113
|
+
if (!issues?.fields) return issues
|
|
114
|
+
const hidden = hiddenPaths(values, ui)
|
|
115
|
+
if (hidden.length === 0) return issues
|
|
116
|
+
// The adapter reports every issue twice — per field and form-wide —
|
|
117
|
+
// so both maps are filtered, or a hidden issue still blocks submit.
|
|
118
|
+
const visible = (map: Record<string, unknown>) =>
|
|
119
|
+
Object.fromEntries(
|
|
120
|
+
Object.entries(map).filter(
|
|
121
|
+
([path]) => !isUnderHidden(path, hidden),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
const fields = visible(issues.fields)
|
|
125
|
+
if (Object.keys(fields).length === 0) return undefined
|
|
126
|
+
return {
|
|
127
|
+
...issues,
|
|
128
|
+
form:
|
|
129
|
+
issues.form && typeof issues.form === 'object'
|
|
130
|
+
? visible(issues.form as Record<string, unknown>)
|
|
131
|
+
: issues.form,
|
|
132
|
+
fields,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
: undefined
|
|
136
|
+
|
|
137
|
+
const form = createForm(() => ({
|
|
138
|
+
defaultValues: options.defaultValues,
|
|
139
|
+
validators: schemaValidator
|
|
140
|
+
? { onChange: schemaValidator, onSubmit: schemaValidator }
|
|
141
|
+
: undefined,
|
|
142
|
+
// `dependsOn` compiles onto TanStack's listeners: a dependency's change
|
|
143
|
+
// revalidates the dependent field with the `dynamic` cause. Visibility
|
|
144
|
+
// itself needs no wiring — `when` reads the form's reactive values.
|
|
145
|
+
listeners: hasDeps
|
|
146
|
+
? {
|
|
147
|
+
onChange: ({ formApi, fieldApi }) => {
|
|
148
|
+
for (const [path, config] of Object.entries(ui)) {
|
|
149
|
+
if (config.dependsOn?.includes(String(fieldApi.name))) {
|
|
150
|
+
void formApi.validateField(path, 'dynamic')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
: undefined,
|
|
156
|
+
onSubmit: async ({ value, formApi }) => {
|
|
157
|
+
await options.onSubmit(submitted(value as T), {
|
|
158
|
+
reset: () => formApi.reset(),
|
|
159
|
+
})
|
|
160
|
+
},
|
|
161
|
+
}))
|
|
162
|
+
const submitting = form.useSelector((state) => state.isSubmitting)
|
|
163
|
+
return {
|
|
164
|
+
form,
|
|
165
|
+
/** True while the submit handler's promise is pending. */
|
|
166
|
+
submitting: submitting as Accessor<boolean>,
|
|
167
|
+
/** The field map, resolved. Pass it to `ConfigForm`'s `fields`. */
|
|
168
|
+
ui,
|
|
169
|
+
/**
|
|
170
|
+
* The document the backend sees for these values: hidden fields pruned
|
|
171
|
+
* unless `pruneHidden` is off. Submit sends it; so does `createAutoSave`.
|
|
172
|
+
*/
|
|
173
|
+
document: submitted,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The `<form>` element, wired.
|
|
179
|
+
*
|
|
180
|
+
* `noValidate` because the kit's own field messages are the error UI; the native
|
|
181
|
+
* bubble would appear first, say less, and disappear on the next keystroke.
|
|
182
|
+
* `preventDefault` because this is a single-page app: a real submit would
|
|
183
|
+
* navigate.
|
|
184
|
+
*/
|
|
185
|
+
export function Form(props: {
|
|
186
|
+
form: { handleSubmit: () => unknown }
|
|
187
|
+
children: JSX.Element
|
|
188
|
+
class?: string
|
|
189
|
+
}): JSX.Element {
|
|
190
|
+
const [local, rest] = splitProps(props, ['form', 'children', 'class'])
|
|
191
|
+
return (
|
|
192
|
+
<form
|
|
193
|
+
{...rest}
|
|
194
|
+
noValidate
|
|
195
|
+
class={local.class}
|
|
196
|
+
onSubmit={(event) => {
|
|
197
|
+
event.preventDefault()
|
|
198
|
+
event.stopPropagation()
|
|
199
|
+
void local.form.handleSubmit()
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
{local.children}
|
|
203
|
+
</form>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { bytes, count, isEmail, usd, usdK } from './format'
|
|
3
|
+
|
|
4
|
+
describe('format', () => {
|
|
5
|
+
it('formats money, counts and sizes the way the pages did', () => {
|
|
6
|
+
expect(usd(128430)).toBe('$128,430')
|
|
7
|
+
expect(usd(0)).toBe('$0')
|
|
8
|
+
expect(usdK(42500)).toBe('$43k')
|
|
9
|
+
expect(usdK(400)).toBe('$0k')
|
|
10
|
+
expect(count(128430)).toBe('128,430')
|
|
11
|
+
expect(bytes(512)).toBe('512 B')
|
|
12
|
+
expect(bytes(2048)).toBe('2.0 KB')
|
|
13
|
+
expect(bytes(5 * 1024 * 1024)).toBe('5.0 MB')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('accepts ordinary addresses and rejects the rest', () => {
|
|
17
|
+
expect(isEmail('rhea@example.com')).toBe(true)
|
|
18
|
+
expect(isEmail('rhea@example')).toBe(false)
|
|
19
|
+
expect(isEmail('rhea kapoor@example.com')).toBe(false)
|
|
20
|
+
expect(isEmail('')).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
})
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting: money, counts, sizes, addresses.
|
|
3
|
+
*
|
|
4
|
+
* Built once at module scope — an `Intl.NumberFormat` per render is the kind of
|
|
5
|
+
* cost that never shows in a profile and always shows in a list. The examples
|
|
6
|
+
* had four near-identical USD copies; this is the one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const usdFormat = new Intl.NumberFormat('en-US', {
|
|
10
|
+
style: 'currency',
|
|
11
|
+
currency: 'USD',
|
|
12
|
+
maximumFractionDigits: 0,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/** Whole-dollar USD: `usd(128430)` → `$128,430`. */
|
|
16
|
+
export const usd = (value: number): string => usdFormat.format(value)
|
|
17
|
+
|
|
18
|
+
/** Compact axis form: `usdK(42500)` → `$43k`. */
|
|
19
|
+
export const usdK = (value: number): string => `$${Math.round(value / 1000)}k`
|
|
20
|
+
|
|
21
|
+
const countFormat = new Intl.NumberFormat('en-US')
|
|
22
|
+
|
|
23
|
+
/** Grouped integer: `count(128430)` → `128,430`. */
|
|
24
|
+
export const count = (value: number): string => countFormat.format(value)
|
|
25
|
+
|
|
26
|
+
/** Human file size: `bytes(2048)` → `2.0 KB`. */
|
|
27
|
+
export const bytes = (value: number): string =>
|
|
28
|
+
value < 1024
|
|
29
|
+
? `${value} B`
|
|
30
|
+
: value < 1024 * 1024
|
|
31
|
+
? `${(value / 1024).toFixed(1)} KB`
|
|
32
|
+
: `${(value / (1024 * 1024)).toFixed(1)} MB`
|
|
33
|
+
|
|
34
|
+
/** The one address check every example form shares. */
|
|
35
|
+
export const isEmail = (value: string): boolean =>
|
|
36
|
+
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './aggregate'
|
|
2
|
+
export * from './config-store'
|
|
3
|
+
export * from './csv-export'
|
|
4
|
+
export * from './dashboard'
|
|
5
|
+
export * from './download'
|
|
6
|
+
export * from './format'
|
|
7
|
+
export * from './select'
|
|
8
|
+
export * from './table-persist'
|
|
9
|
+
export * from './undo'
|
|
10
|
+
export * from './url-state'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
|
|
2
|
+
import { type JSX, onCleanup, untrack } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The query client an app boots with.
|
|
6
|
+
*
|
|
7
|
+
* The defaults are the kit's opinion, stated once: local data is not a
|
|
8
|
+
* dashboard (no refetch on window focus), a transient failure is worth one retry,
|
|
9
|
+
* and an error is data the page renders rather than an exception that tears the
|
|
10
|
+
* tree down. A page overrides any of them per query.
|
|
11
|
+
*/
|
|
12
|
+
export interface DataClientOptions {
|
|
13
|
+
/** How long a list is fresh. Default `30_000`. */
|
|
14
|
+
staleTime?: number
|
|
15
|
+
/** Retries after the first failure. Default `1`. */
|
|
16
|
+
retry?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createDataClient(options: DataClientOptions = {}): QueryClient {
|
|
20
|
+
return new QueryClient({
|
|
21
|
+
defaultOptions: {
|
|
22
|
+
queries: {
|
|
23
|
+
staleTime: options.staleTime ?? 30_000,
|
|
24
|
+
retry: options.retry ?? 1,
|
|
25
|
+
refetchOnWindowFocus: false,
|
|
26
|
+
throwOnError: false,
|
|
27
|
+
},
|
|
28
|
+
mutations: { retry: 0 },
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The provider a page wraps its workspace in.
|
|
35
|
+
*
|
|
36
|
+
* Pass `client` to share one (a workspace with several panes shares its cache);
|
|
37
|
+
* omit it and the provider owns a client and clears it on unmount, so a route
|
|
38
|
+
* change never leaves timers or caches behind.
|
|
39
|
+
*/
|
|
40
|
+
export function DataProvider(props: {
|
|
41
|
+
client?: QueryClient
|
|
42
|
+
options?: DataClientOptions
|
|
43
|
+
children: JSX.Element
|
|
44
|
+
}): JSX.Element {
|
|
45
|
+
// Chosen once, by design: swapping a provider's client mid-tree would orphan
|
|
46
|
+
// the cache every consumer is reading from.
|
|
47
|
+
const provided = untrack(() => props.client)
|
|
48
|
+
const client = provided ?? createDataClient(untrack(() => props.options))
|
|
49
|
+
if (!provided) onCleanup(() => client.clear())
|
|
50
|
+
return (
|
|
51
|
+
<QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
2
|
+
import type { QueryKey } from '@tanstack/solid-query'
|
|
3
|
+
import { createSignal, For, type JSX, untrack } from 'solid-js'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { DataProvider } from './client'
|
|
6
|
+
import {
|
|
7
|
+
type Collection,
|
|
8
|
+
createCollection,
|
|
9
|
+
createListQuery,
|
|
10
|
+
} from './collection'
|
|
11
|
+
import type { Repository } from './repository'
|
|
12
|
+
|
|
13
|
+
interface Row {
|
|
14
|
+
id: string
|
|
15
|
+
title: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Deferred<T> {
|
|
19
|
+
promise: Promise<T>
|
|
20
|
+
resolve: (value: T) => void
|
|
21
|
+
reject: (error: unknown) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function deferred<T>(): Deferred<T> {
|
|
25
|
+
let resolve!: (value: T) => void
|
|
26
|
+
let reject!: (error: unknown) => void
|
|
27
|
+
const promise = new Promise<T>((ok, fail) => {
|
|
28
|
+
resolve = ok
|
|
29
|
+
reject = fail
|
|
30
|
+
})
|
|
31
|
+
return { promise, resolve, reject }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeRepository(initial: Row[]) {
|
|
35
|
+
let rows = [...initial]
|
|
36
|
+
const repository: Repository<Row, { title: string }> = {
|
|
37
|
+
list: async () => [...rows],
|
|
38
|
+
create: async (input) => {
|
|
39
|
+
const row = { id: `R-${rows.length + 1}`, title: input.title }
|
|
40
|
+
rows = [row, ...rows]
|
|
41
|
+
return row
|
|
42
|
+
},
|
|
43
|
+
update: vi.fn(async (id, patch) => {
|
|
44
|
+
rows = rows.map((row) => (row.id === id ? { ...row, ...patch } : row))
|
|
45
|
+
return rows.find((row) => row.id === id) as Row
|
|
46
|
+
}),
|
|
47
|
+
remove: async (ids) => {
|
|
48
|
+
const gone = new Set(ids)
|
|
49
|
+
rows = rows.filter((row) => !gone.has(row.id))
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
return { repository, rows: () => rows }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Harness(props: {
|
|
56
|
+
repository: Repository<Row, { title: string }>
|
|
57
|
+
ready: (collection: Collection<Row, { title: string }>) => void
|
|
58
|
+
interval?: number | false
|
|
59
|
+
}): JSX.Element {
|
|
60
|
+
const collection = createCollection(() => ({
|
|
61
|
+
key: ['rows'],
|
|
62
|
+
repository: props.repository,
|
|
63
|
+
retry: false,
|
|
64
|
+
...(props.interval !== undefined
|
|
65
|
+
? { refetchInterval: props.interval }
|
|
66
|
+
: {}),
|
|
67
|
+
}))
|
|
68
|
+
untrack(() => props.ready(collection))
|
|
69
|
+
return (
|
|
70
|
+
<ul>
|
|
71
|
+
<For each={collection.query.rows()}>{(row) => <li>{row.title}</li>}</For>
|
|
72
|
+
</ul>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mount(
|
|
77
|
+
repository: Repository<Row, { title: string }>,
|
|
78
|
+
interval?: number | false,
|
|
79
|
+
) {
|
|
80
|
+
let collection!: Collection<Row, { title: string }>
|
|
81
|
+
render(() => (
|
|
82
|
+
<DataProvider>
|
|
83
|
+
<Harness
|
|
84
|
+
repository={repository}
|
|
85
|
+
interval={interval}
|
|
86
|
+
ready={(value) => (collection = value)}
|
|
87
|
+
/>
|
|
88
|
+
</DataProvider>
|
|
89
|
+
))
|
|
90
|
+
return () => collection
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('createCollection', () => {
|
|
94
|
+
it('loads the list and reports its pending and error states', async () => {
|
|
95
|
+
const failing = deferred<Row[]>()
|
|
96
|
+
const { repository } = makeRepository([{ id: '1', title: 'One' }])
|
|
97
|
+
const broken: Repository<Row, { title: string }> = {
|
|
98
|
+
...repository,
|
|
99
|
+
list: () => failing.promise,
|
|
100
|
+
}
|
|
101
|
+
const collection = mount(broken)
|
|
102
|
+
expect(collection().query.isPending()).toBe(true)
|
|
103
|
+
|
|
104
|
+
failing.reject(new Error('The service is down'))
|
|
105
|
+
await waitFor(() => expect(collection().query.isError()).toBe(true))
|
|
106
|
+
expect(collection().query.error()?.message).toBe('The service is down')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('patches optimistically and restores the list when the request fails', async () => {
|
|
110
|
+
const gate = deferred<Row>()
|
|
111
|
+
const { repository } = makeRepository([
|
|
112
|
+
{ id: '1', title: 'One' },
|
|
113
|
+
{ id: '2', title: 'Two' },
|
|
114
|
+
])
|
|
115
|
+
const gated: Repository<Row, { title: string }> = {
|
|
116
|
+
...repository,
|
|
117
|
+
update: (id, patch) => {
|
|
118
|
+
gate.promise.then(
|
|
119
|
+
() => repository.update(id, patch),
|
|
120
|
+
() => {},
|
|
121
|
+
)
|
|
122
|
+
return gate.promise
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
const collection = mount(gated)
|
|
126
|
+
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
|
127
|
+
|
|
128
|
+
collection().update.mutate({ id: '1', patch: { title: 'Changed' } })
|
|
129
|
+
// On screen before the request settles — that is the point of optimistic.
|
|
130
|
+
await waitFor(() => expect(screen.getByText('Changed')).toBeTruthy())
|
|
131
|
+
|
|
132
|
+
gate.reject(new Error('nope'))
|
|
133
|
+
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
|
134
|
+
expect(screen.queryByText('Changed')).toBeNull()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('creates through the repository and refreshes the list', async () => {
|
|
138
|
+
const { repository, rows } = makeRepository([{ id: '1', title: 'One' }])
|
|
139
|
+
const collection = mount(repository)
|
|
140
|
+
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
|
141
|
+
|
|
142
|
+
await collection().create.mutateAsync({ title: 'New' })
|
|
143
|
+
expect(rows()[0]?.title).toBe('New')
|
|
144
|
+
await waitFor(() => expect(screen.getByText('New')).toBeTruthy())
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('completes many at once and removes through the same seam', async () => {
|
|
148
|
+
const { repository } = makeRepository([
|
|
149
|
+
{ id: '1', title: 'One' },
|
|
150
|
+
{ id: '2', title: 'Two' },
|
|
151
|
+
])
|
|
152
|
+
const collection = mount(repository)
|
|
153
|
+
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
|
154
|
+
|
|
155
|
+
await collection().updateMany.mutateAsync({
|
|
156
|
+
ids: ['1', '2'],
|
|
157
|
+
patch: { title: 'Done' },
|
|
158
|
+
})
|
|
159
|
+
await waitFor(() => expect(screen.getAllByText('Done')).toHaveLength(2))
|
|
160
|
+
|
|
161
|
+
await collection().remove.mutateAsync({ ids: ['1', '2'] })
|
|
162
|
+
await waitFor(() => expect(screen.queryByText('Done')).toBeNull())
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('polls the list on the interval it is given', async () => {
|
|
166
|
+
const { repository } = makeRepository([{ id: '1', title: 'One' }])
|
|
167
|
+
const list = vi.fn(repository.list)
|
|
168
|
+
const polled: Repository<Row, { title: string }> = { ...repository, list }
|
|
169
|
+
mount(polled, 30)
|
|
170
|
+
await waitFor(() => expect(list).toHaveBeenCalledTimes(1))
|
|
171
|
+
|
|
172
|
+
// The explicit dial: focus-refetch stays off, the interval does the work.
|
|
173
|
+
await waitFor(
|
|
174
|
+
() => expect(list.mock.calls.length).toBeGreaterThanOrEqual(3),
|
|
175
|
+
{
|
|
176
|
+
timeout: 2000,
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('never polls when refetchInterval is false', async () => {
|
|
182
|
+
const { repository } = makeRepository([{ id: '1', title: 'One' }])
|
|
183
|
+
const list = vi.fn(repository.list)
|
|
184
|
+
const quiet: Repository<Row, { title: string }> = { ...repository, list }
|
|
185
|
+
mount(quiet, false)
|
|
186
|
+
await waitFor(() => expect(list).toHaveBeenCalledTimes(1))
|
|
187
|
+
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 120))
|
|
189
|
+
expect(list).toHaveBeenCalledTimes(1)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('createListQuery keepPreviousData', () => {
|
|
194
|
+
/** One deferred response per key tag, so a key change gates on demand. */
|
|
195
|
+
function keyedRepository(tag: () => string) {
|
|
196
|
+
const pending = new Map<string, Deferred<Row[]>>()
|
|
197
|
+
const repository: Repository<Row, never> = {
|
|
198
|
+
list: () => {
|
|
199
|
+
const gate = deferred<Row[]>()
|
|
200
|
+
pending.set(tag(), gate)
|
|
201
|
+
return gate.promise
|
|
202
|
+
},
|
|
203
|
+
create: async () => {
|
|
204
|
+
throw new Error('not used')
|
|
205
|
+
},
|
|
206
|
+
update: async () => {
|
|
207
|
+
throw new Error('not used')
|
|
208
|
+
},
|
|
209
|
+
remove: async () => {},
|
|
210
|
+
}
|
|
211
|
+
return { repository, pending }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ListHarness(props: {
|
|
215
|
+
queryKey: () => QueryKey
|
|
216
|
+
repository: Repository<Row, never>
|
|
217
|
+
keepPreviousData?: boolean
|
|
218
|
+
}): JSX.Element {
|
|
219
|
+
const list = createListQuery(() => ({
|
|
220
|
+
key: props.queryKey(),
|
|
221
|
+
repository: props.repository,
|
|
222
|
+
retry: false,
|
|
223
|
+
...(props.keepPreviousData ? { keepPreviousData: true } : {}),
|
|
224
|
+
}))
|
|
225
|
+
return (
|
|
226
|
+
<ul>
|
|
227
|
+
<For each={list.rows()}>{(row) => <li>{row.title}</li>}</For>
|
|
228
|
+
</ul>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mountList(keepPreviousData?: boolean) {
|
|
233
|
+
const [key, setKey] = createSignal<QueryKey>(['list', 'a'])
|
|
234
|
+
const { repository, pending } = keyedRepository(() =>
|
|
235
|
+
String(untrack(key)[1]),
|
|
236
|
+
)
|
|
237
|
+
render(() => (
|
|
238
|
+
<DataProvider>
|
|
239
|
+
<ListHarness
|
|
240
|
+
queryKey={key}
|
|
241
|
+
repository={repository}
|
|
242
|
+
keepPreviousData={keepPreviousData}
|
|
243
|
+
/>
|
|
244
|
+
</DataProvider>
|
|
245
|
+
))
|
|
246
|
+
return {
|
|
247
|
+
setKey: (tag: string) => setKey(['list', tag]),
|
|
248
|
+
pending,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it('holds the old page while the new key is in flight', async () => {
|
|
253
|
+
const { setKey, pending } = mountList(true)
|
|
254
|
+
await waitFor(() => {
|
|
255
|
+
pending.get('a')?.resolve([{ id: '1', title: 'One' }])
|
|
256
|
+
expect(screen.getByText('One')).toBeTruthy()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
setKey('b')
|
|
260
|
+
// The request is in flight; the table must not blank.
|
|
261
|
+
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
|
262
|
+
pending.get('b')?.resolve([{ id: '2', title: 'Two' }])
|
|
263
|
+
await waitFor(() => expect(screen.getByText('Two')).toBeTruthy())
|
|
264
|
+
expect(screen.queryByText('One')).toBeNull()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('without it the rows clear while the new key loads', async () => {
|
|
268
|
+
const { setKey, pending } = mountList(false)
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
pending.get('a')?.resolve([{ id: '1', title: 'One' }])
|
|
271
|
+
expect(screen.getByText('One')).toBeTruthy()
|
|
272
|
+
})
|
|
273
|
+
setKey('b')
|
|
274
|
+
await waitFor(() => expect(screen.queryByText('One')).toBeNull())
|
|
275
|
+
pending.get('b')?.resolve([{ id: '2', title: 'Two' }])
|
|
276
|
+
await waitFor(() => expect(screen.getByText('Two')).toBeTruthy())
|
|
277
|
+
})
|
|
278
|
+
})
|