@byline/host-tanstack-start 4.5.0 → 4.6.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 (34) hide show
  1. package/dist/admin-shell/chrome/th-sortable.d.ts +8 -1
  2. package/dist/admin-shell/chrome/th-sortable.js +2 -1
  3. package/dist/admin-shell/collections/create.d.ts +3 -1
  4. package/dist/admin-shell/collections/create.js +7 -3
  5. package/dist/admin-shell/collections/edit.d.ts +3 -1
  6. package/dist/admin-shell/collections/edit.js +21 -13
  7. package/dist/admin-shell/collections/list.js +49 -20
  8. package/dist/routes/create-collection-create-route.js +8 -1
  9. package/dist/routes/create-collection-edit-route.js +5 -2
  10. package/dist/routes/create-collection-list-route.js +2 -14
  11. package/dist/routes/list-return-state.d.ts +45 -0
  12. package/dist/routes/list-return-state.js +43 -0
  13. package/dist/server-fns/collections/index.d.ts +1 -0
  14. package/dist/server-fns/collections/index.js +1 -0
  15. package/dist/server-fns/collections/list-view-state.d.ts +45 -0
  16. package/dist/server-fns/collections/list-view-state.js +59 -0
  17. package/dist/server-fns/collections/list.js +37 -14
  18. package/dist/server-fns/collections/set-list-view-preference.d.ts +18 -0
  19. package/dist/server-fns/collections/set-list-view-preference.js +32 -0
  20. package/package.json +8 -8
  21. package/src/admin-shell/chrome/th-sortable.tsx +9 -0
  22. package/src/admin-shell/collections/create.tsx +12 -5
  23. package/src/admin-shell/collections/edit.tsx +12 -4
  24. package/src/admin-shell/collections/list.tsx +49 -7
  25. package/src/routes/create-collection-create-route.tsx +9 -0
  26. package/src/routes/create-collection-edit-route.tsx +4 -0
  27. package/src/routes/create-collection-list-route.tsx +2 -12
  28. package/src/routes/list-return-state.test.node.ts +55 -0
  29. package/src/routes/list-return-state.ts +77 -0
  30. package/src/server-fns/collections/index.ts +1 -0
  31. package/src/server-fns/collections/list-view-state.test.node.ts +141 -0
  32. package/src/server-fns/collections/list-view-state.ts +137 -0
  33. package/src/server-fns/collections/list.ts +51 -26
  34. package/src/server-fns/collections/set-list-view-preference.ts +67 -0
@@ -1,7 +1,10 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
- import { getAdminBylineClient } from "@byline/client/server";
2
+ import { getPreferenceCommand } from "@byline/admin/admin-preferences";
3
+ import { getAdminBylineClient, getAdminRequestContext } from "@byline/client/server";
3
4
  import { ERR_NOT_FOUND, buildRelationSummaryPopulateMap, getCollectionAdminConfig, getCollectionDefinition, getCollectionSchemasForPath, getLogger, getServerConfig } from "@byline/core";
4
5
  import { ensureCollection } from "../../integrations/api-utils.js";
6
+ import { bylineCore } from "../../integrations/byline-core.js";
7
+ import { resolveListViewState, sortableFieldNames } from "./list-view-state.js";
5
8
  import { serialise } from "./utils.js";
6
9
  const getCollectionDocuments = createServerFn({
7
10
  method: 'GET'
@@ -16,23 +19,42 @@ const getCollectionDocuments = createServerFn({
16
19
  }).log(getLogger());
17
20
  const client = getAdminBylineClient();
18
21
  const handle = client.collection(path);
19
- const pageSize = params.page_size ?? 20;
20
22
  const where = {};
21
23
  if (params.status) where.status = params.status;
22
24
  if (params.query) where.query = params.query;
23
25
  const adminConfig = getCollectionAdminConfig(path);
24
- const configuredSort = params.order || true === config.definition.orderable || adminConfig?.defaultSort == null ? void 0 : {
26
+ const configuredSort = true !== config.definition.orderable && adminConfig?.defaultSort != null ? {
25
27
  order: String(adminConfig.defaultSort.field),
26
28
  desc: 'desc' === adminConfig.defaultSort.direction
27
- };
28
- const defaultSort = true === config.definition.orderable ? {
29
- order_key: 'asc'
30
- } : null != configuredSort ? {
31
- [configuredSort.order]: configuredSort.desc ? 'desc' : 'asc'
32
29
  } : void 0;
33
- const sortSpec = params.order ? {
34
- [params.order]: false === params.desc ? 'asc' : 'desc'
35
- } : defaultSort;
30
+ let preference = null;
31
+ const adminStore = bylineCore().adminStore;
32
+ if (null != adminStore && (null == params.page_size || null == params.order)) try {
33
+ const context = await getAdminRequestContext();
34
+ const res = await getPreferenceCommand(context, {
35
+ scope: `collections.${path}.list`
36
+ }, {
37
+ store: adminStore
38
+ });
39
+ preference = res.value ?? null;
40
+ } catch (err) {
41
+ getLogger().warn({
42
+ err,
43
+ collection: path
44
+ }, 'list-view preference read failed — using defaults');
45
+ }
46
+ const viewState = resolveListViewState({
47
+ params: {
48
+ page_size: params.page_size,
49
+ order: params.order,
50
+ desc: params.desc
51
+ },
52
+ preference,
53
+ orderable: true === config.definition.orderable,
54
+ sortableFields: sortableFieldNames(config.definition.fields),
55
+ configuredSort
56
+ });
57
+ const pageSize = viewState.pageSize;
36
58
  const populateMap = buildRelationSummaryPopulateMap(config.definition.fields, (targetPath)=>({
37
59
  def: getCollectionDefinition(targetPath),
38
60
  admin: getCollectionAdminConfig(targetPath)
@@ -41,7 +63,7 @@ const getCollectionDocuments = createServerFn({
41
63
  const populate = hasRelations ? populateMap : void 0;
42
64
  const result = await handle.find({
43
65
  where: Object.keys(where).length > 0 ? where : void 0,
44
- sort: sortSpec,
66
+ sort: viewState.sort,
45
67
  locale: params.locale ?? 'en',
46
68
  page: params.page,
47
69
  pageSize,
@@ -64,8 +86,9 @@ const getCollectionDocuments = createServerFn({
64
86
  docs,
65
87
  meta: {
66
88
  ...result.meta,
67
- order: params.order ?? configuredSort?.order,
68
- desc: params.desc ?? configuredSort?.desc
89
+ order: viewState.metaOrder,
90
+ desc: viewState.metaDesc,
91
+ pageSize
69
92
  },
70
93
  included: {
71
94
  collection: {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export interface SetListViewPreferenceInput {
9
+ collection: string;
10
+ value: {
11
+ page_size?: number;
12
+ order?: string;
13
+ desc?: boolean;
14
+ };
15
+ }
16
+ export declare const setListViewPreference: import("@tanstack/react-start").RequiredFetcher<undefined, (input: SetListViewPreferenceInput) => SetListViewPreferenceInput, Promise<{
17
+ ok: true;
18
+ }>>;
@@ -0,0 +1,32 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { setPreferenceCommand } from "@byline/admin/admin-preferences";
3
+ import { getAdminRequestContext } from "@byline/client/server";
4
+ import { ERR_NOT_FOUND, getLogger } from "@byline/core";
5
+ import { ensureCollection } from "../../integrations/api-utils.js";
6
+ import { bylineCore } from "../../integrations/byline-core.js";
7
+ const setListViewPreference = createServerFn({
8
+ method: 'POST'
9
+ }).validator((input)=>input).handler(async ({ data })=>{
10
+ const config = await ensureCollection(data.collection);
11
+ if (!config) throw ERR_NOT_FOUND({
12
+ message: 'Collection not found',
13
+ details: {
14
+ collectionPath: data.collection
15
+ }
16
+ }).log(getLogger());
17
+ const adminStore = bylineCore().adminStore;
18
+ if (null == adminStore) return {
19
+ ok: true
20
+ };
21
+ const context = await getAdminRequestContext();
22
+ await setPreferenceCommand(context, {
23
+ scope: `collections.${data.collection}.list`,
24
+ value: data.value
25
+ }, {
26
+ store: adminStore
27
+ });
28
+ return {
29
+ ok: true
30
+ };
31
+ });
32
+ export { setListViewPreference };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "4.5.0",
6
+ "version": "4.6.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -118,13 +118,13 @@
118
118
  "react-json-view-lite": "^2.5.0",
119
119
  "react-swipeable": "^7.0.2",
120
120
  "zod": "^4.4.3",
121
- "@byline/ai": "4.5.0",
122
- "@byline/core": "4.5.0",
123
- "@byline/client": "4.5.0",
124
- "@byline/i18n": "4.5.0",
125
- "@byline/ui": "4.5.0",
126
- "@byline/admin": "4.5.0",
127
- "@byline/auth": "4.5.0"
121
+ "@byline/admin": "4.6.0",
122
+ "@byline/client": "4.6.0",
123
+ "@byline/auth": "4.6.0",
124
+ "@byline/core": "4.6.0",
125
+ "@byline/ai": "4.6.0",
126
+ "@byline/ui": "4.6.0",
127
+ "@byline/i18n": "4.6.0"
128
128
  },
129
129
  "peerDependencies": {
130
130
  "@tanstack/react-router": "^1.167.0",
@@ -37,6 +37,13 @@ export interface TableHeadingCellSortableProps extends TableHeadingCellProps {
37
37
  */
38
38
  activeOrder?: string
39
39
  activeDesc?: boolean
40
+ /**
41
+ * Fired after a sort click navigates, with the new order/direction.
42
+ * The collection list uses this to persist the choice as a sticky
43
+ * per-user preference; surfaces that omit it (admin-users) are
44
+ * unaffected.
45
+ */
46
+ onSort?: (order: string, desc: boolean) => void
40
47
  }
41
48
 
42
49
  export function TableHeadingCellSortable({
@@ -48,6 +55,7 @@ export function TableHeadingCellSortable({
48
55
  className,
49
56
  activeOrder,
50
57
  activeDesc,
58
+ onSort,
51
59
  ...rest
52
60
  }: TableHeadingCellSortableProps & {
53
61
  ref?: React.RefObject<HTMLTableCellElement>
@@ -68,6 +76,7 @@ export function TableHeadingCellSortable({
68
76
  to: location.pathname as never,
69
77
  search: params,
70
78
  })
79
+ onSort?.(fieldName, descending)
71
80
  }
72
81
  }
73
82
 
@@ -14,6 +14,7 @@ import { useTranslation } from '@byline/i18n/react'
14
14
  import { Container, Section, useToastManager } from '@byline/ui/react'
15
15
 
16
16
  import { getAdminRoutePath } from '../../routes/admin-path.js'
17
+ import { decodeListReturnState } from '../../routes/list-return-state.js'
17
18
  import { createCollectionDocument } from '../../server-fns/collections/index.js'
18
19
  import { useNavigate } from '../chrome/loose-router.js'
19
20
  import { useTanStackNavigationGuard } from './tanstack-navigation-guard.js'
@@ -27,11 +28,14 @@ export const CreateView = ({
27
28
  collectionDefinition,
28
29
  adminConfig,
29
30
  initialData,
31
+ from,
30
32
  }: {
31
33
  collectionDefinition: CollectionDefinition
32
34
  adminConfig?: CollectionAdminConfig
33
35
  // biome-ignore lint/suspicious/noExplicitAny: shape is collection-specific
34
36
  initialData?: Record<string, any>
37
+ /** URL-encoded list search state to return to on cancel — see list-return-state.ts. */
38
+ from?: string
35
39
  }) => {
36
40
  const toastManager = useToastManager()
37
41
  const { t } = useTranslation('byline-admin')
@@ -62,20 +66,22 @@ export const CreateView = ({
62
66
  },
63
67
  })
64
68
  // Create → edit: land the editor on the new document, where the rest
65
- // of the work (content, status, relations) happens. The list-view
66
- // fallback covers callers running against an older server fn that
67
- // doesn't return the new document's id.
69
+ // of the work (content, status, relations) happens. `from` threads
70
+ // forward so closing the brand-new document also returns to the
71
+ // originating list. The list-view fallback covers callers running
72
+ // against an older server fn that doesn't return the new document's
73
+ // id — it restores the return state directly.
68
74
  if (result?.documentId) {
69
75
  navigate({
70
76
  to: getAdminRoutePath('collections', '$collection', '$id'),
71
77
  params: { collection: path, id: result.documentId } as never,
72
- search: { action: 'created' },
78
+ search: { action: 'created', from },
73
79
  })
74
80
  } else {
75
81
  navigate({
76
82
  to: getAdminRoutePath('collections', '$collection'),
77
83
  params: { collection: path },
78
- search: { action: 'created' },
84
+ search: { ...decodeListReturnState(from), action: 'created' },
79
85
  })
80
86
  }
81
87
  } catch (err) {
@@ -120,6 +126,7 @@ export const CreateView = ({
120
126
  navigate({
121
127
  to: getAdminRoutePath('collections', '$collection'),
122
128
  params: { collection: path },
129
+ search: decodeListReturnState(from),
123
130
  })
124
131
  }
125
132
  collectionPath={path}
@@ -44,6 +44,7 @@ export const EditView = ({
44
44
  locale,
45
45
  contentLocales,
46
46
  defaultContentLocale,
47
+ returnSearch,
47
48
  }: {
48
49
  collectionDefinition: CollectionDefinition
49
50
  adminConfig?: CollectionAdminConfig
@@ -51,6 +52,8 @@ export const EditView = ({
51
52
  locale?: string
52
53
  contentLocales: ReadonlyArray<ContentLocaleOption>
53
54
  defaultContentLocale: string
55
+ /** URL-encoded list search state to return to on close — see list-return-state.ts. */
56
+ returnSearch?: Record<string, unknown>
54
57
  }) => {
55
58
  const toastManager = useToastManager()
56
59
  const { t } = useTranslation('byline-admin')
@@ -77,7 +80,7 @@ export const EditView = ({
77
80
  navigate({
78
81
  to: getAdminRoutePath('collections', '$collection', '$id'),
79
82
  params: { collection: path, id: String(initialData.id) },
80
- search: { locale: newLocale },
83
+ search: (prev: Record<string, unknown>) => ({ ...prev, locale: newLocale }),
81
84
  })
82
85
  }
83
86
 
@@ -196,10 +199,13 @@ export const EditView = ({
196
199
  status: 'success',
197
200
  message: t('collections.edit.duplicatedSuccessMessage', { label: singular }),
198
201
  })
199
- // Navigate to the new document's edit view.
202
+ // Navigate to the new document's edit view. Threads the current
203
+ // search (including `from`) forward so closing the duplicate
204
+ // returns to the originating list.
200
205
  navigate({
201
206
  to: getAdminRoutePath('collections', '$collection', '$id'),
202
207
  params: { collection: path, id: result.documentId },
208
+ search: (prev: Record<string, unknown>) => ({ ...prev }),
203
209
  })
204
210
  } catch (err) {
205
211
  console.error('Duplicate error:', err)
@@ -274,7 +280,7 @@ export const EditView = ({
274
280
  navigate({
275
281
  to: getAdminRoutePath('collections', '$collection', '$id'),
276
282
  params: { collection: path, id: String(initialData.id) },
277
- search: { locale: targetLocale },
283
+ search: (prev: Record<string, unknown>) => ({ ...prev, locale: targetLocale }),
278
284
  })
279
285
  } catch (err) {
280
286
  console.error('Copy to locale error:', err)
@@ -326,7 +332,7 @@ export const EditView = ({
326
332
  navigate({
327
333
  to: getAdminRoutePath('collections', '$collection', '$id'),
328
334
  params: { collection: path, id: String(initialData.id) },
329
- search: { locale: defaultContentLocale },
335
+ search: (prev: Record<string, unknown>) => ({ ...prev, locale: defaultContentLocale }),
330
336
  })
331
337
  } catch (err) {
332
338
  console.error('Delete locale error:', err)
@@ -371,6 +377,7 @@ export const EditView = ({
371
377
  navigate({
372
378
  to: getAdminRoutePath('collections', '$collection'),
373
379
  params: { collection: path },
380
+ search: returnSearch,
374
381
  })
375
382
  } catch (err) {
376
383
  console.error('Delete error:', err)
@@ -533,6 +540,7 @@ export const EditView = ({
533
540
  navigate({
534
541
  to: getAdminRoutePath('collections', '$collection'),
535
542
  params: { collection: path },
543
+ search: returnSearch,
536
544
  })
537
545
  }
538
546
  collectionPath={path}
@@ -44,6 +44,8 @@ import {
44
44
  import cx from 'classnames'
45
45
 
46
46
  import { getAdminRoutePath } from '../../routes/admin-path.js'
47
+ import { encodeListReturnState } from '../../routes/list-return-state.js'
48
+ import { setListViewPreference } from '../../server-fns/collections/index.js'
47
49
  import { Link, useNavigate } from '../chrome/loose-router.js'
48
50
  import { RouterPager } from '../chrome/router-pager.js'
49
51
  import { SortAscendingIcon } from '../chrome/sort-icons.js'
@@ -199,6 +201,7 @@ export const ListView = ({
199
201
  // the stored order and "drop between A and B" maps onto the wrong
200
202
  // neighbour ids.
201
203
  const searchParams = location.search as {
204
+ page_size?: number
202
205
  order?: string
203
206
  desc?: boolean
204
207
  query?: string
@@ -269,6 +272,22 @@ export const ListView = ({
269
272
  [workflowStatuses, t]
270
273
  )
271
274
 
275
+ // Same identity-stability rationale as `statusItems` above: this Select is
276
+ // now controlled by `value`, so a fresh `items` array identity on every
277
+ // render would trip Base UI's SelectRoot store-sync loop.
278
+ // 15 is also the list server fn's default page size (see list-view-state.ts)
279
+ // — the default must stay a selectable option so a fresh user with no stored
280
+ // preference sees the value the control claims.
281
+ const pageSizeItems = useMemo(
282
+ () => [
283
+ { value: '15', label: '15' },
284
+ { value: '30', label: '30' },
285
+ { value: '50', label: '50' },
286
+ { value: '100', label: '100' },
287
+ ],
288
+ []
289
+ )
290
+
272
291
  const handleOnSearch = (query: string): void => {
273
292
  if (query != null && query.length > 0) {
274
293
  const params = structuredClone(location.search)
@@ -309,6 +328,21 @@ export const ListView = ({
309
328
  })
310
329
  }
311
330
 
331
+ // Quietly persist sticky view keys as a per-user preference. Fire-and-
332
+ // forget: the navigation already happened, so a failed save must never
333
+ // toast, block, or roll anything back.
334
+ const persistListPreference = (value: {
335
+ page_size?: number
336
+ order?: string
337
+ desc?: boolean
338
+ }): void => {
339
+ setListViewPreference({
340
+ data: { collection: data.included.collection.path as string, value },
341
+ }).catch((err: unknown) => {
342
+ console.warn('list-view preference save failed:', err)
343
+ })
344
+ }
345
+
312
346
  function handleOnPageSizeChange(value: string | null): void {
313
347
  if (typeof value !== 'string' || value.length === 0) return
314
348
  const params = structuredClone(location.search)
@@ -319,6 +353,7 @@ export const ListView = ({
319
353
  params: { collection: data.included.collection.path },
320
354
  search: params,
321
355
  })
356
+ persistListPreference({ page_size: Number.parseInt(value, 10) })
322
357
  }
323
358
 
324
359
  return (
@@ -339,6 +374,7 @@ export const ListView = ({
339
374
  <Link
340
375
  to={getAdminRoutePath('collections', '$collection', 'create')}
341
376
  params={{ collection: data.included.collection.path }}
377
+ search={{ from: encodeListReturnState(location.search as Record<string, unknown>) }}
342
378
  />
343
379
  }
344
380
  >
@@ -460,6 +496,11 @@ export const ListView = ({
460
496
  collection: data.included.collection.path,
461
497
  id: document.id,
462
498
  }}
499
+ search={{
500
+ from: encodeListReturnState(
501
+ location.search as Record<string, unknown>
502
+ ),
503
+ }}
463
504
  >
464
505
  {column.formatter
465
506
  ? renderFormatted(
@@ -509,6 +550,7 @@ export const ListView = ({
509
550
  className={column.className}
510
551
  activeOrder={activeOrder}
511
552
  activeDesc={activeDesc}
553
+ onSort={(order, desc) => persistListPreference({ order, desc })}
512
554
  />
513
555
  )
514
556
  })}
@@ -536,6 +578,11 @@ export const ListView = ({
536
578
  collection: data.included.collection.path,
537
579
  id: document.id,
538
580
  }}
581
+ search={{
582
+ from: encodeListReturnState(
583
+ location.search as Record<string, unknown>
584
+ ),
585
+ }}
539
586
  >
540
587
  {column.formatter
541
588
  ? renderFormatted(
@@ -583,13 +630,8 @@ export const ListView = ({
583
630
  id="page_size"
584
631
  name="page_size"
585
632
  size="sm"
586
- defaultValue="15"
587
- items={[
588
- { value: '15', label: '15' },
589
- { value: '30', label: '30' },
590
- { value: '50', label: '50' },
591
- { value: '100', label: '100' },
592
- ]}
633
+ value={String(searchParams.page_size ?? data?.meta.pageSize ?? 15)}
634
+ items={pageSizeItems}
593
635
  onValueChange={handleOnPageSizeChange}
594
636
  />
595
637
  <RouterPager
@@ -15,14 +15,21 @@ import {
15
15
  getCollectionDefinition,
16
16
  } from '@byline/core'
17
17
  import { useTranslation } from '@byline/i18n/react'
18
+ import { z } from 'zod'
18
19
 
19
20
  import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
20
21
  import { CreateView } from '../admin-shell/collections/create.js'
21
22
  import { getAdminRoutePath } from './admin-path.js'
22
23
 
24
+ const searchSchema = z.object({
25
+ /** URL-encoded list search state to return to on cancel — see list-return-state.ts. */
26
+ from: z.string().optional(),
27
+ })
28
+
23
29
  export function createCollectionCreateRoute(path: string) {
24
30
  // biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
25
31
  const Route: any = createFileRoute(path as never)({
32
+ validateSearch: searchSchema,
26
33
  loader: async ({
27
34
  params,
28
35
  }: {
@@ -46,6 +53,7 @@ export function createCollectionCreateRoute(path: string) {
46
53
  const collectionDef = getCollectionDefinition(collection) as CollectionDefinition
47
54
  const adminConfig = getCollectionAdminConfig(collection)
48
55
  const { t } = useTranslation('byline-admin')
56
+ const search = Route.useSearch() as z.infer<typeof searchSchema>
49
57
  return (
50
58
  <>
51
59
  <BreadcrumbsClient
@@ -65,6 +73,7 @@ export function createCollectionCreateRoute(path: string) {
65
73
  collectionDefinition={collectionDef}
66
74
  adminConfig={adminConfig ?? undefined}
67
75
  initialData={initialData}
76
+ from={search.from}
68
77
  />
69
78
  </>
70
79
  )
@@ -21,11 +21,14 @@ import { EditView } from '../admin-shell/collections/edit.js'
21
21
  import { getCollectionDocument } from '../server-fns/collections/index.js'
22
22
  import { getAdminRoutePath } from './admin-path.js'
23
23
  import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
24
+ import { decodeListReturnState } from './list-return-state.js'
24
25
 
25
26
  const searchSchema = z.object({
26
27
  locale: z.string().optional(),
27
28
  /** Set by the create view's create → edit redirect; fires the created toast. */
28
29
  action: z.enum(['created']).optional(),
30
+ /** URL-encoded list search state to return to on close — see list-return-state.ts. */
31
+ from: z.string().optional(),
29
32
  })
30
33
 
31
34
  export function createCollectionEditRoute(path: string) {
@@ -135,6 +138,7 @@ export function createCollectionEditRoute(path: string) {
135
138
  locale={locale}
136
139
  contentLocales={contentLocales}
137
140
  defaultContentLocale={defaultContentLocale}
141
+ returnSearch={decodeListReturnState(search.from)}
138
142
  />
139
143
  </>
140
144
  )
@@ -18,7 +18,7 @@ import {
18
18
  } from '@byline/core'
19
19
  import { useTranslation } from '@byline/i18n/react'
20
20
  import { useToastManager } from '@byline/ui/react'
21
- import { z } from 'zod'
21
+ import type { z } from 'zod'
22
22
 
23
23
  import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
24
24
  import { useNavigate } from '../admin-shell/chrome/loose-router.js'
@@ -31,19 +31,9 @@ import {
31
31
  reorderCollectionDocument,
32
32
  } from '../server-fns/collections/index.js'
33
33
  import { getAdminRoutePath } from './admin-path.js'
34
+ import { collectionListSearchSchema as searchSchema } from './list-return-state.js'
34
35
  import type { CollectionTreeRow } from '../server-fns/collections/tree.js'
35
36
 
36
- const searchSchema = z.object({
37
- page: z.coerce.number().min(1).optional(),
38
- page_size: z.coerce.number().max(100).optional(),
39
- order: z.string().optional(),
40
- desc: z.coerce.boolean().optional(),
41
- query: z.string().optional(),
42
- locale: z.string().optional(),
43
- status: z.string().optional(),
44
- action: z.enum(['created']).optional(),
45
- })
46
-
47
37
  export function createCollectionListRoute(path: string) {
48
38
  // biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
49
39
  const Route: any = createFileRoute(path as never)({
@@ -0,0 +1,55 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { decodeListReturnState, encodeListReturnState } from './list-return-state.js'
12
+
13
+ describe('encodeListReturnState / decodeListReturnState', () => {
14
+ it('round-trips a full list search state', () => {
15
+ const search = {
16
+ page: 7,
17
+ page_size: 50,
18
+ order: 'title',
19
+ desc: false,
20
+ query: 'harvest',
21
+ locale: 'fr',
22
+ status: 'draft',
23
+ }
24
+ const encoded = encodeListReturnState(search)
25
+ expect(typeof encoded).toBe('string')
26
+ expect(decodeListReturnState(encoded)).toEqual(search)
27
+ })
28
+
29
+ it('returns undefined for an empty search (no point carrying a bare target)', () => {
30
+ expect(encodeListReturnState({})).toBeUndefined()
31
+ })
32
+
33
+ it('never carries the transient action param', () => {
34
+ const encoded = encodeListReturnState({ page: 2, action: 'created' })
35
+ expect(decodeListReturnState(encoded)).toEqual({ page: 2 })
36
+ })
37
+
38
+ it('decodes desc=false as boolean false (not string-coerced truthiness)', () => {
39
+ const decoded = decodeListReturnState('order=title&desc=false')
40
+ expect(decoded).toEqual({ order: 'title', desc: false })
41
+ })
42
+
43
+ it('degrades malformed input to undefined instead of throwing', () => {
44
+ expect(decodeListReturnState(undefined)).toBeUndefined()
45
+ expect(decodeListReturnState('')).toBeUndefined()
46
+ expect(decodeListReturnState('page=notanumber')).toBeUndefined()
47
+ expect(decodeListReturnState('page=0')).toBeUndefined()
48
+ expect(decodeListReturnState('page_size=99999')).toBeUndefined()
49
+ })
50
+
51
+ it('drops empty-string values at encode time', () => {
52
+ const encoded = encodeListReturnState({ page: 3, query: '' })
53
+ expect(decodeListReturnState(encoded)).toEqual({ page: 3 })
54
+ })
55
+ })