@byline/host-tanstack-start 6.2.0 → 6.3.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.
@@ -1,16 +1,27 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
- import { createContext, useContext, useState } from "react";
2
+ import { createContext, useCallback, useContext, useState } from "react";
3
3
  const BreadcrumbsContext = /*#__PURE__*/ createContext(void 0);
4
+ function sameBreadcrumbs(a, b) {
5
+ if (a === b) return true;
6
+ if (a.length !== b.length) return false;
7
+ return a.every((crumb, index)=>crumb.label === b[index].label && crumb.href === b[index].href && crumb.active === b[index].active);
8
+ }
9
+ function sameSettings(a, b) {
10
+ return a.homeLabel === b.homeLabel && a.homePath === b.homePath && sameBreadcrumbs(a.breadcrumbs, b.breadcrumbs);
11
+ }
4
12
  function BreadcrumbsProvider({ children }) {
5
13
  const [breadcrumbSettings, setBreadcrumbSettings] = useState({
6
14
  homeLabel: 'Home',
7
15
  homePath: '/',
8
16
  breadcrumbs: []
9
17
  });
18
+ const setBreadcrumbs = useCallback((settings)=>{
19
+ setBreadcrumbSettings((previous)=>sameSettings(previous, settings) ? previous : settings);
20
+ }, []);
10
21
  return /*#__PURE__*/ jsx(BreadcrumbsContext, {
11
22
  value: {
12
23
  breadCrumbSettings: breadcrumbSettings,
13
- setBreadcrumbs: setBreadcrumbSettings
24
+ setBreadcrumbs
14
25
  },
15
26
  children: children
16
27
  });
@@ -18,11 +18,6 @@ function Breadcrumbs({ breadcrumbs, className, homeLabel = 'Home', homePath = '/
18
18
  const navRef = useRef(null);
19
19
  const measureRef = useRef(null);
20
20
  const [visibleIndices, setVisibleIndices] = useState(()=>breadcrumbs.map((_, i)=>i));
21
- useIsoLayoutEffect(()=>{
22
- setVisibleIndices(breadcrumbs.map((_, i)=>i));
23
- }, [
24
- breadcrumbs
25
- ]);
26
21
  useIsoLayoutEffect(()=>{
27
22
  const nav = navRef.current;
28
23
  const measure = measureRef.current;
@@ -71,7 +66,8 @@ function Breadcrumbs({ breadcrumbs, className, homeLabel = 'Home', homePath = '/
71
66
  ro.observe(nav);
72
67
  return ()=>ro.disconnect();
73
68
  }, [
74
- breadcrumbs
69
+ breadcrumbs,
70
+ homeLabel
75
71
  ]);
76
72
  const overflowed = useMemo(()=>{
77
73
  const visible = new Set(visibleIndices);
@@ -0,0 +1,8 @@
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 {};
@@ -2,10 +2,11 @@ import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useTranslation } from "@byline/i18n/react";
3
3
  import { Container, Section } from "@byline/ui/react";
4
4
  import clsx from "clsx";
5
- import { JsonView, allExpanded, darkStyles } from "react-json-view-lite";
5
+ import { JsonView, darkStyles } from "react-json-view-lite";
6
6
  import api_module from "./api.module.js";
7
7
  import { ViewMenu } from "./view-menu.js";
8
8
  import "react-json-view-lite/dist/index.css";
9
+ const expandToFirstLevel = (level)=>level < 2;
9
10
  const ApiView = ({ collectionDefinition, initialData, locale, depth, contentLocales, defaultContentLocale })=>{
10
11
  const { labels, path } = collectionDefinition;
11
12
  const { t } = useTranslation('byline-admin');
@@ -38,7 +39,7 @@ const ApiView = ({ collectionDefinition, initialData, locale, depth, contentLoca
38
39
  className: clsx('byline-api-viewer', api_module.viewer),
39
40
  children: /*#__PURE__*/ jsx(JsonView, {
40
41
  data: initialData,
41
- shouldExpandNode: allExpanded,
42
+ shouldExpandNode: expandToFirstLevel,
42
43
  style: {
43
44
  ...darkStyles,
44
45
  container: 'api-json-view'
@@ -16,3 +16,22 @@
16
16
  */
17
17
  import type { BylineFieldServices } from '@byline/admin/react';
18
18
  export declare const bylineFieldServices: BylineFieldServices;
19
+ /** The subset of the admin session the field services need. */
20
+ export interface FieldServicesViewer {
21
+ is_super_admin: boolean;
22
+ abilities: ReadonlyArray<string>;
23
+ }
24
+ /**
25
+ * Content-addressed memo key for the services object.
26
+ *
27
+ * Avoids notifying field-service context consumers when the host supplies an
28
+ * equivalent viewer object or abilities array. Existing service functions retain
29
+ * their module-level identity even when the containing object is rebuilt.
30
+ * Ability order is not meaningful, so sort a copy before building the key.
31
+ */
32
+ export declare function abilityFingerprint(viewer: FieldServicesViewer): string;
33
+ /**
34
+ * Field services for one viewer. Call inside `useMemo`, keyed on
35
+ * `abilityFingerprint(viewer)`.
36
+ */
37
+ export declare function buildBylineFieldServices(viewer: FieldServicesViewer): BylineFieldServices;
@@ -1,3 +1,4 @@
1
+ import { getAdminRoutePath } from "../routes/admin-path.js";
1
2
  import { getCollectionDocuments } from "../server-fns/collections/list.js";
2
3
  import { getTreeAncestors, getTreeParent, placeTreeNode, removeFromTree } from "../server-fns/collections/tree.js";
3
4
  import { uploadField } from "../server-fns/collections/upload.js";
@@ -20,12 +21,27 @@ const byline_field_services_getTreeAncestors = (input)=>getTreeAncestors({
20
21
  const byline_field_services_getTreeParent = (input)=>getTreeParent({
21
22
  data: input
22
23
  });
24
+ const getCreateDocumentUrl = (collectionPath)=>getAdminRoutePath('collections', collectionPath, 'create');
23
25
  const bylineFieldServices = {
24
26
  getCollectionDocuments: byline_field_services_getCollectionDocuments,
25
27
  uploadField: byline_field_services_uploadField,
26
28
  placeTreeNode: byline_field_services_placeTreeNode,
27
29
  removeFromTree: byline_field_services_removeFromTree,
28
30
  getTreeAncestors: byline_field_services_getTreeAncestors,
29
- getTreeParent: byline_field_services_getTreeParent
31
+ getTreeParent: byline_field_services_getTreeParent,
32
+ getCreateDocumentUrl: getCreateDocumentUrl
30
33
  };
31
- export { bylineFieldServices };
34
+ function abilityFingerprint(viewer) {
35
+ if (viewer.is_super_admin) return 'super-admin';
36
+ return [
37
+ ...viewer.abilities
38
+ ].sort().join(' ');
39
+ }
40
+ function buildBylineFieldServices(viewer) {
41
+ const canCreateInCollection = (collectionPath)=>viewer.is_super_admin || viewer.abilities.includes(`collections.${collectionPath}.create`);
42
+ return {
43
+ ...bylineFieldServices,
44
+ canCreateInCollection
45
+ };
46
+ }
47
+ export { abilityFingerprint, buildBylineFieldServices, bylineFieldServices };
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
- import { useEffect } from "react";
2
+ import { useEffect, useMemo } from "react";
3
3
  import { Outlet, createFileRoute } from "@tanstack/react-router";
4
4
  import { applyStoredTheme } from "@byline/admin/admin-account/components/theme";
5
5
  import { BylineFieldServicesProvider } from "@byline/admin/react";
@@ -18,7 +18,7 @@ import { SessionChangeBoundary } from "../admin-shell/chrome/session-change-boun
18
18
  import { buildLocaleDefinitions } from "../i18n/locale-definitions.js";
19
19
  import { bylineAdminServices } from "../integrations/byline-admin-services.js";
20
20
  import { BylineAiAdminProvider } from "../integrations/byline-ai.js";
21
- import { bylineFieldServices } from "../integrations/byline-field-services.js";
21
+ import { abilityFingerprint, buildBylineFieldServices } from "../integrations/byline-field-services.js";
22
22
  import { observeSession, sessionIsChanged } from "../integrations/session-coordination.js";
23
23
  import { renewExpectedSession } from "../integrations/session-renewal.js";
24
24
  import { getAnalyticsRuntime } from "../server-fns/analytics/index.js";
@@ -58,6 +58,9 @@ function createAdminLayoutRoute(path) {
58
58
  useEffect(()=>{
59
59
  applyStoredTheme();
60
60
  }, []);
61
+ const fieldServices = useMemo(()=>buildBylineFieldServices(user), [
62
+ abilityFingerprint(user)
63
+ ]);
61
64
  const handleSetLocale = async (next)=>{
62
65
  await setAdminLocaleFn({
63
66
  data: {
@@ -77,7 +80,7 @@ function createAdminLayoutRoute(path) {
77
80
  children: /*#__PURE__*/ jsx(BylineAdminServicesProvider, {
78
81
  services: bylineAdminServices,
79
82
  children: /*#__PURE__*/ jsx(BylineFieldServicesProvider, {
80
- services: bylineFieldServices,
83
+ services: fieldServices,
81
84
  children: /*#__PURE__*/ jsx(BylineAiAdminProvider, {
82
85
  children: /*#__PURE__*/ jsxs(AdminMenuProvider, {
83
86
  children: [
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "6.2.0",
6
+ "version": "6.3.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -125,15 +125,15 @@
125
125
  "react-json-view-lite": "^2.5.0",
126
126
  "react-swipeable": "^7.0.2",
127
127
  "zod": "^4.6.2",
128
- "@byline/admin": "6.2.0",
129
- "@byline/analytics": "6.2.0",
130
- "@byline/auth": "6.2.0",
131
- "@byline/ai": "6.2.0",
132
- "@byline/client": "6.2.0",
133
- "@byline/core": "6.2.0",
134
- "@byline/analytics-agent": "6.2.0",
135
- "@byline/i18n": "6.2.0",
136
- "@byline/ui": "6.2.0"
128
+ "@byline/admin": "6.3.0",
129
+ "@byline/analytics": "6.3.0",
130
+ "@byline/analytics-agent": "6.3.0",
131
+ "@byline/ai": "6.3.0",
132
+ "@byline/client": "6.3.0",
133
+ "@byline/auth": "6.3.0",
134
+ "@byline/i18n": "6.3.0",
135
+ "@byline/ui": "6.3.0",
136
+ "@byline/core": "6.3.0"
137
137
  },
138
138
  "peerDependencies": {
139
139
  "@tanstack/react-router": "^1.167.0",
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import { createContext, type ReactNode, useContext, useState } from 'react'
9
+ import { createContext, type ReactNode, useCallback, useContext, useState } from 'react'
10
10
 
11
11
  import type { Breadcrumb } from './@types.js'
12
12
 
@@ -23,6 +23,32 @@ interface BreadcrumbsContextType {
23
23
 
24
24
  const BreadcrumbsContext = createContext<BreadcrumbsContextType | undefined>(undefined)
25
25
 
26
+ /**
27
+ * Route components build their breadcrumb trail from an inline array literal,
28
+ * so every render submits a new array that is almost always value-identical to
29
+ * the last one. Comparing by value lets the provider hand back the previous
30
+ * state, which keeps the array reference — and therefore the measurement
31
+ * effect in `Breadcrumbs` — stable across unrelated renders.
32
+ */
33
+ function sameBreadcrumbs(a: Breadcrumb[], b: Breadcrumb[]): boolean {
34
+ if (a === b) return true
35
+ if (a.length !== b.length) return false
36
+ return a.every(
37
+ (crumb, index) =>
38
+ crumb.label === b[index].label &&
39
+ crumb.href === b[index].href &&
40
+ crumb.active === b[index].active
41
+ )
42
+ }
43
+
44
+ function sameSettings(a: BreadcrumbsSettings, b: BreadcrumbsSettings): boolean {
45
+ return (
46
+ a.homeLabel === b.homeLabel &&
47
+ a.homePath === b.homePath &&
48
+ sameBreadcrumbs(a.breadcrumbs, b.breadcrumbs)
49
+ )
50
+ }
51
+
26
52
  export function BreadcrumbsProvider({ children }: { children: ReactNode }) {
27
53
  const [breadcrumbSettings, setBreadcrumbSettings] = useState<BreadcrumbsSettings>({
28
54
  homeLabel: 'Home',
@@ -30,10 +56,14 @@ export function BreadcrumbsProvider({ children }: { children: ReactNode }) {
30
56
  breadcrumbs: [],
31
57
  })
32
58
 
59
+ // `BreadcrumbsClient` lists this setter in its effect dependencies, so it
60
+ // has to stay referentially stable or the effect refires on every render.
61
+ const setBreadcrumbs = useCallback((settings: BreadcrumbsSettings) => {
62
+ setBreadcrumbSettings((previous) => (sameSettings(previous, settings) ? previous : settings))
63
+ }, [])
64
+
33
65
  return (
34
- <BreadcrumbsContext
35
- value={{ breadCrumbSettings: breadcrumbSettings, setBreadcrumbs: setBreadcrumbSettings }}
36
- >
66
+ <BreadcrumbsContext value={{ breadCrumbSettings: breadcrumbSettings, setBreadcrumbs }}>
37
67
  {children}
38
68
  </BreadcrumbsContext>
39
69
  )
@@ -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>
@@ -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
  }
@@ -21,7 +21,7 @@
21
21
  * route (and the `AdminAppBar`) can read it without an extra fetch.
22
22
  */
23
23
 
24
- import { useEffect } from 'react'
24
+ import { useEffect, useMemo } from 'react'
25
25
  import { createFileRoute, Outlet } from '@tanstack/react-router'
26
26
 
27
27
  import { applyStoredTheme } from '@byline/admin/admin-account/components/theme'
@@ -43,7 +43,10 @@ import { SessionChangeBoundary } from '../admin-shell/chrome/session-change-boun
43
43
  import { buildLocaleDefinitions } from '../i18n/locale-definitions.js'
44
44
  import { bylineAdminServices } from '../integrations/byline-admin-services.js'
45
45
  import { BylineAiAdminProvider } from '../integrations/byline-ai.js'
46
- import { bylineFieldServices } from '../integrations/byline-field-services.js'
46
+ import {
47
+ abilityFingerprint,
48
+ buildBylineFieldServices,
49
+ } from '../integrations/byline-field-services.js'
47
50
  import { observeSession, sessionIsChanged } from '../integrations/session-coordination.js'
48
51
  import { renewExpectedSession } from '../integrations/session-renewal.js'
49
52
  import { getAnalyticsRuntime } from '../server-fns/analytics/index.js'
@@ -99,9 +102,17 @@ export function createAdminLayoutRoute(path: string) {
99
102
  useEffect(() => {
100
103
  applyStoredTheme()
101
104
  }, [])
102
- // Cookie + DB write happen in setAdminLocaleFn; full reload
103
- // re-runs beforeLoad so the provider re-renders with the new
104
- // bundle/locale (no in-place bundle swap needed for PR 1's scope).
105
+ // Composed here rather than imported as a constant because the create
106
+ // affordance depends on the viewer's abilities, which only exist in route
107
+ // context. Keying on ability content avoids notifying context consumers
108
+ // when route context supplies an equivalent user object or abilities array.
109
+ // biome-ignore lint/correctness/useExhaustiveDependencies: see above
110
+ const fieldServices = useMemo(
111
+ () => buildBylineFieldServices(user),
112
+ [abilityFingerprint(user)]
113
+ )
114
+
115
+ // The full reload re-runs beforeLoad with the saved locale and its bundle.
105
116
  const handleSetLocale = async (next: LocaleCode) => {
106
117
  await setAdminLocaleFn({ data: { locale: next } })
107
118
  window.location.reload()
@@ -116,7 +127,7 @@ export function createAdminLayoutRoute(path: string) {
116
127
  setLocale={handleSetLocale}
117
128
  >
118
129
  <BylineAdminServicesProvider services={bylineAdminServices}>
119
- <BylineFieldServicesProvider services={bylineFieldServices}>
130
+ <BylineFieldServicesProvider services={fieldServices}>
120
131
  <BylineAiAdminProvider>
121
132
  <AdminMenuProvider>
122
133
  <RouteProgressBar />