@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/src/form/form.tsx CHANGED
@@ -1,5 +1,15 @@
1
- import { createForm } from '@tanstack/solid-form'
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, { reset: () => formApi.reset() })
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
@@ -1 +1,3 @@
1
+ export * from './autosave'
2
+ export * from './config'
1
3
  export * from './form'
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 { For, type JSX, untrack } from 'solid-js'
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 { type Collection, createCollection } from './collection'
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(repository: Repository<Row, { title: string }>) {
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
  })
@@ -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 {
@@ -1,3 +1,4 @@
1
1
  export * from './client'
2
2
  export * from './collection'
3
3
  export * from './repository'
4
+ export * from './server-table'
@@ -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
+ }