@byline/host-tanstack-start 4.16.0 → 4.17.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 (146) hide show
  1. package/dist/admin-shell/chrome/admin-menu-drawer.js +14 -14
  2. package/dist/admin-shell/chrome/dashboard.js +41 -5
  3. package/dist/admin-shell/chrome/dashboard.test.d.ts +8 -0
  4. package/dist/admin-shell/collections/api.d.ts +2 -2
  5. package/dist/admin-shell/collections/create.d.ts +2 -2
  6. package/dist/admin-shell/collections/create.js +1 -0
  7. package/dist/admin-shell/collections/edit.d.ts +2 -2
  8. package/dist/admin-shell/collections/edit.js +1 -0
  9. package/dist/admin-shell/collections/history.d.ts +4 -12
  10. package/dist/admin-shell/collections/history.js +112 -303
  11. package/dist/admin-shell/collections/reindex-button.d.ts +1 -1
  12. package/dist/admin-shell/collections/reindex-button.js +5 -1
  13. package/dist/admin-shell/collections/restore-version-modal.d.ts +4 -4
  14. package/dist/admin-shell/collections/restore-version-modal.js +8 -24
  15. package/dist/admin-shell/collections/version-history.d.ts +72 -0
  16. package/dist/admin-shell/collections/version-history.js +259 -0
  17. package/dist/admin-shell/singletons/history.d.ts +19 -0
  18. package/dist/admin-shell/singletons/history.js +170 -0
  19. package/dist/admin-shell/singletons/history.test.d.ts +8 -0
  20. package/dist/admin-shell/singletons/preview-link.d.ts +12 -0
  21. package/dist/admin-shell/singletons/preview-link.js +54 -0
  22. package/dist/admin-shell/singletons/view-menu.d.ts +13 -0
  23. package/dist/admin-shell/singletons/view-menu.js +116 -0
  24. package/dist/admin-shell/singletons/view.d.ts +18 -0
  25. package/dist/admin-shell/singletons/view.js +259 -0
  26. package/dist/admin-shell/singletons/view.test.d.ts +8 -0
  27. package/dist/integrations/api-utils.d.ts +18 -4
  28. package/dist/integrations/api-utils.js +10 -3
  29. package/dist/routes/create-admin-dashboard-route.js +2 -2
  30. package/dist/routes/create-collection-api-route.js +3 -3
  31. package/dist/routes/create-collection-create-route.js +4 -3
  32. package/dist/routes/create-collection-edit-route.js +4 -3
  33. package/dist/routes/create-collection-history-route.js +4 -3
  34. package/dist/routes/create-collection-list-route.js +4 -3
  35. package/dist/routes/create-singleton-history-route.d.ts +8 -0
  36. package/dist/routes/create-singleton-history-route.js +99 -0
  37. package/dist/routes/create-singleton-route.d.ts +35 -0
  38. package/dist/routes/create-singleton-route.js +130 -0
  39. package/dist/routes/get-multi-collection-definition.d.ts +10 -0
  40. package/dist/routes/get-multi-collection-definition.js +6 -0
  41. package/dist/routes/get-singleton-definition.d.ts +10 -0
  42. package/dist/routes/get-singleton-definition.js +6 -0
  43. package/dist/routes/index.d.ts +2 -0
  44. package/dist/routes/index.js +2 -0
  45. package/dist/server-fns/actors.d.ts +16 -0
  46. package/dist/server-fns/actors.js +25 -0
  47. package/dist/server-fns/admin-document-presentation.d.ts +30 -0
  48. package/dist/server-fns/admin-document-presentation.js +32 -0
  49. package/dist/server-fns/collections/actors.d.ts +1 -10
  50. package/dist/server-fns/collections/actors.js +1 -25
  51. package/dist/server-fns/collections/get.js +9 -26
  52. package/dist/server-fns/collections/reorder.js +1 -1
  53. package/dist/server-fns/collections/scheduled-publication.js +2 -2
  54. package/dist/server-fns/collections/upload.js +3 -3
  55. package/dist/server-fns/collections/utils.d.ts +1 -7
  56. package/dist/server-fns/collections/utils.js +1 -4
  57. package/dist/server-fns/serialise.d.ts +14 -0
  58. package/dist/server-fns/serialise.js +4 -0
  59. package/dist/server-fns/singleton-document-read.d.ts +20 -0
  60. package/dist/server-fns/singleton-document-read.js +10 -0
  61. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +12 -0
  62. package/dist/server-fns/singletons/cancel-scheduled-publish.js +7 -0
  63. package/dist/server-fns/singletons/change-status.d.ts +14 -0
  64. package/dist/server-fns/singletons/change-status.js +7 -0
  65. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +14 -0
  66. package/dist/server-fns/singletons/confirm-scheduled-publish.js +9 -0
  67. package/dist/server-fns/singletons/copy-to-locale.d.ts +18 -0
  68. package/dist/server-fns/singletons/copy-to-locale.js +11 -0
  69. package/dist/server-fns/singletons/find-by-version.d.ts +16 -0
  70. package/dist/server-fns/singletons/find-by-version.js +9 -0
  71. package/dist/server-fns/singletons/get-scheduled-publish.d.ts +12 -0
  72. package/dist/server-fns/singletons/get-scheduled-publish.js +7 -0
  73. package/dist/server-fns/singletons/get.d.ts +24 -0
  74. package/dist/server-fns/singletons/get.js +6 -0
  75. package/dist/server-fns/singletons/history.d.ts +31 -0
  76. package/dist/server-fns/singletons/history.js +22 -0
  77. package/dist/server-fns/singletons/index.d.ts +19 -0
  78. package/dist/server-fns/singletons/index.js +12 -0
  79. package/dist/server-fns/singletons/restore-version.d.ts +14 -0
  80. package/dist/server-fns/singletons/restore-version.js +7 -0
  81. package/dist/server-fns/singletons/schedule-publish.d.ts +16 -0
  82. package/dist/server-fns/singletons/schedule-publish.js +10 -0
  83. package/dist/server-fns/singletons/unpublish.d.ts +12 -0
  84. package/dist/server-fns/singletons/unpublish.js +7 -0
  85. package/dist/server-fns/singletons/update.d.ts +14 -0
  86. package/dist/server-fns/singletons/update.js +14 -0
  87. package/dist/vitest-jsdom.test.d.ts +8 -0
  88. package/package.json +24 -18
  89. package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -13
  90. package/src/admin-shell/chrome/dashboard.test.tsx +199 -0
  91. package/src/admin-shell/chrome/dashboard.tsx +43 -9
  92. package/src/admin-shell/collections/api.tsx +2 -2
  93. package/src/admin-shell/collections/create.tsx +6 -2
  94. package/src/admin-shell/collections/edit.tsx +6 -2
  95. package/src/admin-shell/collections/history.module.css +1 -1
  96. package/src/admin-shell/collections/history.tsx +162 -442
  97. package/src/admin-shell/collections/reindex-button.tsx +4 -2
  98. package/src/admin-shell/collections/restore-version-modal.tsx +19 -25
  99. package/src/admin-shell/collections/version-history.tsx +349 -0
  100. package/src/admin-shell/singletons/history.test.tsx +407 -0
  101. package/src/admin-shell/singletons/history.tsx +185 -0
  102. package/src/admin-shell/singletons/preview-link.tsx +75 -0
  103. package/src/admin-shell/singletons/view-menu.tsx +136 -0
  104. package/src/admin-shell/singletons/view.test.tsx +400 -0
  105. package/src/admin-shell/singletons/view.tsx +351 -0
  106. package/src/integrations/api-utils.ts +34 -9
  107. package/src/routes/create-admin-dashboard-route.test.node.ts +70 -0
  108. package/src/routes/create-admin-dashboard-route.tsx +10 -2
  109. package/src/routes/create-collection-api-route.tsx +4 -4
  110. package/src/routes/create-collection-create-route.tsx +5 -8
  111. package/src/routes/create-collection-edit-route.tsx +5 -4
  112. package/src/routes/create-collection-history-route.tsx +5 -8
  113. package/src/routes/create-collection-list-route.tsx +5 -8
  114. package/src/routes/create-singleton-history-route.test.node.ts +148 -0
  115. package/src/routes/create-singleton-history-route.tsx +116 -0
  116. package/src/routes/create-singleton-route.test.node.ts +172 -0
  117. package/src/routes/create-singleton-route.tsx +176 -0
  118. package/src/routes/get-multi-collection-definition.ts +16 -0
  119. package/src/routes/get-singleton-definition.ts +16 -0
  120. package/src/routes/index.ts +2 -0
  121. package/src/server-fns/actors.ts +43 -0
  122. package/src/server-fns/admin-document-presentation.ts +78 -0
  123. package/src/server-fns/collections/actors.ts +1 -52
  124. package/src/server-fns/collections/get.ts +10 -49
  125. package/src/server-fns/collections/reorder.ts +1 -1
  126. package/src/server-fns/collections/scheduled-publication.ts +7 -1
  127. package/src/server-fns/collections/upload-singleton.test.node.ts +164 -0
  128. package/src/server-fns/collections/upload.ts +7 -3
  129. package/src/server-fns/collections/utils.ts +1 -9
  130. package/src/server-fns/serialise.ts +17 -0
  131. package/src/server-fns/singleton-document-read.ts +32 -0
  132. package/src/server-fns/singletons/cancel-scheduled-publish.ts +19 -0
  133. package/src/server-fns/singletons/change-status.ts +19 -0
  134. package/src/server-fns/singletons/confirm-scheduled-publish.ts +23 -0
  135. package/src/server-fns/singletons/copy-to-locale.ts +32 -0
  136. package/src/server-fns/singletons/find-by-version.ts +23 -0
  137. package/src/server-fns/singletons/get-scheduled-publish.ts +19 -0
  138. package/src/server-fns/singletons/get.ts +32 -0
  139. package/src/server-fns/singletons/history.ts +41 -0
  140. package/src/server-fns/singletons/index.ts +20 -0
  141. package/src/server-fns/singletons/restore-version.ts +19 -0
  142. package/src/server-fns/singletons/schedule-publish.ts +24 -0
  143. package/src/server-fns/singletons/singletons.test.node.ts +298 -0
  144. package/src/server-fns/singletons/unpublish.ts +19 -0
  145. package/src/server-fns/singletons/update.ts +31 -0
  146. package/src/vitest-jsdom.test.tsx +27 -0
@@ -0,0 +1,136 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useEffect } from 'react'
12
+
13
+ import type { SingletonAdminConfig, SingletonPreviewDocument } from '@byline/core'
14
+ import { useTranslation } from '@byline/i18n/react'
15
+ import { Button, HistoryIcon, IconButton, Label, Select } from '@byline/ui/react'
16
+ import cx from 'clsx'
17
+
18
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
19
+ import { useNavigate } from '../chrome/loose-router.js'
20
+ import collectionStyles from '../collections/view-menu.module.css'
21
+ import { SingletonPreviewLink } from './preview-link.js'
22
+ import type { ContentLocaleOption } from '../collections/view-menu.js'
23
+
24
+ export type SingletonViewMenuPath = 'edit' | 'history'
25
+
26
+ /** Id-less edit/history navigation; preview appears only for a mapped document. */
27
+ export function SingletonViewMenu({
28
+ singleton,
29
+ activeView,
30
+ locale,
31
+ contentLocales,
32
+ defaultContentLocale,
33
+ adminConfig,
34
+ doc,
35
+ }: {
36
+ singleton: string
37
+ activeView: SingletonViewMenuPath
38
+ locale?: string
39
+ contentLocales: ReadonlyArray<ContentLocaleOption>
40
+ defaultContentLocale: string
41
+ adminConfig?: SingletonAdminConfig
42
+ doc?: SingletonPreviewDocument
43
+ }) {
44
+ const navigate = useNavigate()
45
+ const { t } = useTranslation('byline-admin')
46
+
47
+ // Edit always represents one concrete locale. Strip the history-only `all`
48
+ // sentinel if a copied URL brings it back to the form.
49
+ useEffect(() => {
50
+ if (activeView === 'edit' && locale === 'all') {
51
+ navigate({
52
+ to: getAdminRoutePath('singletons', '$singleton'),
53
+ params: { singleton },
54
+ search: { locale: defaultContentLocale },
55
+ replace: true,
56
+ })
57
+ }
58
+ }, [activeView, locale, singleton, defaultContentLocale, navigate])
59
+
60
+ const handleLocaleChange = (value: string | null) => {
61
+ if (value == null) return
62
+ navigate({
63
+ to:
64
+ activeView === 'history'
65
+ ? getAdminRoutePath('singletons', '$singleton', 'history')
66
+ : getAdminRoutePath('singletons', '$singleton'),
67
+ params: { singleton },
68
+ search: { locale: value },
69
+ })
70
+ }
71
+
72
+ return (
73
+ <div className={cx('byline-view-menu', collectionStyles.root)}>
74
+ <Label
75
+ className={cx('muted byline-view-menu-label', collectionStyles.label)}
76
+ id="contentLocaleLabel"
77
+ htmlFor="contentLocale"
78
+ label={t('collections.viewMenu.contentLocaleLabel')}
79
+ />
80
+ <Select<string>
81
+ name="contentLocale"
82
+ id="contentLocale"
83
+ className={cx('byline-view-menu-locale-select', collectionStyles.localeSelect)}
84
+ size="xs"
85
+ variant="outlined"
86
+ value={locale ?? defaultContentLocale}
87
+ items={[
88
+ ...(activeView === 'history'
89
+ ? [{ value: 'all', label: t('collections.viewMenu.localeAll') }]
90
+ : []),
91
+ ...contentLocales.map((entry) => ({ value: entry.code, label: entry.label })),
92
+ ]}
93
+ onValueChange={handleLocaleChange}
94
+ />
95
+ {adminConfig?.preview != null && doc != null && (
96
+ <SingletonPreviewLink
97
+ doc={doc}
98
+ adminConfig={adminConfig}
99
+ locale={locale}
100
+ className={cx('byline-view-menu-icon-button', collectionStyles.iconButton)}
101
+ />
102
+ )}
103
+ <IconButton
104
+ className={cx('byline-view-menu-icon-button', collectionStyles.iconButton)}
105
+ size="xs"
106
+ variant={activeView === 'history' ? 'filled' : 'text'}
107
+ aria-label={t('collections.breadcrumbs.history')}
108
+ onClick={() =>
109
+ navigate({
110
+ to: getAdminRoutePath('singletons', '$singleton', 'history'),
111
+ params: { singleton },
112
+ search: locale ? { locale } : {},
113
+ })
114
+ }
115
+ >
116
+ <HistoryIcon
117
+ className={cx('byline-view-menu-icon-button-icon', collectionStyles.iconButtonIcon)}
118
+ />
119
+ </IconButton>
120
+ <Button
121
+ size="xs"
122
+ variant={activeView === 'edit' ? 'filled' : 'outlined'}
123
+ className={cx('byline-view-menu-button', collectionStyles.button)}
124
+ onClick={() =>
125
+ navigate({
126
+ to: getAdminRoutePath('singletons', '$singleton'),
127
+ params: { singleton },
128
+ search: locale ? { locale } : {},
129
+ })
130
+ }
131
+ >
132
+ {t('common.actions.edit')}
133
+ </Button>
134
+ </div>
135
+ )
136
+ }
@@ -0,0 +1,400 @@
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 } from 'react'
10
+
11
+ import { BylineFieldServicesProvider } from '@byline/admin/react'
12
+ import { defineAdminConfig, defineSingleton, type SingletonDefinition } from '@byline/core'
13
+ import { adminTranslations } from '@byline/i18n/admin'
14
+ import { I18nProvider } from '@byline/i18n/react'
15
+ import { createRoot, type Root } from 'react-dom/client'
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
17
+
18
+ const mocks = vi.hoisted(() => ({
19
+ navigate: vi.fn(),
20
+ invalidate: vi.fn(async () => {}),
21
+ toast: vi.fn(),
22
+ guardStates: [] as boolean[],
23
+ update: vi.fn(),
24
+ changeStatus: vi.fn(),
25
+ unpublish: vi.fn(),
26
+ schedule: vi.fn(),
27
+ confirmSchedule: vi.fn(),
28
+ cancelSchedule: vi.fn(),
29
+ copyToLocale: vi.fn(),
30
+ }))
31
+
32
+ vi.mock('@tanstack/react-router', () => ({
33
+ useRouter: () => ({ invalidate: mocks.invalidate }),
34
+ }))
35
+
36
+ vi.mock('../chrome/loose-router.js', () => ({
37
+ useNavigate: () => mocks.navigate,
38
+ }))
39
+
40
+ vi.mock('../collections/tanstack-navigation-guard.js', () => ({
41
+ useTanStackNavigationGuard: (shouldBlock: boolean) => {
42
+ mocks.guardStates.push(shouldBlock)
43
+ return { isBlocked: false, stay: () => {}, proceed: () => {} }
44
+ },
45
+ }))
46
+
47
+ vi.mock('../../server-fns/singletons/index.js', () => ({
48
+ updateSingleton: mocks.update,
49
+ changeSingletonStatus: mocks.changeStatus,
50
+ unpublishSingleton: mocks.unpublish,
51
+ scheduleSingletonPublish: mocks.schedule,
52
+ confirmSingletonScheduledPublish: mocks.confirmSchedule,
53
+ cancelSingletonScheduledPublish: mocks.cancelSchedule,
54
+ copySingletonToLocale: mocks.copyToLocale,
55
+ }))
56
+
57
+ vi.mock('../../server-fns/preview/index.js', () => ({
58
+ enablePreviewModeFn: vi.fn(async () => {}),
59
+ }))
60
+
61
+ vi.mock('@byline/ui/react', async (importOriginal) => {
62
+ const actual = await importOriginal<Record<string, unknown>>()
63
+ const Pass = ({ children }: any) => <div>{children}</div>
64
+ const Modal: any = ({ children, isOpen }: any) => (isOpen ? <div>{children}</div> : null)
65
+ Modal.Container = Pass
66
+ Modal.Header = Pass
67
+ Modal.Content = Pass
68
+ Modal.Actions = Pass
69
+ return {
70
+ ...actual,
71
+ useToastManager: () => ({ add: mocks.toast }),
72
+ Dropdown: {
73
+ Root: Pass,
74
+ Trigger: ({ children }: any) => <div data-testid="actions-trigger">{children}</div>,
75
+ Portal: Pass,
76
+ Content: Pass,
77
+ Item: ({ children, onClick }: any) => (
78
+ <div onClick={onClick} onKeyDown={() => {}} role="menuitem" tabIndex={0}>
79
+ {children}
80
+ </div>
81
+ ),
82
+ Separator: () => <hr />,
83
+ },
84
+ Modal,
85
+ Input: ({ name, value, onChange }: any) => (
86
+ <input name={name} value={value ?? ''} onChange={onChange} />
87
+ ),
88
+ Label: ({ label, htmlFor, className }: any) => (
89
+ <label htmlFor={htmlFor} className={className}>
90
+ {label}
91
+ </label>
92
+ ),
93
+ Select: ({ id, name, value, items, onValueChange, className }: any) => (
94
+ <select
95
+ id={id}
96
+ name={name}
97
+ value={value}
98
+ className={className}
99
+ onChange={(event) => onValueChange(event.target.value)}
100
+ >
101
+ {items.map((item: { value: string; label: string }) => (
102
+ <option key={item.value} value={item.value}>
103
+ {item.label}
104
+ </option>
105
+ ))}
106
+ </select>
107
+ ),
108
+ }
109
+ })
110
+
111
+ import { SingletonView } from './view.js'
112
+
113
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
114
+
115
+ const singleton = defineSingleton({
116
+ path: 'site-settings',
117
+ label: 'Site settings',
118
+ fields: [
119
+ { name: 'title', label: 'Title', type: 'text', defaultValue: 'Default title' },
120
+ {
121
+ name: 'attachment',
122
+ label: 'Attachment',
123
+ type: 'file',
124
+ optional: true,
125
+ upload: { requireSavedDocument: true },
126
+ },
127
+ ],
128
+ }) satisfies SingletonDefinition
129
+
130
+ const adminConfig = {
131
+ singleton: true as const,
132
+ slug: singleton.path,
133
+ preview: { url: () => '/preview-settings' },
134
+ }
135
+
136
+ const locales = [
137
+ { code: 'en', label: 'English' },
138
+ { code: 'fr', label: 'Français' },
139
+ ]
140
+
141
+ const loadedDocument = {
142
+ id: 'doc-settings',
143
+ versionId: 'version-1',
144
+ path: '__singleton__/site-settings',
145
+ status: 'draft',
146
+ fields: { title: 'Loaded title', attachment: null },
147
+ _publishedVersion: {
148
+ id: 'doc-settings',
149
+ versionId: 'published-1',
150
+ status: 'published',
151
+ createdAt: '2026-08-25T00:00:00.000Z',
152
+ updatedAt: '2026-08-25T00:00:00.000Z',
153
+ },
154
+ _scheduledPublicationEnabled: true,
155
+ _canSchedulePublication: true,
156
+ _scheduledPublish: null,
157
+ }
158
+
159
+ const fieldServices = {
160
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
161
+ uploadField: async () => ({}),
162
+ } as any
163
+
164
+ let container: HTMLDivElement
165
+ let root: Root
166
+
167
+ function view(
168
+ currentDocument: typeof loadedDocument | null,
169
+ locale = 'en',
170
+ config: typeof adminConfig | { singleton: true; slug: string } = adminConfig
171
+ ) {
172
+ return (
173
+ <I18nProvider
174
+ bundle={adminTranslations({ locales: ['en'] })}
175
+ activeLocale="en"
176
+ defaultLocale="en"
177
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
178
+ >
179
+ <BylineFieldServicesProvider services={fieldServices}>
180
+ <SingletonView
181
+ singletonDefinition={singleton}
182
+ adminConfig={config}
183
+ document={currentDocument}
184
+ initialData={currentDocument == null ? { title: 'Default title' } : undefined}
185
+ locale={locale}
186
+ contentLocales={locales}
187
+ defaultContentLocale="en"
188
+ />
189
+ </BylineFieldServicesProvider>
190
+ </I18nProvider>
191
+ )
192
+ }
193
+
194
+ function render(
195
+ currentDocument: typeof loadedDocument | null,
196
+ locale = 'en',
197
+ config: typeof adminConfig | { singleton: true; slug: string } = adminConfig
198
+ ) {
199
+ act(() => root.render(view(currentDocument, locale, config)))
200
+ }
201
+
202
+ function typeIntoTitle(value: string) {
203
+ const input = container.querySelector<HTMLInputElement>('input[name="title"]')
204
+ if (input == null) throw new Error('title input not found')
205
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
206
+ act(() => {
207
+ setter?.call(input, value)
208
+ input.dispatchEvent(new Event('input', { bubbles: true }))
209
+ })
210
+ }
211
+
212
+ async function submit() {
213
+ const form = container.querySelector('form')
214
+ if (form == null) throw new Error('form not found')
215
+ await act(async () => {
216
+ form.requestSubmit()
217
+ })
218
+ }
219
+
220
+ function latestToastDescription(): string {
221
+ const call = mocks.toast.mock.calls.at(-1)?.[0] as { description?: string } | undefined
222
+ return call?.description ?? ''
223
+ }
224
+
225
+ beforeEach(() => {
226
+ vi.clearAllMocks()
227
+ mocks.guardStates.length = 0
228
+ mocks.update.mockResolvedValue({ versionId: 'version-next' })
229
+ mocks.changeStatus.mockResolvedValue({ newStatus: 'published' })
230
+ mocks.unpublish.mockResolvedValue({ archivedCount: 1 })
231
+ mocks.schedule.mockResolvedValue({ state: 'armed' })
232
+ mocks.confirmSchedule.mockResolvedValue({ state: 'armed' })
233
+ mocks.cancelSchedule.mockResolvedValue(null)
234
+ mocks.copyToLocale.mockResolvedValue({ versionId: 'version-next' })
235
+
236
+ defineAdminConfig({
237
+ routes: { admin: '/internal/cms' },
238
+ collections: [singleton],
239
+ admin: [adminConfig],
240
+ slugifier: (value) => value.toLowerCase(),
241
+ i18n: {
242
+ admin: { defaultLocale: 'en', locales: ['en'] },
243
+ content: {
244
+ defaultLocale: 'en',
245
+ locales: ['en', 'fr'],
246
+ localeDefinitions: locales.map(({ code, label }) => ({ code, nativeName: label })),
247
+ },
248
+ },
249
+ })
250
+
251
+ container = document.createElement('div')
252
+ document.body.appendChild(container)
253
+ root = createRoot(container)
254
+ })
255
+
256
+ afterEach(() => {
257
+ act(() => root.unmount())
258
+ container.remove()
259
+ })
260
+
261
+ describe('SingletonView', () => {
262
+ it('renders an unmaterialised slot with stable identity and no document-only affordances', () => {
263
+ render(null)
264
+
265
+ expect(container.querySelector('h1')?.textContent).toBe('Site settings')
266
+ expect(container.textContent).not.toContain('Create Site settings')
267
+ expect(container.querySelector<HTMLInputElement>('input[name="title"]')?.value).toBe(
268
+ 'Default title'
269
+ )
270
+ expect(container.querySelector('.byline-form-path')).toBeNull()
271
+ expect(container.querySelector('[data-testid="actions-trigger"]')).toBeNull()
272
+ expect(container.querySelector('.byline-view-menu')).toBeNull()
273
+ expect(container.querySelector('.byline-preview-link')).toBeNull()
274
+ expect(container.querySelector('.byline-form-actions-status-wrap')).toBeNull()
275
+ expect(container.querySelector('[data-testid="upload-require-saved-document"]')).not.toBeNull()
276
+ })
277
+
278
+ it('reveals the inherited editor capabilities only after materialisation', () => {
279
+ render(loadedDocument)
280
+
281
+ expect(container.querySelector('.byline-view-menu')).not.toBeNull()
282
+ expect(container.querySelector('select[name="contentLocale"]')).not.toBeNull()
283
+ expect(container.querySelector('.byline-preview-link')).not.toBeNull()
284
+ expect(container.querySelector('button[aria-label="History"]')).not.toBeNull()
285
+ expect(container.querySelector('.byline-form-actions-status-wrap')).not.toBeNull()
286
+ expect(container.querySelector('[data-testid="actions-trigger"]')).not.toBeNull()
287
+ expect(container.textContent).toContain('Copy to Locale')
288
+ expect(container.textContent).toContain('Schedule')
289
+ expect(container.textContent).toContain('Unpublish')
290
+ expect(container.querySelector('[data-testid="upload-require-saved-document"]')).toBeNull()
291
+ expect(container.querySelector('.byline-field-file-upload')).not.toBeNull()
292
+ expect(container.textContent).not.toContain('Delete')
293
+ expect(container.textContent).not.toContain('Duplicate')
294
+ expect(container.textContent).not.toContain('API')
295
+
296
+ act(() => container.querySelector<HTMLButtonElement>('button[aria-label="History"]')?.click())
297
+ expect(mocks.navigate).toHaveBeenCalledWith({
298
+ to: '/internal/cms/singletons/$singleton/history',
299
+ params: { singleton: 'site-settings' },
300
+ search: { locale: 'en' },
301
+ })
302
+ })
303
+
304
+ it('never derives a singleton preview from its internal stored path', () => {
305
+ render(loadedDocument, 'en', { singleton: true, slug: singleton.path })
306
+
307
+ expect(container.querySelector('.byline-view-menu')).not.toBeNull()
308
+ expect(container.querySelector('.byline-preview-link')).toBeNull()
309
+ })
310
+
311
+ it('saves full data with the active locale and the correct optimistic version', async () => {
312
+ render(null, 'en')
313
+ typeIntoTitle('First save')
314
+ await submit()
315
+
316
+ expect(mocks.update).toHaveBeenLastCalledWith({
317
+ data: {
318
+ singleton: 'site-settings',
319
+ data: { title: 'First save', attachment: undefined },
320
+ locale: 'en',
321
+ expectedVersionId: undefined,
322
+ },
323
+ })
324
+ expect(mocks.invalidate).toHaveBeenCalledTimes(1)
325
+
326
+ act(() => root.unmount())
327
+ root = createRoot(container)
328
+ render(loadedDocument, 'fr')
329
+ typeIntoTitle('Second save')
330
+ await submit()
331
+
332
+ expect(mocks.update).toHaveBeenLastCalledWith({
333
+ data: {
334
+ singleton: 'site-settings',
335
+ data: { title: 'Second save', attachment: null },
336
+ locale: 'fr',
337
+ expectedVersionId: 'version-1',
338
+ },
339
+ })
340
+ expect(mocks.invalidate).toHaveBeenCalledTimes(2)
341
+ })
342
+
343
+ it('distinguishes stale saves from a singleton missing on the server', async () => {
344
+ mocks.update.mockRejectedValueOnce({ code: 'ERR_CONFLICT' })
345
+ render(loadedDocument)
346
+ typeIntoTitle('Stale edit')
347
+ await submit()
348
+ const conflict = latestToastDescription()
349
+ expect(conflict).toContain('Someone else saved this singleton first')
350
+
351
+ act(() => root.unmount())
352
+ root = createRoot(container)
353
+ mocks.update.mockRejectedValueOnce({ code: 'ERR_NOT_FOUND' })
354
+ render(loadedDocument)
355
+ typeIntoTitle('Missing edit')
356
+ await submit()
357
+ const notConfigured = latestToastDescription()
358
+ expect(notConfigured).toContain('not configured on the server')
359
+ expect(notConfigured).not.toBe(conflict)
360
+ })
361
+
362
+ it('keeps a failed save dirty with the navigation guard active', async () => {
363
+ mocks.update.mockRejectedValueOnce({ code: 'ERR_CONFLICT' })
364
+ render(loadedDocument)
365
+ typeIntoTitle('Unsaved change')
366
+ await submit()
367
+
368
+ expect(mocks.guardStates).toContain(true)
369
+ expect(mocks.guardStates.at(-1)).toBe(true)
370
+ expect(container.textContent).toContain('Cancel')
371
+ })
372
+
373
+ it('re-runs the loader and remounts before unlocking saved-document uploads', async () => {
374
+ const loader = vi
375
+ .fn<() => Promise<typeof loadedDocument | null>>()
376
+ .mockResolvedValueOnce(null)
377
+ .mockResolvedValueOnce(loadedDocument)
378
+
379
+ render(await loader())
380
+ expect(container.querySelector('[data-testid="upload-require-saved-document"]')).not.toBeNull()
381
+
382
+ act(() => root.unmount())
383
+ root = createRoot(container)
384
+ render(await loader())
385
+ expect(loader).toHaveBeenCalledTimes(2)
386
+ expect(container.querySelector('[data-testid="upload-require-saved-document"]')).toBeNull()
387
+ expect(container.querySelector('.byline-field-file-upload')).not.toBeNull()
388
+ })
389
+
390
+ it('closes to the admin dashboard rather than a collection list', () => {
391
+ render(null)
392
+ const close = [...container.querySelectorAll('button')].find(
393
+ (button) => button.textContent?.trim() === 'Close'
394
+ )
395
+ if (close == null) throw new Error('Close button not found')
396
+ act(() => close.click())
397
+
398
+ expect(mocks.navigate).toHaveBeenCalledWith({ to: '/internal/cms' })
399
+ })
400
+ })