@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.
Files changed (39) hide show
  1. package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs-provider.js +13 -2
  2. package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.js +2 -6
  3. package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.test.d.ts +8 -0
  4. package/dist/admin-shell/collections/api.js +3 -2
  5. package/dist/admin-shell/collections/edit.js +3 -2
  6. package/dist/admin-shell/collections/restore-version-modal.js +19 -0
  7. package/dist/admin-shell/describe-mutation-failure.d.ts +18 -0
  8. package/dist/admin-shell/describe-mutation-failure.js +9 -0
  9. package/dist/admin-shell/singletons/view.js +2 -1
  10. package/dist/integrations/byline-field-services.d.ts +19 -0
  11. package/dist/integrations/byline-field-services.js +18 -2
  12. package/dist/routes/create-admin-layout-route.js +6 -3
  13. package/dist/server-fns/collections/create.js +4 -2
  14. package/dist/server-fns/collections/restore-version.d.ts +1 -0
  15. package/dist/server-fns/collections/restore-version.js +4 -1
  16. package/dist/server-fns/collections/update.js +3 -2
  17. package/dist/server-fns/document-mutation-errors.js +7 -1
  18. package/dist/server-fns/document-write-input.d.ts +9 -0
  19. package/dist/server-fns/document-write-input.js +44 -0
  20. package/dist/server-fns/singletons/restore-version.d.ts +1 -1
  21. package/package.json +16 -16
  22. package/src/admin-shell/chrome/breadcrumbs/breadcrumbs-provider.tsx +34 -4
  23. package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.test.tsx +274 -0
  24. package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.tsx +7 -7
  25. package/src/admin-shell/collections/api.tsx +17 -2
  26. package/src/admin-shell/collections/edit.tsx +3 -2
  27. package/src/admin-shell/collections/restore-version-modal.tsx +26 -0
  28. package/src/admin-shell/describe-mutation-failure.ts +30 -0
  29. package/src/admin-shell/singletons/view.tsx +2 -1
  30. package/src/integrations/byline-field-services.test.node.ts +129 -0
  31. package/src/integrations/byline-field-services.ts +37 -0
  32. package/src/routes/create-admin-layout-route.tsx +17 -6
  33. package/src/server-fns/collections/create.ts +5 -2
  34. package/src/server-fns/collections/restore-version.ts +4 -0
  35. package/src/server-fns/collections/update.ts +3 -2
  36. package/src/server-fns/document-mutation-errors.ts +8 -0
  37. package/src/server-fns/document-mutation-transport.test.node.ts +41 -0
  38. package/src/server-fns/document-write-input.test.node.ts +48 -0
  39. 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
- }, [breadcrumbs])
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 { allExpanded, darkStyles, JsonView } from 'react-json-view-lite'
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={allExpanded}
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('documentConcurrency.failed'),
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('documentConcurrency.failed'),
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('documentConcurrency.failed') }),
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
  }