@byline/host-tanstack-start 6.8.0 → 6.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/admin-shell/chrome/admin-app-bar.d.ts +0 -7
  2. package/dist/admin-shell/collections/api.d.ts +6 -2
  3. package/dist/admin-shell/collections/api.js +109 -3
  4. package/dist/admin-shell/collections/api.test.d.ts +8 -0
  5. package/dist/admin-shell/collections/edit.js +2 -0
  6. package/dist/admin-shell/collections/preview-link.js +17 -13
  7. package/dist/admin-shell/collections/view-menu.d.ts +8 -1
  8. package/dist/admin-shell/collections/view-menu.js +124 -107
  9. package/dist/admin-shell/collections/view-menu.module.js +1 -0
  10. package/dist/admin-shell/collections/view-menu_module.css +15 -1
  11. package/dist/admin-shell/scheduled-publications/list.js +2 -2
  12. package/dist/admin-shell/singletons/preview-link.js +17 -13
  13. package/dist/admin-shell/singletons/view-menu.js +78 -63
  14. package/dist/admin-shell/singletons/view.js +2 -0
  15. package/dist/server-fns/collections/get.d.ts +1 -0
  16. package/dist/server-fns/collections/get.js +4 -0
  17. package/package.json +15 -15
  18. package/src/admin-shell/chrome/admin-app-bar.tsx +2 -2
  19. package/src/admin-shell/chrome/dashboard.test.tsx +2 -2
  20. package/src/admin-shell/collections/api.test.tsx +223 -0
  21. package/src/admin-shell/collections/api.tsx +98 -3
  22. package/src/admin-shell/collections/edit.tsx +9 -1
  23. package/src/admin-shell/collections/preview-link.tsx +13 -12
  24. package/src/admin-shell/collections/view-menu.module.css +29 -3
  25. package/src/admin-shell/collections/view-menu.tsx +114 -96
  26. package/src/admin-shell/scheduled-publications/list.tsx +3 -4
  27. package/src/admin-shell/singletons/history.test.tsx +1 -1
  28. package/src/admin-shell/singletons/preview-link.tsx +13 -12
  29. package/src/admin-shell/singletons/view-menu.tsx +65 -59
  30. package/src/admin-shell/singletons/view.test.tsx +4 -4
  31. package/src/admin-shell/singletons/view.tsx +3 -1
  32. 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__*/ jsx(Label, {
48
- className: clsx('muted byline-view-menu-label', view_menu_module.label),
49
- id: "contentLocaleLabel",
50
- htmlFor: "contentLocale",
51
- label: t('collections.viewMenu.contentLocaleLabel')
52
- }),
53
- /*#__PURE__*/ jsx(Select, {
54
- name: "contentLocale",
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
- children: /*#__PURE__*/ jsx(HistoryIcon, {
95
- className: clsx('byline-view-menu-icon-button-icon', view_menu_module.iconButtonIcon)
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__*/ jsx(Button, {
99
- size: "xs",
100
- variant: 'edit' === activeView ? 'filled' : 'outlined',
101
- className: clsx('byline-view-menu-button', view_menu_module.button),
102
- onClick: ()=>navigate({
103
- to: getAdminRoutePath('singletons', '$singleton'),
104
- params: {
105
- singleton
106
- },
107
- search: locale ? {
108
- locale
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
- children: t('common.actions.edit')
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.8.0",
6
+ "version": "6.10.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.8.0",
123
- "@byline/ai": "6.8.0",
124
- "@byline/analytics": "6.8.0",
125
- "@byline/analytics-agent": "6.8.0",
126
- "@byline/auth": "6.8.0",
127
- "@byline/client": "6.8.0",
128
- "@byline/core": "6.8.0",
129
- "@byline/i18n": "6.8.0",
130
- "@byline/ui": "6.8.0",
122
+ "@byline/admin": "6.10.0",
123
+ "@byline/ai": "6.10.0",
124
+ "@byline/analytics": "6.10.0",
125
+ "@byline/analytics-agent": "6.10.0",
126
+ "@byline/auth": "6.10.0",
127
+ "@byline/client": "6.10.0",
128
+ "@byline/core": "6.10.0",
129
+ "@byline/i18n": "6.10.0",
130
+ "@byline/ui": "6.10.0",
131
131
  "@dnd-kit/core": "^6.3.1",
132
132
  "@dnd-kit/sortable": "^10.0.0",
133
133
  "clsx": "^2.1.1",
@@ -143,11 +143,11 @@
143
143
  "vite": "^8.0.0"
144
144
  },
145
145
  "devDependencies": {
146
- "@biomejs/biome": "2.5.15",
146
+ "@biomejs/biome": "2.6.2",
147
147
  "@rsbuild/plugin-react": "^2.1.1",
148
148
  "@rslib/core": "^1.0.3",
149
- "@tanstack/react-router": "^1.170.44",
150
- "@tanstack/react-start": "^1.169.2",
149
+ "@tanstack/react-router": "^1.170.45",
150
+ "@tanstack/react-start": "^1.169.3",
151
151
  "@types/node": "^26.6.5",
152
152
  "@types/react": "19.3.0",
153
153
  "@types/react-dom": "19.3.0",
@@ -158,8 +158,8 @@
158
158
  "react-dom": "^19.3.1",
159
159
  "typescript": "^7.0.2",
160
160
  "typescript-plugin-css-modules": "^5.2.0",
161
- "vite": "^8.3.4",
162
- "vitest": "^5.0.4"
161
+ "vite": "^8.3.5",
162
+ "vitest": "^5.0.5"
163
163
  },
164
164
  "publishConfig": {
165
165
  "access": "public",
@@ -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
- { slug: articles.path, group: 'content' },
133
- { singleton: true, slug: settings.path, group: 'content' },
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 { Container, Section } from '@byline/ui/react'
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-viewer`.
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
- /** Currently active locale from the route search param (undefined → all). */
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)}>