@byline/host-tanstack-start 6.2.1 → 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.
- 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/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/package.json +10 -10
- 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/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
|
@@ -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
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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.
|
|
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.
|
|
129
|
-
"@byline/analytics
|
|
130
|
-
"@byline/analytics": "6.
|
|
131
|
-
"@byline/ai": "6.
|
|
132
|
-
"@byline/client": "6.
|
|
133
|
-
"@byline/
|
|
134
|
-
"@byline/i18n": "6.
|
|
135
|
-
"@byline/ui": "6.
|
|
136
|
-
"@byline/
|
|
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
|
-
|
|
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>
|
|
@@ -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 {
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
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={
|
|
130
|
+
<BylineFieldServicesProvider services={fieldServices}>
|
|
120
131
|
<BylineAiAdminProvider>
|
|
121
132
|
<AdminMenuProvider>
|
|
122
133
|
<RouteProgressBar />
|