@talelk/newui-data 0.0.0-stage → 1.1.0

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