@byline/host-tanstack-start 6.8.0 → 6.9.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/admin-app-bar.d.ts +0 -7
- package/dist/admin-shell/collections/api.d.ts +6 -2
- package/dist/admin-shell/collections/api.js +109 -3
- package/dist/admin-shell/collections/api.test.d.ts +8 -0
- package/dist/admin-shell/collections/edit.js +2 -0
- package/dist/admin-shell/collections/preview-link.js +17 -13
- package/dist/admin-shell/collections/view-menu.d.ts +8 -1
- package/dist/admin-shell/collections/view-menu.js +124 -107
- package/dist/admin-shell/collections/view-menu.module.js +1 -0
- package/dist/admin-shell/collections/view-menu_module.css +15 -1
- package/dist/admin-shell/scheduled-publications/list.js +2 -2
- package/dist/admin-shell/singletons/preview-link.js +17 -13
- package/dist/admin-shell/singletons/view-menu.js +78 -63
- package/dist/admin-shell/singletons/view.js +2 -0
- package/dist/server-fns/collections/get.d.ts +1 -0
- package/dist/server-fns/collections/get.js +4 -0
- package/package.json +10 -10
- package/src/admin-shell/chrome/admin-app-bar.tsx +2 -2
- package/src/admin-shell/chrome/dashboard.test.tsx +2 -2
- package/src/admin-shell/collections/api.test.tsx +223 -0
- package/src/admin-shell/collections/api.tsx +98 -3
- package/src/admin-shell/collections/edit.tsx +9 -1
- package/src/admin-shell/collections/preview-link.tsx +13 -12
- package/src/admin-shell/collections/view-menu.module.css +29 -3
- package/src/admin-shell/collections/view-menu.tsx +114 -96
- package/src/admin-shell/scheduled-publications/list.tsx +3 -4
- package/src/admin-shell/singletons/history.test.tsx +1 -1
- package/src/admin-shell/singletons/preview-link.tsx +13 -12
- package/src/admin-shell/singletons/view-menu.tsx +65 -59
- package/src/admin-shell/singletons/view.test.tsx +4 -4
- package/src/admin-shell/singletons/view.tsx +3 -1
- package/src/server-fns/collections/get.ts +3 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import { useTranslation } from "@byline/i18n/react";
|
|
5
|
-
import { Button, HistoryIcon, IconButton, Label, Select } from "@byline/ui/react";
|
|
5
|
+
import { Button, HistoryIcon, IconButton, Label, Select, Tooltip } from "@byline/ui/react";
|
|
6
6
|
import clsx from "clsx";
|
|
7
7
|
import { getAdminRoutePath } from "../../routes/admin-path.js";
|
|
8
8
|
import { useNavigate } from "../chrome/loose-router.js";
|
|
@@ -44,71 +44,86 @@ function SingletonViewMenu({ singleton, activeView, locale, contentLocales, defa
|
|
|
44
44
|
return /*#__PURE__*/ jsxs("div", {
|
|
45
45
|
className: clsx('byline-view-menu', view_menu_module.root),
|
|
46
46
|
children: [
|
|
47
|
-
/*#__PURE__*/
|
|
48
|
-
className: clsx('
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
id: "contentLocale",
|
|
56
|
-
className: clsx('byline-view-menu-locale-select', view_menu_module.localeSelect),
|
|
57
|
-
size: "xs",
|
|
58
|
-
variant: "outlined",
|
|
59
|
-
value: locale ?? defaultContentLocale,
|
|
60
|
-
items: [
|
|
61
|
-
...'history' === activeView ? [
|
|
62
|
-
{
|
|
63
|
-
value: 'all',
|
|
64
|
-
label: t('collections.viewMenu.localeAll')
|
|
65
|
-
}
|
|
66
|
-
] : [],
|
|
67
|
-
...contentLocales.map((entry)=>({
|
|
68
|
-
value: entry.code,
|
|
69
|
-
label: entry.label
|
|
70
|
-
}))
|
|
71
|
-
],
|
|
72
|
-
onValueChange: handleLocaleChange
|
|
73
|
-
}),
|
|
74
|
-
adminConfig?.preview != null && null != doc && /*#__PURE__*/ jsx(SingletonPreviewLink, {
|
|
75
|
-
doc: doc,
|
|
76
|
-
adminConfig: adminConfig,
|
|
77
|
-
locale: locale,
|
|
78
|
-
className: clsx('byline-view-menu-icon-button', view_menu_module.iconButton)
|
|
79
|
-
}),
|
|
80
|
-
/*#__PURE__*/ jsx(IconButton, {
|
|
81
|
-
className: clsx('byline-view-menu-icon-button', view_menu_module.iconButton),
|
|
82
|
-
size: "xs",
|
|
83
|
-
variant: 'history' === activeView ? 'filled' : 'text',
|
|
84
|
-
"aria-label": t('collections.breadcrumbs.history'),
|
|
85
|
-
onClick: ()=>navigate({
|
|
86
|
-
to: getAdminRoutePath('singletons', '$singleton', 'history'),
|
|
87
|
-
params: {
|
|
88
|
-
singleton
|
|
89
|
-
},
|
|
90
|
-
search: locale ? {
|
|
91
|
-
locale
|
|
92
|
-
} : {}
|
|
47
|
+
/*#__PURE__*/ jsxs("div", {
|
|
48
|
+
className: clsx('byline-view-menu-data', view_menu_module.group),
|
|
49
|
+
children: [
|
|
50
|
+
/*#__PURE__*/ jsx(Label, {
|
|
51
|
+
className: clsx('muted byline-view-menu-label', view_menu_module.label),
|
|
52
|
+
id: "contentLocaleLabel",
|
|
53
|
+
htmlFor: "contentLocale",
|
|
54
|
+
label: t('collections.viewMenu.contentLocaleLabel')
|
|
93
55
|
}),
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
56
|
+
/*#__PURE__*/ jsx(Select, {
|
|
57
|
+
name: "contentLocale",
|
|
58
|
+
id: "contentLocale",
|
|
59
|
+
className: clsx('byline-view-menu-locale-select', view_menu_module.localeSelect),
|
|
60
|
+
size: "xs",
|
|
61
|
+
variant: "outlined",
|
|
62
|
+
value: locale ?? defaultContentLocale,
|
|
63
|
+
items: [
|
|
64
|
+
...'history' === activeView ? [
|
|
65
|
+
{
|
|
66
|
+
value: 'all',
|
|
67
|
+
label: t('collections.viewMenu.localeAll')
|
|
68
|
+
}
|
|
69
|
+
] : [],
|
|
70
|
+
...contentLocales.map((entry)=>({
|
|
71
|
+
value: entry.code,
|
|
72
|
+
label: entry.label
|
|
73
|
+
}))
|
|
74
|
+
],
|
|
75
|
+
onValueChange: handleLocaleChange
|
|
76
|
+
})
|
|
77
|
+
]
|
|
97
78
|
}),
|
|
98
|
-
/*#__PURE__*/
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
79
|
+
/*#__PURE__*/ jsxs("div", {
|
|
80
|
+
className: clsx('byline-view-menu-nav', view_menu_module.group),
|
|
81
|
+
children: [
|
|
82
|
+
adminConfig?.preview != null && null != doc && /*#__PURE__*/ jsx(SingletonPreviewLink, {
|
|
83
|
+
doc: doc,
|
|
84
|
+
adminConfig: adminConfig,
|
|
85
|
+
locale: locale,
|
|
86
|
+
className: clsx('byline-view-menu-icon-button', view_menu_module.iconButton)
|
|
87
|
+
}),
|
|
88
|
+
/*#__PURE__*/ jsx(Tooltip, {
|
|
89
|
+
text: t('collections.breadcrumbs.history'),
|
|
90
|
+
side: "bottom",
|
|
91
|
+
delay: 0,
|
|
92
|
+
children: /*#__PURE__*/ jsx(IconButton, {
|
|
93
|
+
className: clsx('byline-view-menu-icon-button', view_menu_module.iconButton),
|
|
94
|
+
size: "xs",
|
|
95
|
+
variant: 'history' === activeView ? 'filled' : 'text',
|
|
96
|
+
"aria-label": t('collections.breadcrumbs.history'),
|
|
97
|
+
onClick: ()=>navigate({
|
|
98
|
+
to: getAdminRoutePath('singletons', '$singleton', 'history'),
|
|
99
|
+
params: {
|
|
100
|
+
singleton
|
|
101
|
+
},
|
|
102
|
+
search: locale ? {
|
|
103
|
+
locale
|
|
104
|
+
} : {}
|
|
105
|
+
}),
|
|
106
|
+
children: /*#__PURE__*/ jsx(HistoryIcon, {
|
|
107
|
+
className: clsx('byline-view-menu-icon-button-icon', view_menu_module.iconButtonIcon)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
110
|
}),
|
|
111
|
-
|
|
111
|
+
/*#__PURE__*/ jsx(Button, {
|
|
112
|
+
size: "xs",
|
|
113
|
+
variant: 'edit' === activeView ? 'filled' : 'outlined',
|
|
114
|
+
className: clsx('byline-view-menu-button', view_menu_module.button),
|
|
115
|
+
onClick: ()=>navigate({
|
|
116
|
+
to: getAdminRoutePath('singletons', '$singleton'),
|
|
117
|
+
params: {
|
|
118
|
+
singleton
|
|
119
|
+
},
|
|
120
|
+
search: locale ? {
|
|
121
|
+
locale
|
|
122
|
+
} : {}
|
|
123
|
+
}),
|
|
124
|
+
children: t('common.actions.edit')
|
|
125
|
+
})
|
|
126
|
+
]
|
|
112
127
|
})
|
|
113
128
|
]
|
|
114
129
|
});
|
|
@@ -26,6 +26,7 @@ function SingletonView({ singletonDefinition, adminConfig, document: loadedDocum
|
|
|
26
26
|
const currentIndex = workflowStatuses.findIndex((status)=>status.name === currentStatus);
|
|
27
27
|
const nextStatus = -1 !== currentIndex && currentIndex < workflowStatuses.length - 1 ? workflowStatuses[currentIndex + 1] : void 0;
|
|
28
28
|
const publishedVersion = document?._publishedVersion ?? null;
|
|
29
|
+
const unavailableBlocks = document?._unavailableBlocks;
|
|
29
30
|
const restoreWarnings = document?._restoreWarnings;
|
|
30
31
|
const scheduledPublicationEnabled = document?._scheduledPublicationEnabled === true;
|
|
31
32
|
const canSchedulePublication = document?._canSchedulePublication === true;
|
|
@@ -332,6 +333,7 @@ function SingletonView({ singletonDefinition, adminConfig, document: loadedDocum
|
|
|
332
333
|
contentLocales: null == document ? void 0 : contentLocales,
|
|
333
334
|
publishedVersion: null == document ? null : publishedVersion,
|
|
334
335
|
restoreWarnings: restoreWarnings,
|
|
336
|
+
unavailableBlocks: unavailableBlocks,
|
|
335
337
|
nextStatus: null == document ? void 0 : nextStatus,
|
|
336
338
|
workflowStatuses: null == document ? void 0 : workflowStatuses,
|
|
337
339
|
onCancel: ()=>navigate({
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
export declare function getCollectionDocument(collection: string, id: string, locale?: string, depth?: number, populateRelations?: boolean): Promise<{
|
|
19
19
|
revision: number;
|
|
20
20
|
_publishedVersion: Record<string, any> | null;
|
|
21
|
+
_unavailableBlocks?: import("@byline/core").UnavailableBlock[] | undefined;
|
|
21
22
|
_restoreWarnings?: string[] | undefined;
|
|
22
23
|
availableLocales?: string[] | undefined;
|
|
23
24
|
_availableVersionLocales?: string[] | undefined;
|
|
@@ -57,6 +57,7 @@ const getDocumentFn = createServerFn({
|
|
|
57
57
|
}) : null;
|
|
58
58
|
}
|
|
59
59
|
const restoreWarnings = serialised._restoreWarnings;
|
|
60
|
+
const unavailableBlocks = document._unavailableBlocks;
|
|
60
61
|
const availableLocales = serialised.availableLocales;
|
|
61
62
|
const availableVersionLocales = serialised._availableVersionLocales;
|
|
62
63
|
const scheduledPublicationEnabled = getServerConfig().scheduledPublication?.enabled === true;
|
|
@@ -71,6 +72,9 @@ const getDocumentFn = createServerFn({
|
|
|
71
72
|
return {
|
|
72
73
|
...withEditableRevision(parsed, document),
|
|
73
74
|
_publishedVersion: publishedVersion,
|
|
75
|
+
...unavailableBlocks?.length ? {
|
|
76
|
+
_unavailableBlocks: unavailableBlocks
|
|
77
|
+
} : {},
|
|
74
78
|
...restoreWarnings && restoreWarnings.length > 0 ? {
|
|
75
79
|
_restoreWarnings: restoreWarnings
|
|
76
80
|
} : {},
|
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.9.0",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=22.22.2"
|
|
9
9
|
},
|
|
@@ -119,15 +119,15 @@
|
|
|
119
119
|
}
|
|
120
120
|
},
|
|
121
121
|
"dependencies": {
|
|
122
|
-
"@byline/admin": "6.
|
|
123
|
-
"@byline/ai": "6.
|
|
124
|
-
"@byline/analytics": "6.
|
|
125
|
-
"@byline/analytics-agent": "6.
|
|
126
|
-
"@byline/auth": "6.
|
|
127
|
-
"@byline/client": "6.
|
|
128
|
-
"@byline/core": "6.
|
|
129
|
-
"@byline/i18n": "6.
|
|
130
|
-
"@byline/ui": "6.
|
|
122
|
+
"@byline/admin": "6.9.0",
|
|
123
|
+
"@byline/ai": "6.9.0",
|
|
124
|
+
"@byline/analytics": "6.9.0",
|
|
125
|
+
"@byline/analytics-agent": "6.9.0",
|
|
126
|
+
"@byline/auth": "6.9.0",
|
|
127
|
+
"@byline/client": "6.9.0",
|
|
128
|
+
"@byline/core": "6.9.0",
|
|
129
|
+
"@byline/i18n": "6.9.0",
|
|
130
|
+
"@byline/ui": "6.9.0",
|
|
131
131
|
"@dnd-kit/core": "^6.3.1",
|
|
132
132
|
"@dnd-kit/sortable": "^10.0.0",
|
|
133
133
|
"clsx": "^2.1.1",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
5
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
@@ -20,8 +22,6 @@ import { Branding } from './branding.js'
|
|
|
20
22
|
import { Breadcrumbs } from './breadcrumbs/breadcrumbs.js'
|
|
21
23
|
import { useBreadcrumbs } from './breadcrumbs/breadcrumbs-provider.js'
|
|
22
24
|
|
|
23
|
-
;('use client')
|
|
24
|
-
|
|
25
25
|
interface AdminAppBarProps {
|
|
26
26
|
user: CurrentAdminUser
|
|
27
27
|
}
|
|
@@ -129,8 +129,8 @@ describe('AdminDashboard document resources', () => {
|
|
|
129
129
|
routes: { admin: '/internal/cms' },
|
|
130
130
|
collections: [articles, settings],
|
|
131
131
|
admin: [
|
|
132
|
-
{
|
|
133
|
-
{ singleton: true,
|
|
132
|
+
{ path: articles.path, group: 'content' },
|
|
133
|
+
{ singleton: true, path: settings.path, group: 'content' },
|
|
134
134
|
],
|
|
135
135
|
collectionGroups: [{ name: 'content', label: 'Content' }],
|
|
136
136
|
i18n: {
|
|
@@ -0,0 +1,223 @@
|
|
|
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 type { ComponentProps, ReactNode } from 'react'
|
|
10
|
+
import { act } from 'react'
|
|
11
|
+
|
|
12
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
13
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
14
|
+
import { createRoot } from 'react-dom/client'
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
16
|
+
|
|
17
|
+
const mocks = vi.hoisted(() => ({ toast: vi.fn(), writeText: vi.fn() }))
|
|
18
|
+
vi.mock('@byline/ui/react', () => ({
|
|
19
|
+
Container: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
20
|
+
Section: ({ children }: { children: ReactNode }) => <section>{children}</section>,
|
|
21
|
+
IconButton: ({
|
|
22
|
+
size: _size,
|
|
23
|
+
variant: _variant,
|
|
24
|
+
...props
|
|
25
|
+
}: ComponentProps<'button'> & {
|
|
26
|
+
size?: string
|
|
27
|
+
variant?: string
|
|
28
|
+
}) => <button {...props} />,
|
|
29
|
+
Tooltip: ({ children }: { children: ReactNode }) => children,
|
|
30
|
+
CopyIcon: () => null,
|
|
31
|
+
DownloadIcon: () => null,
|
|
32
|
+
useToastManager: () => ({ add: mocks.toast }),
|
|
33
|
+
}))
|
|
34
|
+
vi.mock('./view-menu.js', () => ({
|
|
35
|
+
ViewMenu: ({ dataActions }: { dataActions?: ReactNode }) => <div>{dataActions}</div>,
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
import { ApiView } from './api.js'
|
|
39
|
+
|
|
40
|
+
const payload = {
|
|
41
|
+
id: 'document-1',
|
|
42
|
+
fields: {
|
|
43
|
+
title: 'Sample document',
|
|
44
|
+
content: [{ _type: 'text', children: [{ text: 'Hidden nested content' }] }],
|
|
45
|
+
related: { fields: { title: 'Populated relation' } },
|
|
46
|
+
},
|
|
47
|
+
} as ComponentProps<typeof ApiView>['initialData']
|
|
48
|
+
|
|
49
|
+
let container: HTMLDivElement
|
|
50
|
+
let root: ReturnType<typeof createRoot>
|
|
51
|
+
const originalURL = globalThis.URL
|
|
52
|
+
const createObjectURL = vi.fn((_blob: Blob) => 'blob:test-json')
|
|
53
|
+
const revokeObjectURL = vi.fn()
|
|
54
|
+
|
|
55
|
+
// `locale: null` renders as a route without a locale search param.
|
|
56
|
+
function render(data = payload, locale: string | null = 'en', depth = 0) {
|
|
57
|
+
act(() =>
|
|
58
|
+
root.render(
|
|
59
|
+
<I18nProvider
|
|
60
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
61
|
+
activeLocale="en"
|
|
62
|
+
defaultLocale="en"
|
|
63
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
64
|
+
>
|
|
65
|
+
<ApiView
|
|
66
|
+
collectionDefinition={{
|
|
67
|
+
path: 'docs',
|
|
68
|
+
labels: { singular: 'Doc', plural: 'Docs' },
|
|
69
|
+
fields: [],
|
|
70
|
+
}}
|
|
71
|
+
initialData={data}
|
|
72
|
+
locale={locale ?? undefined}
|
|
73
|
+
depth={depth}
|
|
74
|
+
contentLocales={[
|
|
75
|
+
{ code: 'en', label: 'English' },
|
|
76
|
+
{ code: 'fr', label: 'French' },
|
|
77
|
+
]}
|
|
78
|
+
defaultContentLocale="fr"
|
|
79
|
+
/>
|
|
80
|
+
</I18nProvider>
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function click(label: string) {
|
|
86
|
+
const button = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
|
87
|
+
if (!button) throw new Error(`Button not found: ${label}`)
|
|
88
|
+
await act(async () => button.click())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readBlob(blob: Blob): Promise<string> {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const reader = new FileReader()
|
|
94
|
+
reader.onload = () => resolve(String(reader.result))
|
|
95
|
+
reader.onerror = () => reject(reader.error)
|
|
96
|
+
reader.readAsText(blob)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
102
|
+
vi.stubGlobal(
|
|
103
|
+
'URL',
|
|
104
|
+
Object.assign(class extends originalURL {}, { createObjectURL, revokeObjectURL })
|
|
105
|
+
)
|
|
106
|
+
vi.stubGlobal('navigator', { clipboard: { writeText: mocks.writeText } })
|
|
107
|
+
mocks.writeText.mockResolvedValue(undefined)
|
|
108
|
+
container = document.createElement('div')
|
|
109
|
+
document.body.append(container)
|
|
110
|
+
root = createRoot(container)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
act(() => root.unmount())
|
|
115
|
+
container.remove()
|
|
116
|
+
vi.useRealTimers()
|
|
117
|
+
vi.restoreAllMocks()
|
|
118
|
+
vi.unstubAllGlobals()
|
|
119
|
+
vi.clearAllMocks()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('API document exports', () => {
|
|
123
|
+
it('copies the complete payload without expanding or mounting nested content', async () => {
|
|
124
|
+
render()
|
|
125
|
+
expect(container.textContent).not.toContain('Hidden nested content')
|
|
126
|
+
await click('Copy JSON')
|
|
127
|
+
expect(JSON.parse(mocks.writeText.mock.calls[0]?.[0])).toEqual(payload)
|
|
128
|
+
expect(container.textContent).not.toContain('Hidden nested content')
|
|
129
|
+
expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'JSON copied' }))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('copies new data after a locale and population-depth reload', async () => {
|
|
133
|
+
render()
|
|
134
|
+
const reloaded = {
|
|
135
|
+
...payload,
|
|
136
|
+
fields: { title: 'Français', related: { title: 'Relation française' } },
|
|
137
|
+
}
|
|
138
|
+
render(reloaded, 'fr', 2)
|
|
139
|
+
await click('Copy JSON')
|
|
140
|
+
expect(JSON.parse(mocks.writeText.mock.calls[0]?.[0])).toEqual(reloaded)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('reports clipboard rejection without claiming success', async () => {
|
|
144
|
+
mocks.writeText.mockRejectedValue(new Error('Permission denied'))
|
|
145
|
+
render()
|
|
146
|
+
await click('Copy JSON')
|
|
147
|
+
expect(mocks.toast).toHaveBeenCalledTimes(1)
|
|
148
|
+
expect(mocks.toast).toHaveBeenCalledWith(
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
title: 'Could not copy JSON. Try Download JSON.',
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
expect(container.querySelector('button')?.disabled).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('offers download when the clipboard API is unavailable', async () => {
|
|
157
|
+
vi.stubGlobal('navigator', {})
|
|
158
|
+
render()
|
|
159
|
+
await click('Copy JSON')
|
|
160
|
+
expect(mocks.toast).toHaveBeenCalledWith(
|
|
161
|
+
expect.objectContaining({
|
|
162
|
+
title: 'Could not copy JSON. Try Download JSON.',
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
expect(mocks.writeText).not.toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('names a download after the default locale when the route has none', async () => {
|
|
169
|
+
vi.useFakeTimers()
|
|
170
|
+
let filename = ''
|
|
171
|
+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
|
172
|
+
this: HTMLAnchorElement
|
|
173
|
+
) {
|
|
174
|
+
filename = this.download
|
|
175
|
+
})
|
|
176
|
+
render(payload, null)
|
|
177
|
+
await click('Download JSON')
|
|
178
|
+
expect(filename).toBe('docs-document-1-fr.json')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('reports a download failure and releases the allocated URL', async () => {
|
|
182
|
+
vi.useFakeTimers()
|
|
183
|
+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
|
|
184
|
+
throw new Error('Download unavailable')
|
|
185
|
+
})
|
|
186
|
+
render()
|
|
187
|
+
await click('Download JSON')
|
|
188
|
+
expect(mocks.toast).toHaveBeenCalledWith(
|
|
189
|
+
expect.objectContaining({
|
|
190
|
+
title: 'Could not download JSON.',
|
|
191
|
+
})
|
|
192
|
+
)
|
|
193
|
+
expect(document.querySelector('a[download]')).toBeNull()
|
|
194
|
+
vi.advanceTimersByTime(1000)
|
|
195
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-json')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('downloads complete current JSON and releases the temporary link and URL', async () => {
|
|
199
|
+
vi.useFakeTimers()
|
|
200
|
+
let filename = ''
|
|
201
|
+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
|
202
|
+
this: HTMLAnchorElement
|
|
203
|
+
) {
|
|
204
|
+
filename = this.download
|
|
205
|
+
expect(this.isConnected).toBe(true)
|
|
206
|
+
expect(this.href).toBe('blob:test-json')
|
|
207
|
+
})
|
|
208
|
+
render()
|
|
209
|
+
const reloaded = { ...payload, fields: { title: 'Français', content: payload.fields.content } }
|
|
210
|
+
render(reloaded, 'fr', 2)
|
|
211
|
+
await click('Download JSON')
|
|
212
|
+
const blob = createObjectURL.mock.calls[0]?.[0] as Blob
|
|
213
|
+
expect(blob.type).toBe('application/json;charset=utf-8')
|
|
214
|
+
expect(filename).toBe('docs-document-1-fr-depth2.json')
|
|
215
|
+
expect(document.querySelector('a[download]')).toBeNull()
|
|
216
|
+
expect(revokeObjectURL).not.toHaveBeenCalled()
|
|
217
|
+
vi.advanceTimersByTime(1000)
|
|
218
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-json')
|
|
219
|
+
vi.useRealTimers()
|
|
220
|
+
expect(JSON.parse(await readBlob(blob))).toEqual(reloaded)
|
|
221
|
+
expect(container.textContent).not.toContain('Hidden nested content')
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -6,10 +6,20 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
|
|
9
11
|
import type { MultiCollectionDefinition } from '@byline/core'
|
|
10
12
|
import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
|
|
11
13
|
import { useTranslation } from '@byline/i18n/react'
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
Container,
|
|
16
|
+
CopyIcon,
|
|
17
|
+
DownloadIcon,
|
|
18
|
+
IconButton,
|
|
19
|
+
Section,
|
|
20
|
+
Tooltip,
|
|
21
|
+
useToastManager,
|
|
22
|
+
} from '@byline/ui/react'
|
|
13
23
|
import cx from 'clsx'
|
|
14
24
|
import { darkStyles, JsonView } from 'react-json-view-lite'
|
|
15
25
|
|
|
@@ -36,8 +46,12 @@ const expandToFirstLevel = (level: number): boolean => level < 2
|
|
|
36
46
|
/**
|
|
37
47
|
* JSON inspector view for a collection document.
|
|
38
48
|
*
|
|
49
|
+
* Copy JSON and Download JSON render in the view menu's data group, beside the
|
|
50
|
+
* locale and depth controls whose selection they export.
|
|
51
|
+
*
|
|
39
52
|
* Stable override handles: `.byline-api-section`, `.byline-api-container`,
|
|
40
|
-
* `.byline-api-head`, `.byline-api-title`, `.byline-api-
|
|
53
|
+
* `.byline-api-head`, `.byline-api-title`, `.byline-api-action`,
|
|
54
|
+
* `.byline-api-viewer`.
|
|
41
55
|
*/
|
|
42
56
|
export const ApiView = ({
|
|
43
57
|
collectionDefinition,
|
|
@@ -49,7 +63,7 @@ export const ApiView = ({
|
|
|
49
63
|
}: {
|
|
50
64
|
collectionDefinition: MultiCollectionDefinition
|
|
51
65
|
initialData: AnyCollectionSchemaTypes['UpdateType']
|
|
52
|
-
/**
|
|
66
|
+
/** Locale from the route search param (undefined → the default content locale). */
|
|
53
67
|
locale?: string
|
|
54
68
|
/** Populate depth from the route search param (undefined → 0, no populate). */
|
|
55
69
|
depth?: number
|
|
@@ -58,6 +72,58 @@ export const ApiView = ({
|
|
|
58
72
|
}) => {
|
|
59
73
|
const { labels, path } = collectionDefinition
|
|
60
74
|
const { t } = useTranslation('byline-admin')
|
|
75
|
+
const toastManager = useToastManager()
|
|
76
|
+
const [copyPending, setCopyPending] = useState(false)
|
|
77
|
+
|
|
78
|
+
// Serialize only on demand, from the complete loaded payload rather than
|
|
79
|
+
// the mounted tree. Collapsed branches remain part of both exports.
|
|
80
|
+
const copyJson = async () => {
|
|
81
|
+
setCopyPending(true)
|
|
82
|
+
try {
|
|
83
|
+
await navigator.clipboard.writeText(JSON.stringify(initialData, null, 2))
|
|
84
|
+
toastManager.add({
|
|
85
|
+
title: t('collections.api.copied'),
|
|
86
|
+
data: { intent: 'success', iconType: 'success', icon: true, close: true },
|
|
87
|
+
})
|
|
88
|
+
} catch {
|
|
89
|
+
toastManager.add({
|
|
90
|
+
title: t('collections.api.copyFailed'),
|
|
91
|
+
data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
|
|
92
|
+
})
|
|
93
|
+
} finally {
|
|
94
|
+
setCopyPending(false)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const downloadJson = () => {
|
|
99
|
+
try {
|
|
100
|
+
const blob = new Blob([JSON.stringify(initialData, null, 2)], {
|
|
101
|
+
type: 'application/json;charset=utf-8',
|
|
102
|
+
})
|
|
103
|
+
const url = URL.createObjectURL(blob)
|
|
104
|
+
const link = document.createElement('a')
|
|
105
|
+
try {
|
|
106
|
+
link.href = url
|
|
107
|
+
// Name the selection, so exports of one document in different locales
|
|
108
|
+
// or population depths do not collide.
|
|
109
|
+
const selection = [path, String(initialData.id), locale ?? defaultContentLocale]
|
|
110
|
+
if (depth != null && depth > 0) selection.push(`depth${depth}`)
|
|
111
|
+
const filename = selection.join('-').replace(/[^a-zA-Z0-9_-]/g, '-')
|
|
112
|
+
link.download = `${filename}.json`
|
|
113
|
+
document.body.appendChild(link)
|
|
114
|
+
link.click()
|
|
115
|
+
} finally {
|
|
116
|
+
link.remove()
|
|
117
|
+
// Let the browser acquire the download before releasing its URL.
|
|
118
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
toastManager.add({
|
|
122
|
+
title: t('collections.api.downloadFailed'),
|
|
123
|
+
data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
61
127
|
|
|
62
128
|
return (
|
|
63
129
|
<Section className={cx('byline-api-section', styles.section)}>
|
|
@@ -74,6 +140,35 @@ export const ApiView = ({
|
|
|
74
140
|
depth={depth}
|
|
75
141
|
contentLocales={contentLocales}
|
|
76
142
|
defaultContentLocale={defaultContentLocale}
|
|
143
|
+
dataActions={
|
|
144
|
+
<>
|
|
145
|
+
<Tooltip text={t('collections.api.copyJson')} side="bottom" delay={0}>
|
|
146
|
+
<IconButton
|
|
147
|
+
type="button"
|
|
148
|
+
size="xs"
|
|
149
|
+
variant="text"
|
|
150
|
+
className="byline-view-menu-icon-button byline-api-action"
|
|
151
|
+
aria-label={t('collections.api.copyJson')}
|
|
152
|
+
disabled={copyPending}
|
|
153
|
+
onClick={copyJson}
|
|
154
|
+
>
|
|
155
|
+
<CopyIcon width="18px" height="18px" />
|
|
156
|
+
</IconButton>
|
|
157
|
+
</Tooltip>
|
|
158
|
+
<Tooltip text={t('collections.api.downloadJson')} side="bottom" delay={0}>
|
|
159
|
+
<IconButton
|
|
160
|
+
type="button"
|
|
161
|
+
size="xs"
|
|
162
|
+
variant="text"
|
|
163
|
+
className="byline-view-menu-icon-button byline-api-action"
|
|
164
|
+
aria-label={t('collections.api.downloadJson')}
|
|
165
|
+
onClick={downloadJson}
|
|
166
|
+
>
|
|
167
|
+
<DownloadIcon width="20px" height="20px" />
|
|
168
|
+
</IconButton>
|
|
169
|
+
</Tooltip>
|
|
170
|
+
</>
|
|
171
|
+
}
|
|
77
172
|
/>
|
|
78
173
|
</div>
|
|
79
174
|
<div className={cx('byline-api-viewer', styles.viewer)}>
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
import { useState } from 'react'
|
|
10
10
|
|
|
11
11
|
import { FormRenderer } from '@byline/admin/react'
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
CollectionAdminConfig,
|
|
14
|
+
MultiCollectionDefinition,
|
|
15
|
+
UnavailableBlock,
|
|
16
|
+
} from '@byline/core'
|
|
13
17
|
import {
|
|
14
18
|
getDefaultStatus,
|
|
15
19
|
getWorkflow,
|
|
@@ -321,6 +325,9 @@ export const EditView = ({
|
|
|
321
325
|
// orphan row was skipped because the collection schema has moved on
|
|
322
326
|
// since the document was written.
|
|
323
327
|
// biome-ignore lint/suspicious/noExplicitAny: storage shape
|
|
328
|
+
const unavailableBlocks = (initialData as any)?._unavailableBlocks as
|
|
329
|
+
| UnavailableBlock[]
|
|
330
|
+
| undefined
|
|
324
331
|
const restoreWarnings = (initialData as any)?._restoreWarnings as string[] | undefined
|
|
325
332
|
|
|
326
333
|
const handleUnpublish = async () => {
|
|
@@ -854,6 +861,7 @@ export const EditView = ({
|
|
|
854
861
|
contentLocales={contentLocales}
|
|
855
862
|
publishedVersion={publishedVersion}
|
|
856
863
|
restoreWarnings={restoreWarnings}
|
|
864
|
+
unavailableBlocks={unavailableBlocks}
|
|
857
865
|
nextStatus={nextStatus}
|
|
858
866
|
workflowStatuses={workflowStatuses}
|
|
859
867
|
onCancel={() => {
|