@byline/host-tanstack-start 6.2.1 → 6.4.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/breadcrumbs/breadcrumbs-provider.js +13 -2
- package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.js +2 -6
- package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.test.d.ts +8 -0
- package/dist/admin-shell/collections/api.js +3 -2
- package/dist/admin-shell/collections/edit.js +3 -2
- package/dist/admin-shell/collections/restore-version-modal.js +19 -0
- package/dist/admin-shell/describe-mutation-failure.d.ts +18 -0
- package/dist/admin-shell/describe-mutation-failure.js +9 -0
- package/dist/admin-shell/singletons/view.js +2 -1
- package/dist/integrations/byline-field-services.d.ts +19 -0
- package/dist/integrations/byline-field-services.js +18 -2
- package/dist/routes/create-admin-layout-route.js +6 -3
- package/dist/server-fns/collections/create.js +4 -2
- package/dist/server-fns/collections/restore-version.d.ts +1 -0
- package/dist/server-fns/collections/restore-version.js +4 -1
- package/dist/server-fns/collections/update.js +3 -2
- package/dist/server-fns/document-mutation-errors.js +7 -1
- package/dist/server-fns/document-write-input.d.ts +9 -0
- package/dist/server-fns/document-write-input.js +44 -0
- package/dist/server-fns/singletons/restore-version.d.ts +1 -1
- package/package.json +16 -16
- package/src/admin-shell/chrome/breadcrumbs/breadcrumbs-provider.tsx +34 -4
- package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.test.tsx +274 -0
- package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.tsx +7 -7
- package/src/admin-shell/collections/api.tsx +17 -2
- package/src/admin-shell/collections/edit.tsx +3 -2
- package/src/admin-shell/collections/restore-version-modal.tsx +26 -0
- package/src/admin-shell/describe-mutation-failure.ts +30 -0
- package/src/admin-shell/singletons/view.tsx +2 -1
- package/src/integrations/byline-field-services.test.node.ts +129 -0
- package/src/integrations/byline-field-services.ts +37 -0
- package/src/routes/create-admin-layout-route.tsx +17 -6
- package/src/server-fns/collections/create.ts +5 -2
- package/src/server-fns/collections/restore-version.ts +4 -0
- package/src/server-fns/collections/update.ts +3 -2
- package/src/server-fns/document-mutation-errors.ts +8 -0
- package/src/server-fns/document-mutation-transport.test.node.ts +41 -0
- package/src/server-fns/document-write-input.test.node.ts +48 -0
- package/src/server-fns/document-write-input.ts +67 -0
|
@@ -0,0 +1,274 @@
|
|
|
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 { act, type ComponentProps, type ReactNode } from 'react'
|
|
10
|
+
|
|
11
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
|
|
14
|
+
vi.mock('../loose-router.js', () => ({
|
|
15
|
+
Link: ({ to, children, ...rest }: { to?: string; children?: ReactNode }) => (
|
|
16
|
+
<a href={to} {...rest}>
|
|
17
|
+
{children}
|
|
18
|
+
</a>
|
|
19
|
+
),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
vi.mock('@byline/i18n/react', () => ({
|
|
23
|
+
useTranslation: () => ({ t: (key: string) => key }),
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
vi.mock('@byline/ui/react', () => ({
|
|
27
|
+
EllipsisIcon: (props: ComponentProps<'span'>) => <span {...props} />,
|
|
28
|
+
Dropdown: {
|
|
29
|
+
Root: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
|
30
|
+
Trigger: ({ children, ...rest }: ComponentProps<'button'>) => (
|
|
31
|
+
<button type="button" {...rest}>
|
|
32
|
+
{children}
|
|
33
|
+
</button>
|
|
34
|
+
),
|
|
35
|
+
Portal: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
|
36
|
+
Content: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
|
37
|
+
Item: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
|
38
|
+
},
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
const { Breadcrumbs } = await import('./breadcrumbs.js')
|
|
42
|
+
const { BreadcrumbsProvider, useBreadcrumbs } = await import('./breadcrumbs-provider.js')
|
|
43
|
+
type Breadcrumb = import('./@types.js').Breadcrumb
|
|
44
|
+
|
|
45
|
+
// jsdom performs no layout, so every width would read as 0 and the collapse
|
|
46
|
+
// branch could never be exercised. Derive a deterministic width from rendered
|
|
47
|
+
// text instead, and let each test drive the container width directly.
|
|
48
|
+
const CHAR_WIDTH = 10
|
|
49
|
+
const ITEM_PADDING = 20
|
|
50
|
+
let containerWidth = 1000
|
|
51
|
+
let resizeCallbacks: Array<() => void> = []
|
|
52
|
+
|
|
53
|
+
function installLayoutStubs() {
|
|
54
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
|
55
|
+
configurable: true,
|
|
56
|
+
get(this: HTMLElement) {
|
|
57
|
+
return (this.textContent?.length ?? 0) * CHAR_WIDTH + ITEM_PADDING
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
|
61
|
+
configurable: true,
|
|
62
|
+
get(this: HTMLElement) {
|
|
63
|
+
return this.tagName === 'NAV' ? containerWidth : 0
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
|
67
|
+
() => ({ gap: '4px' }) as unknown as CSSStyleDeclaration
|
|
68
|
+
)
|
|
69
|
+
vi.stubGlobal(
|
|
70
|
+
'ResizeObserver',
|
|
71
|
+
class {
|
|
72
|
+
constructor(callback: () => void) {
|
|
73
|
+
resizeCallbacks.push(callback)
|
|
74
|
+
}
|
|
75
|
+
observe() {}
|
|
76
|
+
disconnect() {}
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let root: Root
|
|
82
|
+
let container: HTMLDivElement
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
86
|
+
containerWidth = 1000
|
|
87
|
+
resizeCallbacks = []
|
|
88
|
+
installLayoutStubs()
|
|
89
|
+
container = document.createElement('div')
|
|
90
|
+
document.body.append(container)
|
|
91
|
+
root = createRoot(container)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
act(() => root.unmount())
|
|
96
|
+
container.remove()
|
|
97
|
+
vi.restoreAllMocks()
|
|
98
|
+
vi.unstubAllGlobals()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/** Labels in the live trail, excluding the hidden measurement layer. */
|
|
102
|
+
function visibleLabels(): string[] {
|
|
103
|
+
const list = container.querySelector('nav > ul')
|
|
104
|
+
if (!list) return []
|
|
105
|
+
return [...list.querySelectorAll('li')].map((li) => li.textContent?.trim() ?? '')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isCollapsed(): boolean {
|
|
109
|
+
return container.querySelector('nav > ul button') != null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const trail = (n: number): Breadcrumb[] =>
|
|
113
|
+
Array.from({ length: n }, (_, i) => ({ label: `Section${i}`, href: `/section-${i}` }))
|
|
114
|
+
|
|
115
|
+
describe('breadcrumbs provider equality', () => {
|
|
116
|
+
it('keeps the previous settings object when equivalent breadcrumbs are submitted', () => {
|
|
117
|
+
const received: unknown[] = []
|
|
118
|
+
function Consumer() {
|
|
119
|
+
const { breadCrumbSettings } = useBreadcrumbs()
|
|
120
|
+
received.push(breadCrumbSettings)
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
let setBreadcrumbs!: ReturnType<typeof useBreadcrumbs>['setBreadcrumbs']
|
|
124
|
+
function Setter() {
|
|
125
|
+
setBreadcrumbs = useBreadcrumbs().setBreadcrumbs
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
act(() =>
|
|
129
|
+
root.render(
|
|
130
|
+
<BreadcrumbsProvider>
|
|
131
|
+
<Setter />
|
|
132
|
+
<Consumer />
|
|
133
|
+
</BreadcrumbsProvider>
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const settings = {
|
|
138
|
+
homeLabel: 'Home',
|
|
139
|
+
homePath: '/admin',
|
|
140
|
+
breadcrumbs: [{ label: 'Dashboard', href: '/admin' }],
|
|
141
|
+
}
|
|
142
|
+
act(() => setBreadcrumbs(settings))
|
|
143
|
+
const afterFirst = received.length
|
|
144
|
+
|
|
145
|
+
// A deep-equal but freshly allocated value — exactly what a route
|
|
146
|
+
// component produces on every render from an inline array literal.
|
|
147
|
+
act(() =>
|
|
148
|
+
setBreadcrumbs({
|
|
149
|
+
homeLabel: 'Home',
|
|
150
|
+
homePath: '/admin',
|
|
151
|
+
breadcrumbs: [{ label: 'Dashboard', href: '/admin' }],
|
|
152
|
+
})
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
expect(received.length).toBe(afterFirst)
|
|
156
|
+
expect(received.at(-1)).toBe(received[afterFirst - 1])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('propagates a new settings object when a breadcrumb label actually changes', () => {
|
|
160
|
+
const received: unknown[] = []
|
|
161
|
+
function Consumer() {
|
|
162
|
+
received.push(useBreadcrumbs().breadCrumbSettings)
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
let setBreadcrumbs!: ReturnType<typeof useBreadcrumbs>['setBreadcrumbs']
|
|
166
|
+
function Setter() {
|
|
167
|
+
setBreadcrumbs = useBreadcrumbs().setBreadcrumbs
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
act(() =>
|
|
171
|
+
root.render(
|
|
172
|
+
<BreadcrumbsProvider>
|
|
173
|
+
<Setter />
|
|
174
|
+
<Consumer />
|
|
175
|
+
</BreadcrumbsProvider>
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
act(() => setBreadcrumbs({ breadcrumbs: [{ label: 'Dashboard', href: '/admin' }] }))
|
|
180
|
+
const afterFirst = received.length
|
|
181
|
+
act(() => setBreadcrumbs({ breadcrumbs: [{ label: 'Renamed', href: '/admin' }] }))
|
|
182
|
+
|
|
183
|
+
expect(received.length).toBeGreaterThan(afterFirst)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('exposes a stable setter so consumer effects do not refire', () => {
|
|
187
|
+
const setters = new Set<unknown>()
|
|
188
|
+
function Consumer() {
|
|
189
|
+
setters.add(useBreadcrumbs().setBreadcrumbs)
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
let setBreadcrumbs!: ReturnType<typeof useBreadcrumbs>['setBreadcrumbs']
|
|
193
|
+
function Setter() {
|
|
194
|
+
setBreadcrumbs = useBreadcrumbs().setBreadcrumbs
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
act(() =>
|
|
198
|
+
root.render(
|
|
199
|
+
<BreadcrumbsProvider>
|
|
200
|
+
<Setter />
|
|
201
|
+
<Consumer />
|
|
202
|
+
</BreadcrumbsProvider>
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
act(() => setBreadcrumbs({ breadcrumbs: [{ label: 'Dashboard', href: '/admin' }] }))
|
|
206
|
+
act(() => setBreadcrumbs({ breadcrumbs: [{ label: 'Renamed', href: '/admin' }] }))
|
|
207
|
+
|
|
208
|
+
expect(setters.size).toBe(1)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('breadcrumbs measurement', () => {
|
|
213
|
+
it('re-measures when only the home label changes', () => {
|
|
214
|
+
const breadcrumbs = trail(5)
|
|
215
|
+
containerWidth = 700
|
|
216
|
+
act(() => root.render(<Breadcrumbs breadcrumbs={breadcrumbs} homeLabel="Home" />))
|
|
217
|
+
expect(isCollapsed()).toBe(false)
|
|
218
|
+
|
|
219
|
+
// Same array reference; only the home label grows. Its width feeds the
|
|
220
|
+
// same calculation, so the trail must collapse.
|
|
221
|
+
act(() =>
|
|
222
|
+
root.render(
|
|
223
|
+
<Breadcrumbs breadcrumbs={breadcrumbs} homeLabel="A considerably longer home label" />
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
expect(isCollapsed()).toBe(true)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('expands a collapsed trail when it is replaced by a shorter one', () => {
|
|
231
|
+
containerWidth = 260
|
|
232
|
+
act(() => root.render(<Breadcrumbs breadcrumbs={trail(6)} homeLabel="Home" />))
|
|
233
|
+
expect(isCollapsed()).toBe(true)
|
|
234
|
+
|
|
235
|
+
act(() => root.render(<Breadcrumbs breadcrumbs={trail(2)} homeLabel="Home" />))
|
|
236
|
+
|
|
237
|
+
expect(isCollapsed()).toBe(false)
|
|
238
|
+
expect(visibleLabels()).toEqual(['Home', 'Section0', 'Section1'])
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('re-measures when labels change without changing the breadcrumb count', () => {
|
|
242
|
+
containerWidth = 600
|
|
243
|
+
act(() => root.render(<Breadcrumbs breadcrumbs={trail(4)} homeLabel="Home" />))
|
|
244
|
+
const before = isCollapsed()
|
|
245
|
+
|
|
246
|
+
const longer: Breadcrumb[] = trail(4).map((b, i) => ({
|
|
247
|
+
...b,
|
|
248
|
+
label: `${b.label} with a much longer label ${i}`,
|
|
249
|
+
}))
|
|
250
|
+
act(() => root.render(<Breadcrumbs breadcrumbs={longer} homeLabel="Home" />))
|
|
251
|
+
|
|
252
|
+
expect(before).toBe(false)
|
|
253
|
+
expect(isCollapsed()).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('collapses and expands across a resize', () => {
|
|
257
|
+
const breadcrumbs = trail(6)
|
|
258
|
+
containerWidth = 1000
|
|
259
|
+
act(() => root.render(<Breadcrumbs breadcrumbs={breadcrumbs} homeLabel="Home" />))
|
|
260
|
+
expect(isCollapsed()).toBe(false)
|
|
261
|
+
|
|
262
|
+
containerWidth = 260
|
|
263
|
+
act(() => {
|
|
264
|
+
for (const cb of resizeCallbacks) cb()
|
|
265
|
+
})
|
|
266
|
+
expect(isCollapsed()).toBe(true)
|
|
267
|
+
|
|
268
|
+
containerWidth = 1000
|
|
269
|
+
act(() => {
|
|
270
|
+
for (const cb of resizeCallbacks) cb()
|
|
271
|
+
})
|
|
272
|
+
expect(isCollapsed()).toBe(false)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
@@ -45,14 +45,11 @@ export function Breadcrumbs({
|
|
|
45
45
|
// visibleIndices = indices into breadcrumbs[] that are shown inline.
|
|
46
46
|
// Anything missing from this set is rolled into the overflow dropdown.
|
|
47
47
|
// Default to "all visible" so SSR and pre-measurement paints look right.
|
|
48
|
+
// No reset effect is needed when the trail changes: the hidden measurement
|
|
49
|
+
// layer always renders every breadcrumb regardless of visibility, so the
|
|
50
|
+
// effect below recomputes the final indices from scratch before paint.
|
|
48
51
|
const [visibleIndices, setVisibleIndices] = useState<number[]>(() => breadcrumbs.map((_, i) => i))
|
|
49
52
|
|
|
50
|
-
// Reset visibility when breadcrumbs change; the measurement effect below
|
|
51
|
-
// will collapse again on the next layout tick if needed.
|
|
52
|
-
useIsoLayoutEffect(() => {
|
|
53
|
-
setVisibleIndices(breadcrumbs.map((_, i) => i))
|
|
54
|
-
}, [breadcrumbs])
|
|
55
|
-
|
|
56
53
|
useIsoLayoutEffect(() => {
|
|
57
54
|
const nav = navRef.current
|
|
58
55
|
const measure = measureRef.current
|
|
@@ -113,7 +110,10 @@ export function Breadcrumbs({
|
|
|
113
110
|
const ro = new ResizeObserver(compute)
|
|
114
111
|
ro.observe(nav)
|
|
115
112
|
return () => ro.disconnect()
|
|
116
|
-
|
|
113
|
+
// `homeLabel` renders as text in the measured row, so its width feeds the
|
|
114
|
+
// same calculation. Now that the breadcrumb array is referentially stable,
|
|
115
|
+
// a home-label-only change no longer re-measures incidentally.
|
|
116
|
+
}, [breadcrumbs, homeLabel])
|
|
117
117
|
|
|
118
118
|
const overflowed = useMemo(() => {
|
|
119
119
|
const visible = new Set(visibleIndices)
|
|
@@ -11,13 +11,28 @@ import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
|
|
|
11
11
|
import { useTranslation } from '@byline/i18n/react'
|
|
12
12
|
import { Container, Section } from '@byline/ui/react'
|
|
13
13
|
import cx from 'clsx'
|
|
14
|
-
import {
|
|
14
|
+
import { darkStyles, JsonView } from 'react-json-view-lite'
|
|
15
15
|
|
|
16
16
|
import styles from './api.module.css'
|
|
17
17
|
import { ViewMenu } from './view-menu.js'
|
|
18
18
|
import type { ContentLocaleOption } from './view-menu.js'
|
|
19
19
|
import 'react-json-view-lite/dist/index.css'
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Expand the document root and the level below it — the document's own metadata
|
|
23
|
+
* and the names of its content fields — and leave everything deeper collapsed.
|
|
24
|
+
*
|
|
25
|
+
* `react-json-view-lite` does not mount collapsed descendants, so this reduces the
|
|
26
|
+
* size of the mounted tree rather than merely hiding nodes. A document with rich
|
|
27
|
+
* text, blocks, several locales and populated relations otherwise mounts thousands
|
|
28
|
+
* of nodes that the reader has to scroll past before finding anything.
|
|
29
|
+
*
|
|
30
|
+
* Declared at module scope so its identity is stable: the viewer treats a new
|
|
31
|
+
* callback as a reason to re-derive expansion state, which would discard whatever
|
|
32
|
+
* the reader had opened.
|
|
33
|
+
*/
|
|
34
|
+
const expandToFirstLevel = (level: number): boolean => level < 2
|
|
35
|
+
|
|
21
36
|
/**
|
|
22
37
|
* JSON inspector view for a collection document.
|
|
23
38
|
*
|
|
@@ -64,7 +79,7 @@ export const ApiView = ({
|
|
|
64
79
|
<div className={cx('byline-api-viewer', styles.viewer)}>
|
|
65
80
|
<JsonView
|
|
66
81
|
data={initialData}
|
|
67
|
-
shouldExpandNode={
|
|
82
|
+
shouldExpandNode={expandToFirstLevel}
|
|
68
83
|
style={{ ...darkStyles, container: 'api-json-view' }}
|
|
69
84
|
/>
|
|
70
85
|
</div>
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
updateDocumentStatus,
|
|
38
38
|
} from '../../server-fns/collections/index.js'
|
|
39
39
|
import { useNavigate } from '../chrome/loose-router.js'
|
|
40
|
+
import { describeMutationFailure } from '../describe-mutation-failure.js'
|
|
40
41
|
import { useDocumentMutationState } from '../document-mutation-state.js'
|
|
41
42
|
import { useTanStackNavigationGuard } from './tanstack-navigation-guard.js'
|
|
42
43
|
import { ViewMenu } from './view-menu.js'
|
|
@@ -430,7 +431,7 @@ export const EditView = ({
|
|
|
430
431
|
}
|
|
431
432
|
console.error('Duplicate error:', err)
|
|
432
433
|
const description = t('collections.edit.duplicateFailedDescription', {
|
|
433
|
-
message: t
|
|
434
|
+
message: describeMutationFailure(err, t),
|
|
434
435
|
})
|
|
435
436
|
toastManager.add({
|
|
436
437
|
title: t('collections.edit.duplicateTitle', { label: singular }),
|
|
@@ -526,7 +527,7 @@ export const EditView = ({
|
|
|
526
527
|
}
|
|
527
528
|
console.error('Copy to locale error:', err)
|
|
528
529
|
const description = t('collections.edit.copyFailedDescription', {
|
|
529
|
-
message: t
|
|
530
|
+
message: describeMutationFailure(err, t),
|
|
530
531
|
})
|
|
531
532
|
toastManager.add({
|
|
532
533
|
title: t('collections.edit.copyToLocaleTitle', { label: singular }),
|
|
@@ -57,6 +57,18 @@ export function RestoreVersionModal({
|
|
|
57
57
|
setError(null)
|
|
58
58
|
try {
|
|
59
59
|
const result = await restoreVersion()
|
|
60
|
+
// Restore is exempt from field validation, so this never blocks it. Name
|
|
61
|
+
// the fields so the editor understands why the next save is refused.
|
|
62
|
+
const issues = getRestoreValidationIssues(result)
|
|
63
|
+
if (issues.length > 0) {
|
|
64
|
+
toastManager.add({
|
|
65
|
+
title: t('collections.restore.validationIssuesToast'),
|
|
66
|
+
description: t('collections.restore.validationIssuesDescription', {
|
|
67
|
+
fields: issues.join(', '),
|
|
68
|
+
}),
|
|
69
|
+
data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
|
|
70
|
+
})
|
|
71
|
+
}
|
|
60
72
|
if (hasCommittedDocumentHookFailure(result)) {
|
|
61
73
|
toastManager.add({
|
|
62
74
|
title: t('collections.save.hookFailedToast'),
|
|
@@ -146,6 +158,20 @@ export function RestoreVersionModal({
|
|
|
146
158
|
)
|
|
147
159
|
}
|
|
148
160
|
|
|
161
|
+
/** Field paths of the restored content that today's validation rejects. */
|
|
162
|
+
function getRestoreValidationIssues(result: unknown): string[] {
|
|
163
|
+
if (result == null || typeof result !== 'object') return []
|
|
164
|
+
const issues = (result as { validationIssues?: unknown }).validationIssues
|
|
165
|
+
if (!Array.isArray(issues)) return []
|
|
166
|
+
return issues
|
|
167
|
+
.map((issue) =>
|
|
168
|
+
issue != null && typeof issue === 'object' && typeof (issue as any).field === 'string'
|
|
169
|
+
? (issue as { field: string }).field
|
|
170
|
+
: ''
|
|
171
|
+
)
|
|
172
|
+
.filter((field) => field.length > 0)
|
|
173
|
+
}
|
|
174
|
+
|
|
149
175
|
function getErrorCode(err: unknown): string | null {
|
|
150
176
|
return typeof (err as { code?: unknown })?.code === 'string'
|
|
151
177
|
? (err as { code: string }).code
|
|
@@ -0,0 +1,30 @@
|
|
|
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 { getDocumentFieldValidationDetails } from '@byline/core'
|
|
10
|
+
import type { TranslationValues } from '@byline/i18n'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Describe a failed document mutation for the editor.
|
|
14
|
+
*
|
|
15
|
+
* Content writes that are not restores — create, update, patch saves,
|
|
16
|
+
* duplication and locale copies — keep the field-validation gate, because each
|
|
17
|
+
* reads or authors content the editor can open and correct. Naming the
|
|
18
|
+
* offending fields is what makes that correction actionable; without it the
|
|
19
|
+
* editor sees only a generic failure.
|
|
20
|
+
*/
|
|
21
|
+
export function describeMutationFailure(
|
|
22
|
+
err: unknown,
|
|
23
|
+
t: (key: string, values?: TranslationValues) => string
|
|
24
|
+
): string {
|
|
25
|
+
const validation = getDocumentFieldValidationDetails(err)
|
|
26
|
+
if (validation == null) return t('documentConcurrency.failed')
|
|
27
|
+
return t('collections.edit.invalidFieldsMessage', {
|
|
28
|
+
fields: validation.issues.map((issue) => issue.field || '?').join(', '),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from '../../server-fns/singletons/index.js'
|
|
38
38
|
import { useNavigate } from '../chrome/loose-router.js'
|
|
39
39
|
import { useTanStackNavigationGuard } from '../collections/tanstack-navigation-guard.js'
|
|
40
|
+
import { describeMutationFailure } from '../describe-mutation-failure.js'
|
|
40
41
|
import { useDocumentMutationState } from '../document-mutation-state.js'
|
|
41
42
|
import { SingletonViewMenu } from './view-menu.js'
|
|
42
43
|
import type { ContentLocaleOption } from '../collections/view-menu.js'
|
|
@@ -287,7 +288,7 @@ export function SingletonView({
|
|
|
287
288
|
}
|
|
288
289
|
toast(
|
|
289
290
|
t('collections.edit.copyToLocaleTitle', { label }),
|
|
290
|
-
t('collections.edit.copyFailedDescription', { message: t
|
|
291
|
+
t('collections.edit.copyFailedDescription', { message: describeMutationFailure(err, t) }),
|
|
291
292
|
'danger'
|
|
292
293
|
)
|
|
293
294
|
throw err
|
|
@@ -0,0 +1,129 @@
|
|
|
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 { defineAdminConfig } from '@byline/core'
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
abilityFingerprint,
|
|
14
|
+
buildBylineFieldServices,
|
|
15
|
+
bylineFieldServices,
|
|
16
|
+
} from './byline-field-services.js'
|
|
17
|
+
|
|
18
|
+
// A deliberately non-default admin base: the create URL must come from host
|
|
19
|
+
// configuration, and a hardcoded `/admin` would pass against the usual value.
|
|
20
|
+
defineAdminConfig({
|
|
21
|
+
i18n: {
|
|
22
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
23
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
24
|
+
},
|
|
25
|
+
routes: { admin: '/control-panel' },
|
|
26
|
+
collections: [
|
|
27
|
+
{
|
|
28
|
+
path: 'media',
|
|
29
|
+
labels: { singular: 'Media', plural: 'Media' },
|
|
30
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const viewer = (abilities: string[], isSuperAdmin = false) => ({
|
|
37
|
+
is_super_admin: isSuperAdmin,
|
|
38
|
+
abilities,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('ability fingerprint', () => {
|
|
42
|
+
/**
|
|
43
|
+
* An equivalent abilities array should not invalidate the memoised services
|
|
44
|
+
* object and notify its context consumers.
|
|
45
|
+
*/
|
|
46
|
+
it('is equal for a different array holding the same abilities', () => {
|
|
47
|
+
const a = abilityFingerprint(viewer(['collections.media.create', 'collections.media.read']))
|
|
48
|
+
const b = abilityFingerprint(viewer(['collections.media.create', 'collections.media.read']))
|
|
49
|
+
|
|
50
|
+
expect(a).toBe(b)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('changes when an ability is gained or lost', () => {
|
|
54
|
+
const before = abilityFingerprint(viewer(['collections.media.read']))
|
|
55
|
+
const after = abilityFingerprint(viewer(['collections.media.read', 'collections.media.create']))
|
|
56
|
+
|
|
57
|
+
expect(after).not.toBe(before)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('distinguishes a super-admin from a viewer holding the same listed abilities', () => {
|
|
61
|
+
const plain = abilityFingerprint(viewer(['collections.media.create']))
|
|
62
|
+
const superAdmin = abilityFingerprint(viewer(['collections.media.create'], true))
|
|
63
|
+
|
|
64
|
+
expect(superAdmin).not.toBe(plain)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Ability order is not meaningful and the server does not promise one, so an
|
|
69
|
+
* order change must not invalidate the memo.
|
|
70
|
+
*/
|
|
71
|
+
it('ignores the order abilities arrive in', () => {
|
|
72
|
+
const one = abilityFingerprint(viewer(['a.read', 'b.create']))
|
|
73
|
+
const other = abilityFingerprint(viewer(['b.create', 'a.read']))
|
|
74
|
+
|
|
75
|
+
expect(one).toBe(other)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('field services built for a viewer', () => {
|
|
80
|
+
it('keeps the existing services intact', () => {
|
|
81
|
+
const services = buildBylineFieldServices(viewer([]))
|
|
82
|
+
|
|
83
|
+
expect(services.getCollectionDocuments).toBe(bylineFieldServices.getCollectionDocuments)
|
|
84
|
+
expect(services.uploadField).toBe(bylineFieldServices.uploadField)
|
|
85
|
+
expect(services.placeTreeNode).toBe(bylineFieldServices.placeTreeNode)
|
|
86
|
+
expect(services.removeFromTree).toBe(bylineFieldServices.removeFromTree)
|
|
87
|
+
expect(services.getTreeAncestors).toBe(bylineFieldServices.getTreeAncestors)
|
|
88
|
+
expect(services.getTreeParent).toBe(bylineFieldServices.getTreeParent)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('permits creation only for a collection the viewer may create in', () => {
|
|
92
|
+
const services = buildBylineFieldServices(viewer(['collections.media.create']))
|
|
93
|
+
|
|
94
|
+
expect(services.canCreateInCollection?.('media')).toBe(true)
|
|
95
|
+
expect(services.canCreateInCollection?.('pages')).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Mirrors `useAbility`, which short-circuits for super-admins rather than
|
|
100
|
+
* enumerating their abilities.
|
|
101
|
+
*/
|
|
102
|
+
it('permits creation anywhere for a super-admin', () => {
|
|
103
|
+
const services = buildBylineFieldServices(viewer([], true))
|
|
104
|
+
|
|
105
|
+
expect(services.canCreateInCollection?.('media')).toBe(true)
|
|
106
|
+
expect(services.canCreateInCollection?.('anything')).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not treat a read ability as permission to create', () => {
|
|
110
|
+
const services = buildBylineFieldServices(viewer(['collections.media.read']))
|
|
111
|
+
|
|
112
|
+
expect(services.canCreateInCollection?.('media')).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Root-relative, from the host's configured admin path. Not absolute: no
|
|
117
|
+
* origin is available during the server render, and the configured base may
|
|
118
|
+
* not be `/admin`.
|
|
119
|
+
*/
|
|
120
|
+
it('builds a root-relative create url from the configured admin path', () => {
|
|
121
|
+
const services = buildBylineFieldServices(viewer([]))
|
|
122
|
+
const url = services.getCreateDocumentUrl?.('media')
|
|
123
|
+
|
|
124
|
+
expect(url).toBeTruthy()
|
|
125
|
+
expect(url?.startsWith('/')).toBe(true)
|
|
126
|
+
expect(url?.startsWith('//')).toBe(false)
|
|
127
|
+
expect(url).toBe('/control-panel/collections/media/create')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
import type {
|
|
20
20
|
BylineFieldServices,
|
|
21
|
+
CanCreateInCollectionFn,
|
|
21
22
|
GetCollectionDocumentsFn,
|
|
23
|
+
GetCreateDocumentUrlFn,
|
|
22
24
|
GetTreeAncestorsFn,
|
|
23
25
|
GetTreeParentFn,
|
|
24
26
|
PlaceTreeNodeFn,
|
|
@@ -26,6 +28,7 @@ import type {
|
|
|
26
28
|
UploadFieldFn,
|
|
27
29
|
} from '@byline/admin/react'
|
|
28
30
|
|
|
31
|
+
import { getAdminRoutePath } from '../routes/admin-path.js'
|
|
29
32
|
import { getCollectionDocuments as serverGetCollectionDocuments } from '../server-fns/collections/list.js'
|
|
30
33
|
import {
|
|
31
34
|
getTreeAncestors as serverGetTreeAncestors,
|
|
@@ -57,6 +60,9 @@ const getTreeAncestors: GetTreeAncestorsFn = (input) =>
|
|
|
57
60
|
const getTreeParent: GetTreeParentFn = (input) =>
|
|
58
61
|
serverGetTreeParent({ data: input }) as ReturnType<GetTreeParentFn>
|
|
59
62
|
|
|
63
|
+
const getCreateDocumentUrl: GetCreateDocumentUrlFn = (collectionPath) =>
|
|
64
|
+
getAdminRoutePath('collections', collectionPath, 'create')
|
|
65
|
+
|
|
60
66
|
export const bylineFieldServices: BylineFieldServices = {
|
|
61
67
|
getCollectionDocuments,
|
|
62
68
|
uploadField,
|
|
@@ -64,4 +70,35 @@ export const bylineFieldServices: BylineFieldServices = {
|
|
|
64
70
|
removeFromTree,
|
|
65
71
|
getTreeAncestors,
|
|
66
72
|
getTreeParent,
|
|
73
|
+
getCreateDocumentUrl,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The subset of the admin session the field services need. */
|
|
77
|
+
export interface FieldServicesViewer {
|
|
78
|
+
is_super_admin: boolean
|
|
79
|
+
abilities: ReadonlyArray<string>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Content-addressed memo key for the services object.
|
|
84
|
+
*
|
|
85
|
+
* Avoids notifying field-service context consumers when the host supplies an
|
|
86
|
+
* equivalent viewer object or abilities array. Existing service functions retain
|
|
87
|
+
* their module-level identity even when the containing object is rebuilt.
|
|
88
|
+
* Ability order is not meaningful, so sort a copy before building the key.
|
|
89
|
+
*/
|
|
90
|
+
export function abilityFingerprint(viewer: FieldServicesViewer): string {
|
|
91
|
+
if (viewer.is_super_admin) return 'super-admin'
|
|
92
|
+
return [...viewer.abilities].sort().join(' ')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Field services for one viewer. Call inside `useMemo`, keyed on
|
|
97
|
+
* `abilityFingerprint(viewer)`.
|
|
98
|
+
*/
|
|
99
|
+
export function buildBylineFieldServices(viewer: FieldServicesViewer): BylineFieldServices {
|
|
100
|
+
const canCreateInCollection: CanCreateInCollectionFn = (collectionPath) =>
|
|
101
|
+
viewer.is_super_admin || viewer.abilities.includes(`collections.${collectionPath}.create`)
|
|
102
|
+
|
|
103
|
+
return { ...bylineFieldServices, canCreateInCollection }
|
|
67
104
|
}
|