@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.
- package/dist/admin-shell/chrome/th-sortable.d.ts +8 -1
- package/dist/admin-shell/chrome/th-sortable.js +2 -1
- package/dist/admin-shell/collections/create.d.ts +3 -1
- package/dist/admin-shell/collections/create.js +7 -3
- package/dist/admin-shell/collections/edit.d.ts +3 -1
- package/dist/admin-shell/collections/edit.js +21 -13
- package/dist/admin-shell/collections/list.js +49 -20
- package/dist/routes/create-collection-create-route.js +8 -1
- package/dist/routes/create-collection-edit-route.js +5 -2
- package/dist/routes/create-collection-list-route.js +2 -14
- package/dist/routes/list-return-state.d.ts +45 -0
- package/dist/routes/list-return-state.js +43 -0
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/list-view-state.d.ts +45 -0
- package/dist/server-fns/collections/list-view-state.js +59 -0
- package/dist/server-fns/collections/list.js +37 -14
- package/dist/server-fns/collections/set-list-view-preference.d.ts +18 -0
- package/dist/server-fns/collections/set-list-view-preference.js +32 -0
- package/package.json +8 -8
- package/src/admin-shell/chrome/th-sortable.tsx +9 -0
- package/src/admin-shell/collections/create.tsx +12 -5
- package/src/admin-shell/collections/edit.tsx +12 -4
- package/src/admin-shell/collections/list.tsx +49 -7
- package/src/routes/create-collection-create-route.tsx +9 -0
- package/src/routes/create-collection-edit-route.tsx +4 -0
- package/src/routes/create-collection-list-route.tsx +2 -12
- package/src/routes/list-return-state.test.node.ts +55 -0
- package/src/routes/list-return-state.ts +77 -0
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/list-view-state.test.node.ts +141 -0
- package/src/server-fns/collections/list-view-state.ts +137 -0
- package/src/server-fns/collections/list.ts +51 -26
- package/src/server-fns/collections/set-list-view-preference.ts +67 -0
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Return-to-list state for the collection editor (issue #17).
|
|
11
|
+
*
|
|
12
|
+
* The list route holds its complete view state in URL search params, so
|
|
13
|
+
* "come back to page 7 of the filtered list" needs no storage: the list
|
|
14
|
+
* encodes its current search into a single `from` param on the editor
|
|
15
|
+
* link, the editor threads it through its own navigations, and
|
|
16
|
+
* close/delete decode it back into list search params. Malformed or
|
|
17
|
+
* absent state degrades to the bare list — never an error.
|
|
18
|
+
*
|
|
19
|
+
* The list route's search schema lives here (rather than in the route
|
|
20
|
+
* factory) so the helpers and the factory share one definition without
|
|
21
|
+
* a circular import.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { z } from 'zod'
|
|
25
|
+
|
|
26
|
+
export const collectionListSearchSchema = z.object({
|
|
27
|
+
page: z.coerce.number().min(1).optional(),
|
|
28
|
+
page_size: z.coerce.number().max(100).optional(),
|
|
29
|
+
order: z.string().optional(),
|
|
30
|
+
desc: z.coerce.boolean().optional(),
|
|
31
|
+
query: z.string().optional(),
|
|
32
|
+
locale: z.string().optional(),
|
|
33
|
+
status: z.string().optional(),
|
|
34
|
+
action: z.enum(['created']).optional(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export type CollectionListSearch = z.infer<typeof collectionListSearchSchema>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The keys a return target carries. `action` is transient (post-create
|
|
41
|
+
* toast trigger) and must never round-trip through a return target.
|
|
42
|
+
*/
|
|
43
|
+
const RETURN_KEYS = ['page', 'page_size', 'order', 'desc', 'query', 'locale', 'status'] as const
|
|
44
|
+
|
|
45
|
+
/** `undefined` when there is nothing worth carrying (bare list). */
|
|
46
|
+
export function encodeListReturnState(search: Record<string, unknown>): string | undefined {
|
|
47
|
+
const params = new URLSearchParams()
|
|
48
|
+
for (const key of RETURN_KEYS) {
|
|
49
|
+
const value = search[key]
|
|
50
|
+
if (value != null && value !== '') params.set(key, String(value))
|
|
51
|
+
}
|
|
52
|
+
const encoded = params.toString()
|
|
53
|
+
return encoded.length > 0 ? encoded : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse a `from` param back into list search params. Runs the decoded
|
|
58
|
+
* pairs through the list search schema; anything malformed returns
|
|
59
|
+
* `undefined` (→ bare list). `desc` needs explicit normalisation
|
|
60
|
+
* because `z.coerce.boolean()` would coerce the string `'false'` to
|
|
61
|
+
* `true`.
|
|
62
|
+
*/
|
|
63
|
+
export function decodeListReturnState(
|
|
64
|
+
from: string | undefined
|
|
65
|
+
): Record<string, unknown> | undefined {
|
|
66
|
+
if (from == null || from.length === 0) return undefined
|
|
67
|
+
const raw: Record<string, unknown> = Object.fromEntries(new URLSearchParams(from))
|
|
68
|
+
delete raw.action
|
|
69
|
+
if (typeof raw.desc === 'string') raw.desc = raw.desc === 'true'
|
|
70
|
+
const parsed = collectionListSearchSchema.safeParse(raw)
|
|
71
|
+
if (!parsed.success) return undefined
|
|
72
|
+
const result: Record<string, unknown> = {}
|
|
73
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
74
|
+
if (value !== undefined) result[key] = value
|
|
75
|
+
}
|
|
76
|
+
return Object.keys(result).length > 0 ? result : undefined
|
|
77
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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 { resolveListViewState, sortableFieldNames } from './list-view-state.js'
|
|
12
|
+
|
|
13
|
+
const base = {
|
|
14
|
+
params: {},
|
|
15
|
+
preference: null,
|
|
16
|
+
orderable: false,
|
|
17
|
+
sortableFields: ['title', 'summary', 'created_at', 'updated_at'],
|
|
18
|
+
configuredSort: undefined,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('resolveListViewState', () => {
|
|
22
|
+
it('defaults to page size 15 and no sort when nothing is set', () => {
|
|
23
|
+
expect(resolveListViewState({ ...base })).toEqual({ pageSize: 15 })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('lets explicit URL params win over preference and configured sort', () => {
|
|
27
|
+
const result = resolveListViewState({
|
|
28
|
+
...base,
|
|
29
|
+
params: { page_size: 15, order: 'title', desc: false },
|
|
30
|
+
preference: { page_size: 100, order: 'summary', desc: true },
|
|
31
|
+
configuredSort: { order: 'created_at', desc: true },
|
|
32
|
+
})
|
|
33
|
+
expect(result).toEqual({
|
|
34
|
+
pageSize: 15,
|
|
35
|
+
sort: { title: 'asc' },
|
|
36
|
+
metaOrder: 'title',
|
|
37
|
+
metaDesc: false,
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('preserves the pre-existing "omitted desc means descending" param semantics', () => {
|
|
42
|
+
const result = resolveListViewState({ ...base, params: { order: 'title' } })
|
|
43
|
+
expect(result.sort).toEqual({ title: 'desc' })
|
|
44
|
+
// meta passthrough stays exactly what the URL carried (undefined here).
|
|
45
|
+
expect(result.metaOrder).toBe('title')
|
|
46
|
+
expect(result.metaDesc).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('applies the preference on a params-less landing', () => {
|
|
50
|
+
const result = resolveListViewState({
|
|
51
|
+
...base,
|
|
52
|
+
preference: { page_size: 50, order: 'title', desc: true },
|
|
53
|
+
})
|
|
54
|
+
expect(result).toEqual({
|
|
55
|
+
pageSize: 50,
|
|
56
|
+
sort: { title: 'desc' },
|
|
57
|
+
metaOrder: 'title',
|
|
58
|
+
metaDesc: true,
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('skips a stale preference order and falls through to configuredSort', () => {
|
|
63
|
+
const result = resolveListViewState({
|
|
64
|
+
...base,
|
|
65
|
+
preference: { order: 'removed_field', desc: true },
|
|
66
|
+
configuredSort: { order: 'created_at', desc: true },
|
|
67
|
+
})
|
|
68
|
+
expect(result.sort).toEqual({ created_at: 'desc' })
|
|
69
|
+
expect(result.metaOrder).toBe('created_at')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('ignores an out-of-range or non-integer preference page_size', () => {
|
|
73
|
+
expect(resolveListViewState({ ...base, preference: { page_size: 0 } }).pageSize).toBe(15)
|
|
74
|
+
expect(resolveListViewState({ ...base, preference: { page_size: 999 } }).pageSize).toBe(15)
|
|
75
|
+
expect(resolveListViewState({ ...base, preference: { page_size: 12.5 } }).pageSize).toBe(15)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('applies page_size but never sort preferences on orderable collections', () => {
|
|
79
|
+
const result = resolveListViewState({
|
|
80
|
+
...base,
|
|
81
|
+
orderable: true,
|
|
82
|
+
preference: { page_size: 50, order: 'title', desc: true },
|
|
83
|
+
})
|
|
84
|
+
expect(result).toEqual({ pageSize: 50, sort: { order_key: 'asc' } })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('still lets explicit params override the drag order on orderable collections', () => {
|
|
88
|
+
const result = resolveListViewState({
|
|
89
|
+
...base,
|
|
90
|
+
orderable: true,
|
|
91
|
+
params: { order: 'title', desc: true },
|
|
92
|
+
})
|
|
93
|
+
expect(result.sort).toEqual({ title: 'desc' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('combines a page_size-only preference with configuredSort', () => {
|
|
97
|
+
const result = resolveListViewState({
|
|
98
|
+
...base,
|
|
99
|
+
preference: { page_size: 30 },
|
|
100
|
+
configuredSort: { order: 'created_at', desc: true },
|
|
101
|
+
})
|
|
102
|
+
expect(result).toEqual({
|
|
103
|
+
pageSize: 30,
|
|
104
|
+
sort: { created_at: 'desc' },
|
|
105
|
+
metaOrder: 'created_at',
|
|
106
|
+
metaDesc: true,
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('sortableFieldNames', () => {
|
|
112
|
+
it('keeps scalar-store fields plus system columns; drops structure, json, file, and relation fields', () => {
|
|
113
|
+
const fields = [
|
|
114
|
+
{ name: 'title', type: 'text' },
|
|
115
|
+
{ name: 'viewCount', type: 'integer' },
|
|
116
|
+
{ name: 'publishedOn', type: 'datetime' },
|
|
117
|
+
{ name: 'featured', type: 'checkbox' },
|
|
118
|
+
{ name: 'content', type: 'blocks' },
|
|
119
|
+
{ name: 'gallery', type: 'array' },
|
|
120
|
+
{ name: 'body', type: 'richText' },
|
|
121
|
+
{ name: 'featureImage', type: 'image' },
|
|
122
|
+
{ name: 'category', type: 'relation' },
|
|
123
|
+
{ name: 'meta', type: 'group' },
|
|
124
|
+
]
|
|
125
|
+
expect(sortableFieldNames(fields)).toEqual([
|
|
126
|
+
'title',
|
|
127
|
+
'viewCount',
|
|
128
|
+
'publishedOn',
|
|
129
|
+
'featured',
|
|
130
|
+
'created_at',
|
|
131
|
+
'updated_at',
|
|
132
|
+
])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('returns only the system columns for a collection with no sortable fields', () => {
|
|
136
|
+
expect(sortableFieldNames([{ name: 'content', type: 'blocks' }])).toEqual([
|
|
137
|
+
'created_at',
|
|
138
|
+
'updated_at',
|
|
139
|
+
])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Pure precedence resolver for the collection list view's page size and
|
|
11
|
+
* sort — extracted from the list server fn so the chain is testable
|
|
12
|
+
* without a database:
|
|
13
|
+
*
|
|
14
|
+
* URL params → user preference → configured defaultSort → storage
|
|
15
|
+
* default (created_at desc, expressed as "no sort spec")
|
|
16
|
+
*
|
|
17
|
+
* `orderable: true` collections sort by the drag order (`order_key asc`)
|
|
18
|
+
* and never take sort preferences or a configured default — but explicit
|
|
19
|
+
* URL params still win (a shared sorted link opens as sent), and a
|
|
20
|
+
* page-size preference still applies.
|
|
21
|
+
*
|
|
22
|
+
* A preference `order` naming a field that is no longer sortable on the
|
|
23
|
+
* collection is skipped (not an error): the schema may have moved on
|
|
24
|
+
* since the preference was written.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { fieldTypeToStore } from '@byline/core'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Store types whose value column supports ORDER BY. Structure fields
|
|
31
|
+
* (group/array/blocks) and json/file/relation fields are excluded — a
|
|
32
|
+
* preference naming one (hand-edited, or stale after a schema change)
|
|
33
|
+
* must be skipped rather than sent to the sort compiler.
|
|
34
|
+
*/
|
|
35
|
+
const SORTABLE_STORE_TYPES: ReadonlySet<string> = new Set([
|
|
36
|
+
'text',
|
|
37
|
+
'numeric',
|
|
38
|
+
'boolean',
|
|
39
|
+
'datetime',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The field names a stored sort preference may legitimately name: fields
|
|
44
|
+
* whose store type carries an orderable scalar column, plus the document
|
|
45
|
+
* system columns.
|
|
46
|
+
*/
|
|
47
|
+
export function sortableFieldNames(fields: Array<{ name: string; type: string }>): string[] {
|
|
48
|
+
return [
|
|
49
|
+
...fields
|
|
50
|
+
.filter((f) => SORTABLE_STORE_TYPES.has(fieldTypeToStore[f.type]?.storeType ?? ''))
|
|
51
|
+
.map((f) => f.name),
|
|
52
|
+
'created_at',
|
|
53
|
+
'updated_at',
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ListViewPreferenceValue {
|
|
58
|
+
page_size?: number
|
|
59
|
+
order?: string
|
|
60
|
+
desc?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ResolveListViewStateInput {
|
|
64
|
+
params: { page_size?: number; order?: string; desc?: boolean }
|
|
65
|
+
preference: ListViewPreferenceValue | null
|
|
66
|
+
orderable: boolean
|
|
67
|
+
/** Field names valid as a sort column (collection fields + system columns). */
|
|
68
|
+
sortableFields: string[]
|
|
69
|
+
configuredSort?: { order: string; desc: boolean }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ResolvedListViewState {
|
|
73
|
+
pageSize: number
|
|
74
|
+
/** Sort spec for `CollectionHandle.find`; undefined → storage default. */
|
|
75
|
+
sort?: Record<string, 'asc' | 'desc'>
|
|
76
|
+
/** Effective sort echoed through `meta.order` / `meta.desc` for the header indicator. */
|
|
77
|
+
metaOrder?: string
|
|
78
|
+
metaDesc?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveListViewState(input: ResolveListViewStateInput): ResolvedListViewState {
|
|
82
|
+
const { params, preference, orderable, sortableFields, configuredSort } = input
|
|
83
|
+
|
|
84
|
+
// Defensive clamp at read time — the write path validates 1–100, but a
|
|
85
|
+
// hand-edited row must degrade to the default, not a absurd page.
|
|
86
|
+
const rawPrefPageSize = preference?.page_size
|
|
87
|
+
const prefPageSize =
|
|
88
|
+
typeof rawPrefPageSize === 'number' &&
|
|
89
|
+
Number.isInteger(rawPrefPageSize) &&
|
|
90
|
+
rawPrefPageSize >= 1 &&
|
|
91
|
+
rawPrefPageSize <= 100
|
|
92
|
+
? rawPrefPageSize
|
|
93
|
+
: undefined
|
|
94
|
+
const pageSize = params.page_size ?? prefPageSize ?? 15
|
|
95
|
+
|
|
96
|
+
// Explicit URL params always win. Semantics preserved from the original
|
|
97
|
+
// inline code: an omitted `desc` alongside an explicit `order` sorts
|
|
98
|
+
// descending, and the meta echo passes the raw param values through.
|
|
99
|
+
if (params.order) {
|
|
100
|
+
return {
|
|
101
|
+
pageSize,
|
|
102
|
+
sort: { [params.order]: params.desc === false ? 'asc' : 'desc' },
|
|
103
|
+
metaOrder: params.order,
|
|
104
|
+
metaDesc: params.desc,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (orderable) {
|
|
109
|
+
return { pageSize, sort: { order_key: 'asc' } }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rawPrefOrder = preference?.order
|
|
113
|
+
const prefOrder =
|
|
114
|
+
typeof rawPrefOrder === 'string' && sortableFields.includes(rawPrefOrder)
|
|
115
|
+
? rawPrefOrder
|
|
116
|
+
: undefined
|
|
117
|
+
if (prefOrder != null) {
|
|
118
|
+
const desc = preference?.desc === true
|
|
119
|
+
return {
|
|
120
|
+
pageSize,
|
|
121
|
+
sort: { [prefOrder]: desc ? 'desc' : 'asc' },
|
|
122
|
+
metaOrder: prefOrder,
|
|
123
|
+
metaDesc: desc,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (configuredSort != null) {
|
|
128
|
+
return {
|
|
129
|
+
pageSize,
|
|
130
|
+
sort: { [configuredSort.order]: configuredSort.desc ? 'desc' : 'asc' },
|
|
131
|
+
metaOrder: configuredSort.order,
|
|
132
|
+
metaDesc: configuredSort.desc,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { pageSize }
|
|
137
|
+
}
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { createServerFn } from '@tanstack/react-start'
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { getPreferenceCommand } from '@byline/admin/admin-preferences'
|
|
12
|
+
import { getAdminBylineClient, getAdminRequestContext } from '@byline/client/server'
|
|
12
13
|
import {
|
|
13
14
|
buildRelationSummaryPopulateMap,
|
|
14
15
|
ERR_NOT_FOUND,
|
|
@@ -22,7 +23,10 @@ import {
|
|
|
22
23
|
} from '@byline/core'
|
|
23
24
|
|
|
24
25
|
import { ensureCollection } from '../../integrations/api-utils.js'
|
|
26
|
+
import { bylineCore } from '../../integrations/byline-core.js'
|
|
27
|
+
import { resolveListViewState, sortableFieldNames } from './list-view-state.js'
|
|
25
28
|
import { serialise } from './utils'
|
|
29
|
+
import type { ListViewPreferenceValue } from './list-view-state.js'
|
|
26
30
|
|
|
27
31
|
// ---------------------------------------------------------------------------
|
|
28
32
|
// Shared param types
|
|
@@ -57,7 +61,6 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
|
|
|
57
61
|
|
|
58
62
|
const client = getAdminBylineClient()
|
|
59
63
|
const handle = client.collection(path)
|
|
60
|
-
const pageSize = params.page_size ?? 20
|
|
61
64
|
|
|
62
65
|
// Routes through CollectionHandle.find so the read pipeline (beforeRead
|
|
63
66
|
// → findDocuments → afterRead) is identical to any non-admin client.
|
|
@@ -69,30 +72,51 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
|
|
|
69
72
|
if (params.status) where.status = params.status
|
|
70
73
|
if (params.query) where.query = params.query
|
|
71
74
|
|
|
72
|
-
// Sort precedence: the caller's
|
|
73
|
-
// shared link opens exactly as sent) →
|
|
74
|
-
//
|
|
75
|
-
// `defaultSort`
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// render the effective sort indicator on a params-less landing.
|
|
75
|
+
// Sort/page-size precedence (see list-view-state.ts): the caller's
|
|
76
|
+
// explicit params always win (a shared link opens exactly as sent) →
|
|
77
|
+
// the actor's stored per-collection preference → the admin config's
|
|
78
|
+
// `defaultSort` → the storage fallback (`created_at desc`). The
|
|
79
|
+
// effective sort is echoed through `meta.order`/`meta.desc` below so
|
|
80
|
+
// the list header renders the right indicator on a params-less landing.
|
|
79
81
|
const adminConfig = getCollectionAdminConfig(path)
|
|
80
82
|
const configuredSort =
|
|
81
|
-
|
|
83
|
+
config.definition.orderable !== true && adminConfig?.defaultSort != null
|
|
82
84
|
? {
|
|
83
85
|
order: String(adminConfig.defaultSort.field),
|
|
84
86
|
desc: adminConfig.defaultSort.direction === 'desc',
|
|
85
87
|
}
|
|
86
88
|
: undefined
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
|
|
90
|
+
// Per-user preference — read only when it could matter (some param
|
|
91
|
+
// absent). Failures (headless context, unauthenticated preview, DB
|
|
92
|
+
// hiccup) log and fall through: preferences can never break the list.
|
|
93
|
+
let preference: ListViewPreferenceValue | null = null
|
|
94
|
+
const adminStore = bylineCore().adminStore
|
|
95
|
+
if (adminStore != null && (params.page_size == null || params.order == null)) {
|
|
96
|
+
try {
|
|
97
|
+
const context = await getAdminRequestContext()
|
|
98
|
+
const res = await getPreferenceCommand(
|
|
99
|
+
context,
|
|
100
|
+
{ scope: `collections.${path}.list` },
|
|
101
|
+
{ store: adminStore }
|
|
102
|
+
)
|
|
103
|
+
preference = (res.value as ListViewPreferenceValue | null) ?? null
|
|
104
|
+
} catch (err) {
|
|
105
|
+
getLogger().warn(
|
|
106
|
+
{ err, collection: path },
|
|
107
|
+
'list-view preference read failed — using defaults'
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const viewState = resolveListViewState({
|
|
113
|
+
params: { page_size: params.page_size, order: params.order, desc: params.desc },
|
|
114
|
+
preference,
|
|
115
|
+
orderable: config.definition.orderable === true,
|
|
116
|
+
sortableFields: sortableFieldNames(config.definition.fields),
|
|
117
|
+
configuredSort,
|
|
118
|
+
})
|
|
119
|
+
const pageSize = viewState.pageSize
|
|
96
120
|
|
|
97
121
|
// Auto-populate relation columns (depth 1) so the list renders each
|
|
98
122
|
// target's title (via `relationColumnFormatter`) rather than a raw
|
|
@@ -109,7 +133,7 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
|
|
|
109
133
|
|
|
110
134
|
const result = await handle.find({
|
|
111
135
|
where: Object.keys(where).length > 0 ? where : undefined,
|
|
112
|
-
sort:
|
|
136
|
+
sort: viewState.sort,
|
|
113
137
|
locale: params.locale ?? 'en',
|
|
114
138
|
page: params.page,
|
|
115
139
|
pageSize,
|
|
@@ -147,12 +171,13 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
|
|
|
147
171
|
docs,
|
|
148
172
|
meta: {
|
|
149
173
|
...result.meta,
|
|
150
|
-
// The *effective* sort: explicit params, or the
|
|
151
|
-
// `defaultSort` when
|
|
152
|
-
// sort indicator from this, so a
|
|
153
|
-
// which column ordered the rows.
|
|
154
|
-
order:
|
|
155
|
-
desc:
|
|
174
|
+
// The *effective* sort/page-size: explicit params, or the
|
|
175
|
+
// resolved preference/configured `defaultSort` when they filled
|
|
176
|
+
// in. The list header renders its sort indicator from this, so a
|
|
177
|
+
// params-less landing still shows which column ordered the rows.
|
|
178
|
+
order: viewState.metaOrder,
|
|
179
|
+
desc: viewState.metaDesc,
|
|
180
|
+
pageSize,
|
|
156
181
|
},
|
|
157
182
|
included: {
|
|
158
183
|
collection: {
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Persist a per-user list-view preference for one collection.
|
|
11
|
+
*
|
|
12
|
+
* Fire-and-forget from the list UI: changing page size or clicking a
|
|
13
|
+
* column sort quietly upserts the sticky keys for
|
|
14
|
+
* `collections.<path>.list`. The client sends only the keys the
|
|
15
|
+
* interaction changed; the repository merges per key, so a page-size
|
|
16
|
+
* change never wipes a stored sort.
|
|
17
|
+
*
|
|
18
|
+
* Self-service: the target user is always the authenticated actor.
|
|
19
|
+
* `setPreferenceCommand` rejects unauthenticated contexts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
23
|
+
|
|
24
|
+
import { setPreferenceCommand } from '@byline/admin/admin-preferences'
|
|
25
|
+
import { getAdminRequestContext } from '@byline/client/server'
|
|
26
|
+
import { ERR_NOT_FOUND, getLogger } from '@byline/core'
|
|
27
|
+
|
|
28
|
+
import { ensureCollection } from '../../integrations/api-utils.js'
|
|
29
|
+
import { bylineCore } from '../../integrations/byline-core.js'
|
|
30
|
+
|
|
31
|
+
export interface SetListViewPreferenceInput {
|
|
32
|
+
collection: string
|
|
33
|
+
value: {
|
|
34
|
+
page_size?: number
|
|
35
|
+
order?: string
|
|
36
|
+
desc?: boolean
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const setListViewPreference = createServerFn({ method: 'POST' })
|
|
41
|
+
.validator((input: SetListViewPreferenceInput) => input)
|
|
42
|
+
.handler(async ({ data }) => {
|
|
43
|
+
// Scopes are keyed by collection path — refuse to mint rows for
|
|
44
|
+
// collections that don't exist, so a scripted caller cannot persist
|
|
45
|
+
// junk scopes (even self-scoped ones).
|
|
46
|
+
const config = await ensureCollection(data.collection)
|
|
47
|
+
if (!config) {
|
|
48
|
+
throw ERR_NOT_FOUND({
|
|
49
|
+
message: 'Collection not found',
|
|
50
|
+
details: { collectionPath: data.collection },
|
|
51
|
+
}).log(getLogger())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const adminStore = bylineCore().adminStore
|
|
55
|
+
if (adminStore == null) {
|
|
56
|
+
// Headless hosts without an admin store have no preference storage —
|
|
57
|
+
// the save is a silent no-op, mirroring set-locale's posture.
|
|
58
|
+
return { ok: true as const }
|
|
59
|
+
}
|
|
60
|
+
const context = await getAdminRequestContext()
|
|
61
|
+
await setPreferenceCommand(
|
|
62
|
+
context,
|
|
63
|
+
{ scope: `collections.${data.collection}.list`, value: data.value },
|
|
64
|
+
{ store: adminStore }
|
|
65
|
+
)
|
|
66
|
+
return { ok: true as const }
|
|
67
|
+
})
|