@talelk/newui-data 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/package.json +1 -1
- package/skills/newui-data/SKILL.md +59 -5
- package/src/aggregate.test.ts +231 -0
- package/src/aggregate.ts +320 -0
- package/src/config-store.test.ts +157 -0
- package/src/config-store.ts +295 -0
- package/src/csv-export.test.ts +156 -0
- package/src/csv-export.ts +129 -0
- package/src/dashboard.test.tsx +285 -0
- package/src/dashboard.tsx +381 -0
- package/src/exports.test.ts +33 -3
- package/src/form/autosave.test.ts +186 -0
- package/src/form/autosave.ts +194 -0
- package/src/form/config.test.tsx +288 -0
- package/src/form/config.ts +187 -0
- package/src/form/form.tsx +122 -2
- package/src/form/index.ts +2 -0
- package/src/index.ts +6 -0
- package/src/query/collection.test.tsx +130 -3
- package/src/query/collection.ts +15 -0
- package/src/query/index.ts +1 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -0
- package/src/resource/index.ts +7 -0
- package/src/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/url-serialize.ts +93 -0
- package/src/url-state.test.ts +183 -0
- package/src/url-state.ts +168 -0
package/src/form/form.tsx
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createForm,
|
|
3
|
+
type StandardSchemaV1,
|
|
4
|
+
standardSchemaValidators,
|
|
5
|
+
} from '@tanstack/solid-form'
|
|
2
6
|
import { type Accessor, type JSX, splitProps } from 'solid-js'
|
|
7
|
+
import {
|
|
8
|
+
type FieldConfig,
|
|
9
|
+
hiddenPaths,
|
|
10
|
+
isUnderHidden,
|
|
11
|
+
pruneHiddenValues,
|
|
12
|
+
} from './config'
|
|
3
13
|
|
|
4
14
|
/**
|
|
5
15
|
* The form surface: the kit's answer to "how does a page submit".
|
|
@@ -18,6 +28,25 @@ export interface AppFormOptions<T extends object> {
|
|
|
18
28
|
* `submitting()`, which is what a submit button's `loading` reads.
|
|
19
29
|
*/
|
|
20
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
|
|
21
50
|
}
|
|
22
51
|
|
|
23
52
|
/** The `error` prop of a field, from TanStack's error list. */
|
|
@@ -39,11 +68,95 @@ export function fieldError(field: {
|
|
|
39
68
|
return [...new Set(messages)].join(' ')
|
|
40
69
|
}
|
|
41
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
|
+
|
|
42
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
|
+
|
|
43
137
|
const form = createForm(() => ({
|
|
44
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,
|
|
45
156
|
onSubmit: async ({ value, formApi }) => {
|
|
46
|
-
await options.onSubmit(value as T, {
|
|
157
|
+
await options.onSubmit(submitted(value as T), {
|
|
158
|
+
reset: () => formApi.reset(),
|
|
159
|
+
})
|
|
47
160
|
},
|
|
48
161
|
}))
|
|
49
162
|
const submitting = form.useSelector((state) => state.isSubmitting)
|
|
@@ -51,6 +164,13 @@ export function createAppForm<T extends object>(options: AppFormOptions<T>) {
|
|
|
51
164
|
form,
|
|
52
165
|
/** True while the submit handler's promise is pending. */
|
|
53
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,
|
|
54
174
|
}
|
|
55
175
|
}
|
|
56
176
|
|
package/src/form/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
export * from './aggregate'
|
|
2
|
+
export * from './config-store'
|
|
3
|
+
export * from './csv-export'
|
|
4
|
+
export * from './dashboard'
|
|
1
5
|
export * from './download'
|
|
2
6
|
export * from './format'
|
|
3
7
|
export * from './select'
|
|
8
|
+
export * from './table-persist'
|
|
4
9
|
export * from './undo'
|
|
10
|
+
export * from './url-state'
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
2
|
-
import
|
|
2
|
+
import type { QueryKey } from '@tanstack/solid-query'
|
|
3
|
+
import { createSignal, For, type JSX, untrack } from 'solid-js'
|
|
3
4
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
5
|
import { DataProvider } from './client'
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type Collection,
|
|
8
|
+
createCollection,
|
|
9
|
+
createListQuery,
|
|
10
|
+
} from './collection'
|
|
6
11
|
import type { Repository } from './repository'
|
|
7
12
|
|
|
8
13
|
interface Row {
|
|
@@ -50,11 +55,15 @@ function makeRepository(initial: Row[]) {
|
|
|
50
55
|
function Harness(props: {
|
|
51
56
|
repository: Repository<Row, { title: string }>
|
|
52
57
|
ready: (collection: Collection<Row, { title: string }>) => void
|
|
58
|
+
interval?: number | false
|
|
53
59
|
}): JSX.Element {
|
|
54
60
|
const collection = createCollection(() => ({
|
|
55
61
|
key: ['rows'],
|
|
56
62
|
repository: props.repository,
|
|
57
63
|
retry: false,
|
|
64
|
+
...(props.interval !== undefined
|
|
65
|
+
? { refetchInterval: props.interval }
|
|
66
|
+
: {}),
|
|
58
67
|
}))
|
|
59
68
|
untrack(() => props.ready(collection))
|
|
60
69
|
return (
|
|
@@ -64,12 +73,16 @@ function Harness(props: {
|
|
|
64
73
|
)
|
|
65
74
|
}
|
|
66
75
|
|
|
67
|
-
function mount(
|
|
76
|
+
function mount(
|
|
77
|
+
repository: Repository<Row, { title: string }>,
|
|
78
|
+
interval?: number | false,
|
|
79
|
+
) {
|
|
68
80
|
let collection!: Collection<Row, { title: string }>
|
|
69
81
|
render(() => (
|
|
70
82
|
<DataProvider>
|
|
71
83
|
<Harness
|
|
72
84
|
repository={repository}
|
|
85
|
+
interval={interval}
|
|
73
86
|
ready={(value) => (collection = value)}
|
|
74
87
|
/>
|
|
75
88
|
</DataProvider>
|
|
@@ -148,4 +161,118 @@ describe('createCollection', () => {
|
|
|
148
161
|
await collection().remove.mutateAsync({ ids: ['1', '2'] })
|
|
149
162
|
await waitFor(() => expect(screen.queryByText('Done')).toBeNull())
|
|
150
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
|
+
})
|
|
151
278
|
})
|
package/src/query/collection.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createMutation,
|
|
3
3
|
createQuery,
|
|
4
|
+
keepPreviousData,
|
|
4
5
|
type QueryKey,
|
|
5
6
|
useQueryClient,
|
|
6
7
|
} from '@tanstack/solid-query'
|
|
@@ -62,6 +63,18 @@ export interface CollectionOptions<
|
|
|
62
63
|
staleTime?: number
|
|
63
64
|
/** Retries after the first failure. Defaults to the client's. */
|
|
64
65
|
retry?: number | false
|
|
66
|
+
/**
|
|
67
|
+
* Poll the list on an interval. Off (`false`) by default — the client's
|
|
68
|
+
* `refetchOnWindowFocus: false` says returning to the tab is not a refresh;
|
|
69
|
+
* this is the explicit dial for data that genuinely changes on its own.
|
|
70
|
+
*/
|
|
71
|
+
refetchInterval?: number | false
|
|
72
|
+
/**
|
|
73
|
+
* Keep the previous response on screen while the next one is in flight, so
|
|
74
|
+
* a server-mode page change never blanks the table. The rows are stale for
|
|
75
|
+
* one round trip; an empty table is not the alternative anyone asked for.
|
|
76
|
+
*/
|
|
77
|
+
keepPreviousData?: boolean
|
|
65
78
|
/** Every mutation reports here first, so a page can word one notice. */
|
|
66
79
|
onError?: (error: Error) => void
|
|
67
80
|
}
|
|
@@ -79,6 +92,8 @@ export function createListQuery<T extends { id: string }>(
|
|
|
79
92
|
queryFn: ({ signal }) => options().repository.list({ signal }),
|
|
80
93
|
staleTime: options().staleTime,
|
|
81
94
|
retry: options().retry,
|
|
95
|
+
refetchInterval: options().refetchInterval,
|
|
96
|
+
placeholderData: options().keepPreviousData ? keepPreviousData : undefined,
|
|
82
97
|
throwOnError: false,
|
|
83
98
|
}))
|
|
84
99
|
return {
|
package/src/query/index.ts
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createRoot } from 'solid-js'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import type { UrlStateAdapter } from '../url-state'
|
|
4
|
+
import { createServerTableState, type ServerTableState } from './server-table'
|
|
5
|
+
|
|
6
|
+
function setup(...args: Parameters<typeof createServerTableState>): {
|
|
7
|
+
state: ServerTableState
|
|
8
|
+
dispose: () => void
|
|
9
|
+
} {
|
|
10
|
+
let dispose!: () => void
|
|
11
|
+
let state!: ServerTableState
|
|
12
|
+
createRoot((d) => {
|
|
13
|
+
dispose = d
|
|
14
|
+
state = createServerTableState(...args)
|
|
15
|
+
})
|
|
16
|
+
return { state, dispose }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface FakeAdapter extends UrlStateAdapter {
|
|
20
|
+
last(): URLSearchParams
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function adapter(search = ''): FakeAdapter {
|
|
24
|
+
let last = new URLSearchParams(search)
|
|
25
|
+
const replace = vi.fn((params: URLSearchParams) => {
|
|
26
|
+
last = params
|
|
27
|
+
})
|
|
28
|
+
return {
|
|
29
|
+
search: () => search,
|
|
30
|
+
replace,
|
|
31
|
+
last: () => last,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('createServerTableState', () => {
|
|
36
|
+
it('starts from the defaults or the initial slices', () => {
|
|
37
|
+
const { state, dispose } = setup({
|
|
38
|
+
initial: {
|
|
39
|
+
sorting: [{ id: 'name', desc: false }],
|
|
40
|
+
pagination: { pageSize: 25 },
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
expect(state.sorting()).toEqual([{ id: 'name', desc: false }])
|
|
44
|
+
expect(state.columnFilters()).toEqual([])
|
|
45
|
+
expect(state.globalFilter()).toBe('')
|
|
46
|
+
expect(state.pagination()).toEqual({ pageIndex: 0, pageSize: 25 })
|
|
47
|
+
dispose()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('a sort change returns the result to page 0', () => {
|
|
51
|
+
const { state, dispose } = setup()
|
|
52
|
+
state.setPageIndex(4)
|
|
53
|
+
expect(state.pagination().pageIndex).toBe(4)
|
|
54
|
+
state.setSorting([{ id: 'amount', desc: true }])
|
|
55
|
+
expect(state.sorting()).toEqual([{ id: 'amount', desc: true }])
|
|
56
|
+
expect(state.pagination().pageIndex).toBe(0)
|
|
57
|
+
dispose()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('a filter change returns the result to page 0', () => {
|
|
61
|
+
const { state, dispose } = setup()
|
|
62
|
+
state.setPageIndex(2)
|
|
63
|
+
state.setColumnFilters([{ id: 'status', value: 'open' }])
|
|
64
|
+
expect(state.columnFilters()).toEqual([{ id: 'status', value: 'open' }])
|
|
65
|
+
expect(state.pagination().pageIndex).toBe(0)
|
|
66
|
+
state.setGlobalFilter('beta')
|
|
67
|
+
state.setPageIndex(7)
|
|
68
|
+
state.setGlobalFilter('gamma')
|
|
69
|
+
expect(state.pagination().pageIndex).toBe(0)
|
|
70
|
+
dispose()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('paging moves without touching the other slices', () => {
|
|
74
|
+
const { state, dispose } = setup()
|
|
75
|
+
state.setSorting([{ id: 'name', desc: true }])
|
|
76
|
+
state.setPageIndex(3)
|
|
77
|
+
expect(state.pagination()).toEqual({ pageIndex: 3, pageSize: 10 })
|
|
78
|
+
expect(state.sorting()).toEqual([{ id: 'name', desc: true }])
|
|
79
|
+
dispose()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('resizing the page keeps the top row', () => {
|
|
83
|
+
const { state, dispose } = setup()
|
|
84
|
+
state.setPageIndex(2) // rows 20..29 at pageSize 10
|
|
85
|
+
state.setPageSize(20)
|
|
86
|
+
expect(state.pagination()).toEqual({ pageIndex: 1, pageSize: 20 })
|
|
87
|
+
dispose()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('the query key carries every slice over the base', () => {
|
|
91
|
+
const { state, dispose } = setup()
|
|
92
|
+
state.setSorting([{ id: 'name', desc: false }])
|
|
93
|
+
state.setGlobalFilter('acme')
|
|
94
|
+
state.setPageIndex(2)
|
|
95
|
+
expect(state.key(['tasks'])).toEqual([
|
|
96
|
+
'tasks',
|
|
97
|
+
{
|
|
98
|
+
sorting: [{ id: 'name', desc: false }],
|
|
99
|
+
columnFilters: [],
|
|
100
|
+
globalFilter: 'acme',
|
|
101
|
+
pagination: { pageIndex: 2, pageSize: 10 },
|
|
102
|
+
},
|
|
103
|
+
])
|
|
104
|
+
dispose()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('writes the slices to the URL under the namespace', async () => {
|
|
108
|
+
const url = adapter()
|
|
109
|
+
const { state, dispose } = setup({
|
|
110
|
+
sync: { adapter: url, namespace: 'tasks' },
|
|
111
|
+
})
|
|
112
|
+
state.setSorting([{ id: 'name', desc: true }])
|
|
113
|
+
// One coalesced `replace` per tick, so the write lands on the next microtask.
|
|
114
|
+
await Promise.resolve()
|
|
115
|
+
const written = url.last().get('tasks.sorting')
|
|
116
|
+
expect(JSON.parse(written ?? '')).toEqual([{ id: 'name', desc: true }])
|
|
117
|
+
expect(url.last().get('tasks.pagination')).toContain('"pageIndex":0')
|
|
118
|
+
dispose()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('the URL seeds the store on creation', () => {
|
|
122
|
+
const url = adapter(
|
|
123
|
+
`?tasks.sorting=${encodeURIComponent(
|
|
124
|
+
JSON.stringify([{ id: 'amount', desc: true }]),
|
|
125
|
+
)}&tasks.pagination=${encodeURIComponent(
|
|
126
|
+
JSON.stringify({ pageIndex: 3, pageSize: 10 }),
|
|
127
|
+
)}`,
|
|
128
|
+
)
|
|
129
|
+
const { state, dispose } = setup({
|
|
130
|
+
sync: { adapter: url, namespace: 'tasks' },
|
|
131
|
+
})
|
|
132
|
+
expect(state.sorting()).toEqual([{ id: 'amount', desc: true }])
|
|
133
|
+
expect(state.pagination()).toEqual({ pageIndex: 3, pageSize: 10 })
|
|
134
|
+
dispose()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { QueryKey } from '@tanstack/solid-query'
|
|
2
|
+
import type { Accessor } from 'solid-js'
|
|
3
|
+
import {
|
|
4
|
+
createUrlState,
|
|
5
|
+
type UrlStateAdapter,
|
|
6
|
+
type UrlStateValue,
|
|
7
|
+
} from '../url-state'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The query state of a server-mode table.
|
|
11
|
+
*
|
|
12
|
+
* `createServerTableState` owns the slices a server-mode `DataTable` mirrors:
|
|
13
|
+
* sorting, column filters, the global filter and pagination. It is a store,
|
|
14
|
+
* not a hook — the page creates it once and wires its accessors and setters
|
|
15
|
+
* into the table's controlled props, so the same state drives the request, the
|
|
16
|
+
* query key and (with a router adapter) the URL.
|
|
17
|
+
*
|
|
18
|
+
* The reset rule lives here as it does in the table: a sort or filter change
|
|
19
|
+
* returns the result to page 0, because page 3 of a result set that just
|
|
20
|
+
* changed shape is not a page anyone asked for.
|
|
21
|
+
*
|
|
22
|
+
* The slices are structural copies of TanStack Table's state shapes — this
|
|
23
|
+
* package never imports the table engine, so the app is the only place the
|
|
24
|
+
* two meet.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Type aliases, not interfaces: the slices serialize through `createUrlState`,
|
|
28
|
+
// whose value contract needs the implicit index signature a type alias carries.
|
|
29
|
+
|
|
30
|
+
/** One sort entry: a column id and its direction. */
|
|
31
|
+
export type ServerTableSort = {
|
|
32
|
+
id: string
|
|
33
|
+
desc: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One column filter: a column id and its value. */
|
|
37
|
+
export type ServerTableColumnFilter = {
|
|
38
|
+
id: string
|
|
39
|
+
value: unknown
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where the user is in the result set. */
|
|
43
|
+
export type ServerTablePagination = {
|
|
44
|
+
pageIndex: number
|
|
45
|
+
pageSize: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ServerTableStateOptions {
|
|
49
|
+
/** What the page renders before the URL or the user has said anything. */
|
|
50
|
+
initial?: {
|
|
51
|
+
sorting?: ServerTableSort[]
|
|
52
|
+
columnFilters?: ServerTableColumnFilter[]
|
|
53
|
+
globalFilter?: string
|
|
54
|
+
pagination?: Partial<ServerTablePagination>
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Pipe the slices through `createUrlState` so they serialize to query
|
|
58
|
+
* parameters. Keys are namespaced (`table.sorting` by default) so one URL
|
|
59
|
+
* can host several surfaces. Without an adapter the store is plain signals.
|
|
60
|
+
*/
|
|
61
|
+
sync?: {
|
|
62
|
+
adapter?: UrlStateAdapter
|
|
63
|
+
/** Query-key namespace for the slices. Defaults to `table`. */
|
|
64
|
+
namespace?: string
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ServerTableState {
|
|
69
|
+
sorting: Accessor<ServerTableSort[]>
|
|
70
|
+
columnFilters: Accessor<ServerTableColumnFilter[]>
|
|
71
|
+
globalFilter: Accessor<string>
|
|
72
|
+
pagination: Accessor<ServerTablePagination>
|
|
73
|
+
/** Replaces the sort entries and returns to page 0. */
|
|
74
|
+
setSorting: (next: ServerTableSort[]) => void
|
|
75
|
+
/** Replaces the column filters and returns to page 0. */
|
|
76
|
+
setColumnFilters: (next: ServerTableColumnFilter[]) => void
|
|
77
|
+
/** Replaces the search term and returns to page 0. */
|
|
78
|
+
setGlobalFilter: (next: string) => void
|
|
79
|
+
/** Moves within the result set. */
|
|
80
|
+
setPageIndex: (pageIndex: number) => void
|
|
81
|
+
/** Resizes the page, preserving the top row. */
|
|
82
|
+
setPageSize: (pageSize: number) => void
|
|
83
|
+
/** The whole pagination slice at once. */
|
|
84
|
+
setPagination: (next: ServerTablePagination) => void
|
|
85
|
+
/**
|
|
86
|
+
* The slices as one query key, so two pages with the same table state share
|
|
87
|
+
* one request. Pass the collection's own key as `base`.
|
|
88
|
+
*/
|
|
89
|
+
key: (base?: QueryKey) => QueryKey
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const DEFAULT_NAMESPACE = 'table'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A server-mode table's sorting, filtering and pagination as one store,
|
|
96
|
+
* optionally serialized to the URL.
|
|
97
|
+
*/
|
|
98
|
+
export function createServerTableState(
|
|
99
|
+
options: ServerTableStateOptions = {},
|
|
100
|
+
): ServerTableState {
|
|
101
|
+
const ns = options.sync?.namespace ?? DEFAULT_NAMESPACE
|
|
102
|
+
const initialPagination: ServerTablePagination = {
|
|
103
|
+
pageIndex: 0,
|
|
104
|
+
pageSize: 10,
|
|
105
|
+
...options.initial?.pagination,
|
|
106
|
+
}
|
|
107
|
+
// The slices are typed by TanStack's shapes, not by `UrlStateValue`; the
|
|
108
|
+
// URL layer only ever JSON-round-trips them, so the boundary is a cast.
|
|
109
|
+
const initial: Record<string, UrlStateValue> = {
|
|
110
|
+
[`${ns}.sorting`]: options.initial?.sorting ?? [],
|
|
111
|
+
[`${ns}.columnFilters`]: (options.initial?.columnFilters ??
|
|
112
|
+
[]) as UrlStateValue,
|
|
113
|
+
[`${ns}.globalFilter`]: options.initial?.globalFilter ?? '',
|
|
114
|
+
[`${ns}.pagination`]: initialPagination,
|
|
115
|
+
}
|
|
116
|
+
const url = createUrlState(initial, { adapter: options.sync?.adapter })
|
|
117
|
+
|
|
118
|
+
// `createUrlState` types every key as `UrlStateValue`; the store re-types
|
|
119
|
+
// them back. Values round-trip through JSON, so the casts are the whole
|
|
120
|
+
// story — the URL never sees a value this file did not write.
|
|
121
|
+
const signals = url as unknown as Record<string, Accessor<UrlStateValue>>
|
|
122
|
+
const read = <T>(key: string): Accessor<T> => {
|
|
123
|
+
const accessor = signals[key]
|
|
124
|
+
if (!accessor) throw new Error(`createUrlState is missing key "${key}"`)
|
|
125
|
+
return () => accessor() as T
|
|
126
|
+
}
|
|
127
|
+
const sorting = read<ServerTableSort[]>(`${ns}.sorting`)
|
|
128
|
+
const columnFilters = read<ServerTableColumnFilter[]>(`${ns}.columnFilters`)
|
|
129
|
+
const globalFilter = read<string>(`${ns}.globalFilter`)
|
|
130
|
+
const pagination = read<ServerTablePagination>(`${ns}.pagination`)
|
|
131
|
+
|
|
132
|
+
const setPagination = (next: ServerTablePagination) => {
|
|
133
|
+
url.set(`${ns}.pagination`, next)
|
|
134
|
+
}
|
|
135
|
+
/** The reset rule: a changed query starts at page 0. */
|
|
136
|
+
const resetPageIndex = () => {
|
|
137
|
+
const current = pagination()
|
|
138
|
+
if (current.pageIndex !== 0) setPagination({ ...current, pageIndex: 0 })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
sorting,
|
|
143
|
+
columnFilters,
|
|
144
|
+
globalFilter,
|
|
145
|
+
pagination,
|
|
146
|
+
setSorting: (next) => {
|
|
147
|
+
url.set(`${ns}.sorting`, next)
|
|
148
|
+
resetPageIndex()
|
|
149
|
+
},
|
|
150
|
+
setColumnFilters: (next) => {
|
|
151
|
+
url.set(`${ns}.columnFilters`, next as UrlStateValue)
|
|
152
|
+
resetPageIndex()
|
|
153
|
+
},
|
|
154
|
+
setGlobalFilter: (next) => {
|
|
155
|
+
url.set(`${ns}.globalFilter`, next)
|
|
156
|
+
resetPageIndex()
|
|
157
|
+
},
|
|
158
|
+
setPageIndex: (pageIndex) => {
|
|
159
|
+
setPagination({ ...pagination(), pageIndex })
|
|
160
|
+
},
|
|
161
|
+
setPageSize: (pageSize) => {
|
|
162
|
+
const current = pagination()
|
|
163
|
+
const size = Math.max(1, pageSize)
|
|
164
|
+
const topRow = current.pageIndex * current.pageSize
|
|
165
|
+
setPagination({
|
|
166
|
+
pageIndex: Math.floor(topRow / size),
|
|
167
|
+
pageSize: size,
|
|
168
|
+
})
|
|
169
|
+
},
|
|
170
|
+
setPagination,
|
|
171
|
+
key: (base = []) => [
|
|
172
|
+
...base,
|
|
173
|
+
{
|
|
174
|
+
sorting: sorting(),
|
|
175
|
+
columnFilters: columnFilters(),
|
|
176
|
+
globalFilter: globalFilter(),
|
|
177
|
+
pagination: pagination(),
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
}
|
|
181
|
+
}
|