@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,207 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMutation,
|
|
3
|
+
createQuery,
|
|
4
|
+
keepPreviousData,
|
|
5
|
+
type QueryKey,
|
|
6
|
+
useQueryClient,
|
|
7
|
+
} from '@tanstack/solid-query'
|
|
8
|
+
import type { Accessor } from 'solid-js'
|
|
9
|
+
import type { NewRecord, Repository } from './repository'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A list and the four things a page does to it.
|
|
13
|
+
*
|
|
14
|
+
* `createCollection` is the kit's answer to "how does a page hold its records":
|
|
15
|
+
* one query for the list, and mutations that are **optimistic and reversible** —
|
|
16
|
+
* a patch lands on screen immediately, and a failed request puts the previous
|
|
17
|
+
* list back. The page renders `isPending` / `error` and never touches the cache.
|
|
18
|
+
*
|
|
19
|
+
* The list is keyed by `key`, so two panes reading the same collection share one
|
|
20
|
+
* request and one update. The repository stays the only place that knows where
|
|
21
|
+
* the records come from.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface ListQuery<T> {
|
|
25
|
+
/** The records; `[]` while the first request is in flight. */
|
|
26
|
+
rows: Accessor<T[]>
|
|
27
|
+
/** The first request has not settled. */
|
|
28
|
+
isPending: Accessor<boolean>
|
|
29
|
+
/** A request is in flight, first or refetch. */
|
|
30
|
+
isFetching: Accessor<boolean>
|
|
31
|
+
/** The last request failed; `error` says why. */
|
|
32
|
+
isError: Accessor<boolean>
|
|
33
|
+
error: Accessor<Error | undefined>
|
|
34
|
+
/** Re-run the list request (the "Try again" button). */
|
|
35
|
+
refetch: () => Promise<unknown>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CollectionMutation<Input, Result> {
|
|
39
|
+
mutate: (input: Input) => void
|
|
40
|
+
/** Resolves when the mutation *and* the list refresh have settled. */
|
|
41
|
+
mutateAsync: (input: Input) => Promise<Result>
|
|
42
|
+
isPending: Accessor<boolean>
|
|
43
|
+
error: Accessor<Error | undefined>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Collection<T extends { id: string }, New = NewRecord<T>> {
|
|
47
|
+
query: ListQuery<T>
|
|
48
|
+
create: CollectionMutation<New, T>
|
|
49
|
+
update: CollectionMutation<{ id: string; patch: Partial<T> }, T>
|
|
50
|
+
/** Patch many at once — the bulk action. */
|
|
51
|
+
updateMany: CollectionMutation<{ ids: string[]; patch: Partial<T> }, T[]>
|
|
52
|
+
remove: CollectionMutation<{ ids: string[] }, void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CollectionOptions<
|
|
56
|
+
T extends { id: string },
|
|
57
|
+
New = NewRecord<T>,
|
|
58
|
+
> {
|
|
59
|
+
/** The cache identity. Same key = same collection. */
|
|
60
|
+
key: QueryKey
|
|
61
|
+
repository: Repository<T, New>
|
|
62
|
+
/** The list's freshness. Defaults to the client's. */
|
|
63
|
+
staleTime?: number
|
|
64
|
+
/** Retries after the first failure. Defaults to the client's. */
|
|
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
|
|
78
|
+
/** Every mutation reports here first, so a page can word one notice. */
|
|
79
|
+
onError?: (error: Error) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function asError(value: unknown): Error | undefined {
|
|
83
|
+
if (value === null || value === undefined) return undefined
|
|
84
|
+
return value instanceof Error ? value : new Error(String(value))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createListQuery<T extends { id: string }>(
|
|
88
|
+
options: () => CollectionOptions<T, never>,
|
|
89
|
+
): ListQuery<T> {
|
|
90
|
+
const query = createQuery(() => ({
|
|
91
|
+
queryKey: options().key,
|
|
92
|
+
queryFn: ({ signal }) => options().repository.list({ signal }),
|
|
93
|
+
staleTime: options().staleTime,
|
|
94
|
+
retry: options().retry,
|
|
95
|
+
refetchInterval: options().refetchInterval,
|
|
96
|
+
placeholderData: options().keepPreviousData ? keepPreviousData : undefined,
|
|
97
|
+
throwOnError: false,
|
|
98
|
+
}))
|
|
99
|
+
return {
|
|
100
|
+
rows: () => query.data ?? [],
|
|
101
|
+
isPending: () => query.isPending,
|
|
102
|
+
isFetching: () => query.isFetching,
|
|
103
|
+
isError: () => query.isError,
|
|
104
|
+
error: () => asError(query.error),
|
|
105
|
+
refetch: () => query.refetch(),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createCollection<T extends { id: string }, New = NewRecord<T>>(
|
|
110
|
+
options: () => CollectionOptions<T, New>,
|
|
111
|
+
): Collection<T, New> {
|
|
112
|
+
const queryClient = useQueryClient()
|
|
113
|
+
const key = () => options().key
|
|
114
|
+
const fail = (error: unknown) => {
|
|
115
|
+
const normalised = asError(error)
|
|
116
|
+
if (normalised) options().onError?.(normalised)
|
|
117
|
+
return normalised
|
|
118
|
+
}
|
|
119
|
+
const previous = () => queryClient.getQueryData<T[]>(key())
|
|
120
|
+
const restore = (snapshot: T[] | undefined) => {
|
|
121
|
+
if (snapshot) queryClient.setQueryData(key(), snapshot)
|
|
122
|
+
}
|
|
123
|
+
const patchRows = (ids: string[], patch: Partial<T>) => {
|
|
124
|
+
const wanted = new Set(ids)
|
|
125
|
+
queryClient.setQueryData<T[]>(key(), (rows = []) =>
|
|
126
|
+
rows.map((row) => (wanted.has(row.id) ? { ...row, ...patch } : row)),
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const query = createListQuery(options)
|
|
131
|
+
|
|
132
|
+
const create = createMutation(() => ({
|
|
133
|
+
mutationFn: (input: New) => options().repository.create(input),
|
|
134
|
+
// Not optimistic: a new record's id and defaults are the repository's to
|
|
135
|
+
// decide, so guessing them would show a row that then changes identity.
|
|
136
|
+
onError: (error) => void fail(error),
|
|
137
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
|
|
138
|
+
}))
|
|
139
|
+
|
|
140
|
+
const update = createMutation(() => ({
|
|
141
|
+
mutationFn: ({ id, patch }: { id: string; patch: Partial<T> }) =>
|
|
142
|
+
options().repository.update(id, patch),
|
|
143
|
+
onMutate: async ({ id, patch }) => {
|
|
144
|
+
await queryClient.cancelQueries({ queryKey: key() })
|
|
145
|
+
const snapshot = previous()
|
|
146
|
+
patchRows([id], patch)
|
|
147
|
+
return { snapshot }
|
|
148
|
+
},
|
|
149
|
+
onError: (error, _input, context) => {
|
|
150
|
+
restore(context?.snapshot)
|
|
151
|
+
void fail(error)
|
|
152
|
+
},
|
|
153
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
|
|
154
|
+
}))
|
|
155
|
+
|
|
156
|
+
const updateMany = createMutation(() => ({
|
|
157
|
+
mutationFn: async ({ ids, patch }: { ids: string[]; patch: Partial<T> }) =>
|
|
158
|
+
Promise.all(ids.map((id) => options().repository.update(id, patch))),
|
|
159
|
+
onMutate: async ({ ids, patch }) => {
|
|
160
|
+
await queryClient.cancelQueries({ queryKey: key() })
|
|
161
|
+
const snapshot = previous()
|
|
162
|
+
patchRows(ids, patch)
|
|
163
|
+
return { snapshot }
|
|
164
|
+
},
|
|
165
|
+
onError: (error, _input, context) => {
|
|
166
|
+
restore(context?.snapshot)
|
|
167
|
+
void fail(error)
|
|
168
|
+
},
|
|
169
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
const remove = createMutation(() => ({
|
|
173
|
+
mutationFn: ({ ids }: { ids: string[] }) =>
|
|
174
|
+
options().repository.remove(ids),
|
|
175
|
+
onMutate: async ({ ids }) => {
|
|
176
|
+
await queryClient.cancelQueries({ queryKey: key() })
|
|
177
|
+
const snapshot = previous()
|
|
178
|
+
const wanted = new Set(ids)
|
|
179
|
+
queryClient.setQueryData<T[]>(key(), (rows = []) =>
|
|
180
|
+
rows.filter((row) => !wanted.has(row.id)),
|
|
181
|
+
)
|
|
182
|
+
return { snapshot }
|
|
183
|
+
},
|
|
184
|
+
onError: (error, _input, context) => {
|
|
185
|
+
restore(context?.snapshot)
|
|
186
|
+
void fail(error)
|
|
187
|
+
},
|
|
188
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
|
|
189
|
+
}))
|
|
190
|
+
|
|
191
|
+
const wrap = <Input, Result>(
|
|
192
|
+
mutation: ReturnType<typeof createMutation<Result, Error, Input>>,
|
|
193
|
+
): CollectionMutation<Input, Result> => ({
|
|
194
|
+
mutate: (input: Input) => mutation.mutate(input),
|
|
195
|
+
mutateAsync: (input: Input) => mutation.mutateAsync(input),
|
|
196
|
+
isPending: () => mutation.isPending,
|
|
197
|
+
error: () => asError(mutation.error),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
query,
|
|
202
|
+
create: wrap(create),
|
|
203
|
+
update: wrap(update),
|
|
204
|
+
updateMany: wrap(updateMany),
|
|
205
|
+
remove: wrap(remove),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -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 }
|
|
@@ -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
|
+
}
|