@talelk/newui-data 0.0.0-stage → 1.0.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 +54 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +116 -0
- package/src/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +105 -0
- package/src/form/form.test.tsx +102 -0
- package/src/form/form.tsx +85 -0
- package/src/form/index.ts +1 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +4 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +151 -0
- package/src/query/collection.ts +192 -0
- package/src/query/index.ts +3 -0
- package/src/query/repository.ts +30 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -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 +70 -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/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createForm } from '@tanstack/solid-form'
|
|
2
|
+
import { type Accessor, type JSX, splitProps } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The form surface: the kit's answer to "how does a page submit".
|
|
6
|
+
*
|
|
7
|
+
* TanStack Form stays the engine — validators, field state, reset. What this
|
|
8
|
+
* adds is the three things every page was writing by hand: a submit lifecycle
|
|
9
|
+
* the button can read, a `<form>` that never lets the browser's native
|
|
10
|
+
* validation paint first, and one place that turns a field's error list into the
|
|
11
|
+
* single `error` string the kit's `Field` contract takes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface AppFormOptions<T extends object> {
|
|
15
|
+
defaultValues: T
|
|
16
|
+
/**
|
|
17
|
+
* Runs on submit. While the returned promise is pending the form reports
|
|
18
|
+
* `submitting()`, which is what a submit button's `loading` reads.
|
|
19
|
+
*/
|
|
20
|
+
onSubmit: (value: T, api: { reset: () => void }) => void | Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The `error` prop of a field, from TanStack's error list. */
|
|
24
|
+
export function fieldError(field: {
|
|
25
|
+
state: { meta: { errors: unknown[] } }
|
|
26
|
+
}): string | undefined {
|
|
27
|
+
const { errors } = field.state.meta
|
|
28
|
+
if (errors.length === 0) return undefined
|
|
29
|
+
const messages = errors.map((error) =>
|
|
30
|
+
typeof error === 'string'
|
|
31
|
+
? error
|
|
32
|
+
: error && typeof error === 'object' && 'message' in error
|
|
33
|
+
? String((error as { message: unknown }).message)
|
|
34
|
+
: String(error),
|
|
35
|
+
)
|
|
36
|
+
// A field usually declares the same message for `onChange` and `onSubmit`
|
|
37
|
+
// (validate as you type, and again on the way out). TanStack keeps one entry
|
|
38
|
+
// per validator, so the naive join prints the sentence twice.
|
|
39
|
+
return [...new Set(messages)].join(' ')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createAppForm<T extends object>(options: AppFormOptions<T>) {
|
|
43
|
+
const form = createForm(() => ({
|
|
44
|
+
defaultValues: options.defaultValues,
|
|
45
|
+
onSubmit: async ({ value, formApi }) => {
|
|
46
|
+
await options.onSubmit(value as T, { reset: () => formApi.reset() })
|
|
47
|
+
},
|
|
48
|
+
}))
|
|
49
|
+
const submitting = form.useSelector((state) => state.isSubmitting)
|
|
50
|
+
return {
|
|
51
|
+
form,
|
|
52
|
+
/** True while the submit handler's promise is pending. */
|
|
53
|
+
submitting: submitting as Accessor<boolean>,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The `<form>` element, wired.
|
|
59
|
+
*
|
|
60
|
+
* `noValidate` because the kit's own field messages are the error UI; the native
|
|
61
|
+
* bubble would appear first, say less, and disappear on the next keystroke.
|
|
62
|
+
* `preventDefault` because this is a single-page app: a real submit would
|
|
63
|
+
* navigate.
|
|
64
|
+
*/
|
|
65
|
+
export function Form(props: {
|
|
66
|
+
form: { handleSubmit: () => unknown }
|
|
67
|
+
children: JSX.Element
|
|
68
|
+
class?: string
|
|
69
|
+
}): JSX.Element {
|
|
70
|
+
const [local, rest] = splitProps(props, ['form', 'children', 'class'])
|
|
71
|
+
return (
|
|
72
|
+
<form
|
|
73
|
+
{...rest}
|
|
74
|
+
noValidate
|
|
75
|
+
class={local.class}
|
|
76
|
+
onSubmit={(event) => {
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
event.stopPropagation()
|
|
79
|
+
void local.form.handleSubmit()
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{local.children}
|
|
83
|
+
</form>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './form'
|
|
@@ -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,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,151 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
2
|
+
import { For, type JSX, untrack } from 'solid-js'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { DataProvider } from './client'
|
|
5
|
+
import { type Collection, createCollection } from './collection'
|
|
6
|
+
import type { Repository } from './repository'
|
|
7
|
+
|
|
8
|
+
interface Row {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Deferred<T> {
|
|
14
|
+
promise: Promise<T>
|
|
15
|
+
resolve: (value: T) => void
|
|
16
|
+
reject: (error: unknown) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function deferred<T>(): Deferred<T> {
|
|
20
|
+
let resolve!: (value: T) => void
|
|
21
|
+
let reject!: (error: unknown) => void
|
|
22
|
+
const promise = new Promise<T>((ok, fail) => {
|
|
23
|
+
resolve = ok
|
|
24
|
+
reject = fail
|
|
25
|
+
})
|
|
26
|
+
return { promise, resolve, reject }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeRepository(initial: Row[]) {
|
|
30
|
+
let rows = [...initial]
|
|
31
|
+
const repository: Repository<Row, { title: string }> = {
|
|
32
|
+
list: async () => [...rows],
|
|
33
|
+
create: async (input) => {
|
|
34
|
+
const row = { id: `R-${rows.length + 1}`, title: input.title }
|
|
35
|
+
rows = [row, ...rows]
|
|
36
|
+
return row
|
|
37
|
+
},
|
|
38
|
+
update: vi.fn(async (id, patch) => {
|
|
39
|
+
rows = rows.map((row) => (row.id === id ? { ...row, ...patch } : row))
|
|
40
|
+
return rows.find((row) => row.id === id) as Row
|
|
41
|
+
}),
|
|
42
|
+
remove: async (ids) => {
|
|
43
|
+
const gone = new Set(ids)
|
|
44
|
+
rows = rows.filter((row) => !gone.has(row.id))
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
return { repository, rows: () => rows }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Harness(props: {
|
|
51
|
+
repository: Repository<Row, { title: string }>
|
|
52
|
+
ready: (collection: Collection<Row, { title: string }>) => void
|
|
53
|
+
}): JSX.Element {
|
|
54
|
+
const collection = createCollection(() => ({
|
|
55
|
+
key: ['rows'],
|
|
56
|
+
repository: props.repository,
|
|
57
|
+
retry: false,
|
|
58
|
+
}))
|
|
59
|
+
untrack(() => props.ready(collection))
|
|
60
|
+
return (
|
|
61
|
+
<ul>
|
|
62
|
+
<For each={collection.query.rows()}>{(row) => <li>{row.title}</li>}</For>
|
|
63
|
+
</ul>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mount(repository: Repository<Row, { title: string }>) {
|
|
68
|
+
let collection!: Collection<Row, { title: string }>
|
|
69
|
+
render(() => (
|
|
70
|
+
<DataProvider>
|
|
71
|
+
<Harness
|
|
72
|
+
repository={repository}
|
|
73
|
+
ready={(value) => (collection = value)}
|
|
74
|
+
/>
|
|
75
|
+
</DataProvider>
|
|
76
|
+
))
|
|
77
|
+
return () => collection
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('createCollection', () => {
|
|
81
|
+
it('loads the list and reports its pending and error states', async () => {
|
|
82
|
+
const failing = deferred<Row[]>()
|
|
83
|
+
const { repository } = makeRepository([{ id: '1', title: 'One' }])
|
|
84
|
+
const broken: Repository<Row, { title: string }> = {
|
|
85
|
+
...repository,
|
|
86
|
+
list: () => failing.promise,
|
|
87
|
+
}
|
|
88
|
+
const collection = mount(broken)
|
|
89
|
+
expect(collection().query.isPending()).toBe(true)
|
|
90
|
+
|
|
91
|
+
failing.reject(new Error('The service is down'))
|
|
92
|
+
await waitFor(() => expect(collection().query.isError()).toBe(true))
|
|
93
|
+
expect(collection().query.error()?.message).toBe('The service is down')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('patches optimistically and restores the list when the request fails', async () => {
|
|
97
|
+
const gate = deferred<Row>()
|
|
98
|
+
const { repository } = makeRepository([
|
|
99
|
+
{ id: '1', title: 'One' },
|
|
100
|
+
{ id: '2', title: 'Two' },
|
|
101
|
+
])
|
|
102
|
+
const gated: Repository<Row, { title: string }> = {
|
|
103
|
+
...repository,
|
|
104
|
+
update: (id, patch) => {
|
|
105
|
+
gate.promise.then(
|
|
106
|
+
() => repository.update(id, patch),
|
|
107
|
+
() => {},
|
|
108
|
+
)
|
|
109
|
+
return gate.promise
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
const collection = mount(gated)
|
|
113
|
+
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
|
114
|
+
|
|
115
|
+
collection().update.mutate({ id: '1', patch: { title: 'Changed' } })
|
|
116
|
+
// On screen before the request settles — that is the point of optimistic.
|
|
117
|
+
await waitFor(() => expect(screen.getByText('Changed')).toBeTruthy())
|
|
118
|
+
|
|
119
|
+
gate.reject(new Error('nope'))
|
|
120
|
+
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
|
121
|
+
expect(screen.queryByText('Changed')).toBeNull()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('creates through the repository and refreshes the list', async () => {
|
|
125
|
+
const { repository, rows } = makeRepository([{ id: '1', title: 'One' }])
|
|
126
|
+
const collection = mount(repository)
|
|
127
|
+
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
|
128
|
+
|
|
129
|
+
await collection().create.mutateAsync({ title: 'New' })
|
|
130
|
+
expect(rows()[0]?.title).toBe('New')
|
|
131
|
+
await waitFor(() => expect(screen.getByText('New')).toBeTruthy())
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('completes many at once and removes through the same seam', async () => {
|
|
135
|
+
const { repository } = makeRepository([
|
|
136
|
+
{ id: '1', title: 'One' },
|
|
137
|
+
{ id: '2', title: 'Two' },
|
|
138
|
+
])
|
|
139
|
+
const collection = mount(repository)
|
|
140
|
+
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
|
141
|
+
|
|
142
|
+
await collection().updateMany.mutateAsync({
|
|
143
|
+
ids: ['1', '2'],
|
|
144
|
+
patch: { title: 'Done' },
|
|
145
|
+
})
|
|
146
|
+
await waitFor(() => expect(screen.getAllByText('Done')).toHaveLength(2))
|
|
147
|
+
|
|
148
|
+
await collection().remove.mutateAsync({ ids: ['1', '2'] })
|
|
149
|
+
await waitFor(() => expect(screen.queryByText('Done')).toBeNull())
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMutation,
|
|
3
|
+
createQuery,
|
|
4
|
+
type QueryKey,
|
|
5
|
+
useQueryClient,
|
|
6
|
+
} from '@tanstack/solid-query'
|
|
7
|
+
import type { Accessor } from 'solid-js'
|
|
8
|
+
import type { NewRecord, Repository } from './repository'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A list and the four things a page does to it.
|
|
12
|
+
*
|
|
13
|
+
* `createCollection` is the kit's answer to "how does a page hold its records":
|
|
14
|
+
* one query for the list, and mutations that are **optimistic and reversible** —
|
|
15
|
+
* a patch lands on screen immediately, and a failed request puts the previous
|
|
16
|
+
* list back. The page renders `isPending` / `error` and never touches the cache.
|
|
17
|
+
*
|
|
18
|
+
* The list is keyed by `key`, so two panes reading the same collection share one
|
|
19
|
+
* request and one update. The repository stays the only place that knows where
|
|
20
|
+
* the records come from.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface ListQuery<T> {
|
|
24
|
+
/** The records; `[]` while the first request is in flight. */
|
|
25
|
+
rows: Accessor<T[]>
|
|
26
|
+
/** The first request has not settled. */
|
|
27
|
+
isPending: Accessor<boolean>
|
|
28
|
+
/** A request is in flight, first or refetch. */
|
|
29
|
+
isFetching: Accessor<boolean>
|
|
30
|
+
/** The last request failed; `error` says why. */
|
|
31
|
+
isError: Accessor<boolean>
|
|
32
|
+
error: Accessor<Error | undefined>
|
|
33
|
+
/** Re-run the list request (the "Try again" button). */
|
|
34
|
+
refetch: () => Promise<unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CollectionMutation<Input, Result> {
|
|
38
|
+
mutate: (input: Input) => void
|
|
39
|
+
/** Resolves when the mutation *and* the list refresh have settled. */
|
|
40
|
+
mutateAsync: (input: Input) => Promise<Result>
|
|
41
|
+
isPending: Accessor<boolean>
|
|
42
|
+
error: Accessor<Error | undefined>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Collection<T extends { id: string }, New = NewRecord<T>> {
|
|
46
|
+
query: ListQuery<T>
|
|
47
|
+
create: CollectionMutation<New, T>
|
|
48
|
+
update: CollectionMutation<{ id: string; patch: Partial<T> }, T>
|
|
49
|
+
/** Patch many at once — the bulk action. */
|
|
50
|
+
updateMany: CollectionMutation<{ ids: string[]; patch: Partial<T> }, T[]>
|
|
51
|
+
remove: CollectionMutation<{ ids: string[] }, void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CollectionOptions<
|
|
55
|
+
T extends { id: string },
|
|
56
|
+
New = NewRecord<T>,
|
|
57
|
+
> {
|
|
58
|
+
/** The cache identity. Same key = same collection. */
|
|
59
|
+
key: QueryKey
|
|
60
|
+
repository: Repository<T, New>
|
|
61
|
+
/** The list's freshness. Defaults to the client's. */
|
|
62
|
+
staleTime?: number
|
|
63
|
+
/** Retries after the first failure. Defaults to the client's. */
|
|
64
|
+
retry?: number | false
|
|
65
|
+
/** Every mutation reports here first, so a page can word one notice. */
|
|
66
|
+
onError?: (error: Error) => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asError(value: unknown): Error | undefined {
|
|
70
|
+
if (value === null || value === undefined) return undefined
|
|
71
|
+
return value instanceof Error ? value : new Error(String(value))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createListQuery<T extends { id: string }>(
|
|
75
|
+
options: () => CollectionOptions<T, never>,
|
|
76
|
+
): ListQuery<T> {
|
|
77
|
+
const query = createQuery(() => ({
|
|
78
|
+
queryKey: options().key,
|
|
79
|
+
queryFn: ({ signal }) => options().repository.list({ signal }),
|
|
80
|
+
staleTime: options().staleTime,
|
|
81
|
+
retry: options().retry,
|
|
82
|
+
throwOnError: false,
|
|
83
|
+
}))
|
|
84
|
+
return {
|
|
85
|
+
rows: () => query.data ?? [],
|
|
86
|
+
isPending: () => query.isPending,
|
|
87
|
+
isFetching: () => query.isFetching,
|
|
88
|
+
isError: () => query.isError,
|
|
89
|
+
error: () => asError(query.error),
|
|
90
|
+
refetch: () => query.refetch(),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createCollection<T extends { id: string }, New = NewRecord<T>>(
|
|
95
|
+
options: () => CollectionOptions<T, New>,
|
|
96
|
+
): Collection<T, New> {
|
|
97
|
+
const queryClient = useQueryClient()
|
|
98
|
+
const key = () => options().key
|
|
99
|
+
const fail = (error: unknown) => {
|
|
100
|
+
const normalised = asError(error)
|
|
101
|
+
if (normalised) options().onError?.(normalised)
|
|
102
|
+
return normalised
|
|
103
|
+
}
|
|
104
|
+
const previous = () => queryClient.getQueryData<T[]>(key())
|
|
105
|
+
const restore = (snapshot: T[] | undefined) => {
|
|
106
|
+
if (snapshot) queryClient.setQueryData(key(), snapshot)
|
|
107
|
+
}
|
|
108
|
+
const patchRows = (ids: string[], patch: Partial<T>) => {
|
|
109
|
+
const wanted = new Set(ids)
|
|
110
|
+
queryClient.setQueryData<T[]>(key(), (rows = []) =>
|
|
111
|
+
rows.map((row) => (wanted.has(row.id) ? { ...row, ...patch } : row)),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const query = createListQuery(options)
|
|
116
|
+
|
|
117
|
+
const create = createMutation(() => ({
|
|
118
|
+
mutationFn: (input: New) => options().repository.create(input),
|
|
119
|
+
// Not optimistic: a new record's id and defaults are the repository's to
|
|
120
|
+
// decide, so guessing them would show a row that then changes identity.
|
|
121
|
+
onError: (error) => void fail(error),
|
|
122
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
|
|
123
|
+
}))
|
|
124
|
+
|
|
125
|
+
const update = createMutation(() => ({
|
|
126
|
+
mutationFn: ({ id, patch }: { id: string; patch: Partial<T> }) =>
|
|
127
|
+
options().repository.update(id, patch),
|
|
128
|
+
onMutate: async ({ id, patch }) => {
|
|
129
|
+
await queryClient.cancelQueries({ queryKey: key() })
|
|
130
|
+
const snapshot = previous()
|
|
131
|
+
patchRows([id], patch)
|
|
132
|
+
return { snapshot }
|
|
133
|
+
},
|
|
134
|
+
onError: (error, _input, context) => {
|
|
135
|
+
restore(context?.snapshot)
|
|
136
|
+
void fail(error)
|
|
137
|
+
},
|
|
138
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
|
|
139
|
+
}))
|
|
140
|
+
|
|
141
|
+
const updateMany = createMutation(() => ({
|
|
142
|
+
mutationFn: async ({ ids, patch }: { ids: string[]; patch: Partial<T> }) =>
|
|
143
|
+
Promise.all(ids.map((id) => options().repository.update(id, patch))),
|
|
144
|
+
onMutate: async ({ ids, patch }) => {
|
|
145
|
+
await queryClient.cancelQueries({ queryKey: key() })
|
|
146
|
+
const snapshot = previous()
|
|
147
|
+
patchRows(ids, patch)
|
|
148
|
+
return { snapshot }
|
|
149
|
+
},
|
|
150
|
+
onError: (error, _input, context) => {
|
|
151
|
+
restore(context?.snapshot)
|
|
152
|
+
void fail(error)
|
|
153
|
+
},
|
|
154
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
|
|
155
|
+
}))
|
|
156
|
+
|
|
157
|
+
const remove = createMutation(() => ({
|
|
158
|
+
mutationFn: ({ ids }: { ids: string[] }) =>
|
|
159
|
+
options().repository.remove(ids),
|
|
160
|
+
onMutate: async ({ ids }) => {
|
|
161
|
+
await queryClient.cancelQueries({ queryKey: key() })
|
|
162
|
+
const snapshot = previous()
|
|
163
|
+
const wanted = new Set(ids)
|
|
164
|
+
queryClient.setQueryData<T[]>(key(), (rows = []) =>
|
|
165
|
+
rows.filter((row) => !wanted.has(row.id)),
|
|
166
|
+
)
|
|
167
|
+
return { snapshot }
|
|
168
|
+
},
|
|
169
|
+
onError: (error, _input, context) => {
|
|
170
|
+
restore(context?.snapshot)
|
|
171
|
+
void fail(error)
|
|
172
|
+
},
|
|
173
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
|
|
174
|
+
}))
|
|
175
|
+
|
|
176
|
+
const wrap = <Input, Result>(
|
|
177
|
+
mutation: ReturnType<typeof createMutation<Result, Error, Input>>,
|
|
178
|
+
): CollectionMutation<Input, Result> => ({
|
|
179
|
+
mutate: (input: Input) => mutation.mutate(input),
|
|
180
|
+
mutateAsync: (input: Input) => mutation.mutateAsync(input),
|
|
181
|
+
isPending: () => mutation.isPending,
|
|
182
|
+
error: () => asError(mutation.error),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
query,
|
|
187
|
+
create: wrap(create),
|
|
188
|
+
update: wrap(update),
|
|
189
|
+
updateMany: wrap(updateMany),
|
|
190
|
+
remove: wrap(remove),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A repository is the seam between a page and wherever its records live.
|
|
3
|
+
*
|
|
4
|
+
* It is deliberately small: `list`, `create`, `update`, `remove`. Domain verbs
|
|
5
|
+
* (`complete`, `archive`, `comment`) are `update(id, patch)` with a different
|
|
6
|
+
* name at the call site — a contract that grows a method per business action is
|
|
7
|
+
* a database, not a seam.
|
|
8
|
+
*
|
|
9
|
+
* `list` is the only read. A page that needs one record filters the list it
|
|
10
|
+
* already has; a remote adapter that wants a single request can still expose
|
|
11
|
+
* `list` and let the query cache hold the collection, which is what every local
|
|
12
|
+
* example does.
|
|
13
|
+
*/
|
|
14
|
+
export interface Repository<T extends { id: string }, New = NewRecord<T>> {
|
|
15
|
+
/** Every record, in the order the collection wants them shown. */
|
|
16
|
+
list(options?: { signal?: AbortSignal }): Promise<T[]>
|
|
17
|
+
/** Create one; resolves with the stored record (its real id included). */
|
|
18
|
+
create(input: New, options?: { signal?: AbortSignal }): Promise<T>
|
|
19
|
+
/** Patch one; resolves with the stored record. */
|
|
20
|
+
update(
|
|
21
|
+
id: string,
|
|
22
|
+
patch: Partial<T>,
|
|
23
|
+
options?: { signal?: AbortSignal },
|
|
24
|
+
): Promise<T>
|
|
25
|
+
/** Delete many at once; resolves when they are gone. */
|
|
26
|
+
remove(ids: string[], options?: { signal?: AbortSignal }): Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a caller must supply to create a record: everything but the id it gets. */
|
|
30
|
+
export type NewRecord<T> = Omit<T, 'id'> & { id?: string }
|