@actuate-media/cms-admin 0.69.1 → 0.71.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/CHANGELOG.md +19 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/pages-tree.test.js +40 -1
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-template-seed.render.test.js +48 -0
- package/dist/__tests__/views/page-builder-template-seed.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +21 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -1
- package/dist/__tests__/views/script-tags.render.test.d.ts +2 -0
- package/dist/__tests__/views/script-tags.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/script-tags.render.test.js +148 -0
- package/dist/__tests__/views/script-tags.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +11 -9
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts +10 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.js +21 -2
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/pages-service.d.ts +7 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +11 -0
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +14 -0
- package/dist/lib/pages-tree.d.ts.map +1 -1
- package/dist/lib/pages-tree.js +51 -0
- package/dist/lib/pages-tree.js.map +1 -1
- package/dist/views/FormEditor.js +5 -7
- package/dist/views/FormEditor.js.map +1 -1
- package/dist/views/HiddenLoginNotFound.d.ts +6 -0
- package/dist/views/HiddenLoginNotFound.d.ts.map +1 -1
- package/dist/views/HiddenLoginNotFound.js +6 -0
- package/dist/views/HiddenLoginNotFound.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +2 -1
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/PageEditor.d.ts +4 -0
- package/dist/views/PageEditor.d.ts.map +1 -1
- package/dist/views/PageEditor.js +4 -0
- package/dist/views/PageEditor.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +40 -5
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +11 -1
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
- package/dist/views/Pages/PagesTreeTable.js +40 -9
- package/dist/views/Pages/PagesTreeTable.js.map +1 -1
- package/dist/views/Pages.d.ts +4 -0
- package/dist/views/Pages.d.ts.map +1 -1
- package/dist/views/Pages.js +4 -0
- package/dist/views/Pages.js.map +1 -1
- package/dist/views/Redirects.d.ts +4 -0
- package/dist/views/Redirects.d.ts.map +1 -1
- package/dist/views/Redirects.js +4 -0
- package/dist/views/Redirects.js.map +1 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +122 -15
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/page-builder/PageBuilder.d.ts +3 -1
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +24 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +30 -4
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/package.json +1 -1
- package/src/AdminRoot.tsx +11 -1
- package/src/__tests__/lib/pages-tree.test.ts +49 -0
- package/src/__tests__/views/page-builder-template-seed.render.test.tsx +59 -0
- package/src/__tests__/views/page-templates.render.test.tsx +40 -0
- package/src/__tests__/views/script-tags.render.test.tsx +178 -0
- package/src/components/TipTapEditor.tsx +11 -9
- package/src/components/script-tags/ScriptTagEditorPane.tsx +40 -4
- package/src/index.ts +3 -0
- package/src/lib/pages-service.ts +15 -0
- package/src/lib/pages-tree.ts +54 -0
- package/src/views/FormEditor.tsx +10 -10
- package/src/views/HiddenLoginNotFound.tsx +6 -0
- package/src/views/MediaBrowser.tsx +8 -9
- package/src/views/PageEditor.tsx +4 -0
- package/src/views/Pages/PagesListView.tsx +54 -9
- package/src/views/Pages/PagesTreeTable.tsx +203 -111
- package/src/views/Pages.tsx +4 -0
- package/src/views/Redirects.tsx +4 -0
- package/src/views/ScriptTags.tsx +268 -30
- package/src/views/page-builder/PageBuilder.tsx +30 -2
- package/src/views/page-builder/PageTemplates.tsx +56 -15
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
buildPageTree,
|
|
4
|
+
checkReparent,
|
|
5
|
+
collectDescendantIds,
|
|
4
6
|
defaultExpandedIds,
|
|
5
7
|
flattenPageTree,
|
|
6
8
|
type PageTreeNode,
|
|
@@ -195,3 +197,50 @@ describe('defaultExpandedIds', () => {
|
|
|
195
197
|
expect([...defaultExpandedIds(roots)]).toEqual(['about'])
|
|
196
198
|
})
|
|
197
199
|
})
|
|
200
|
+
|
|
201
|
+
describe('collectDescendantIds', () => {
|
|
202
|
+
it('returns every id in the subtree, excluding the page itself', () => {
|
|
203
|
+
const roots = buildPageTree([
|
|
204
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
205
|
+
page({ id: 'team', title: 'Our Team', path: '/about/team' }),
|
|
206
|
+
page({ id: 'deep', title: 'Deep', path: '/about/team/deep' }),
|
|
207
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
208
|
+
])
|
|
209
|
+
expect([...collectDescendantIds(roots, 'about')].sort()).toEqual(['deep', 'team'])
|
|
210
|
+
expect([...collectDescendantIds(roots, 'blog')]).toEqual([])
|
|
211
|
+
expect([...collectDescendantIds(roots, 'missing')]).toEqual([])
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('checkReparent', () => {
|
|
216
|
+
const roots = () =>
|
|
217
|
+
buildPageTree([
|
|
218
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
219
|
+
page({ id: 'team', title: 'Our Team', path: '/about/team' }),
|
|
220
|
+
page({ id: 'deep', title: 'Deep', path: '/about/team/deep' }),
|
|
221
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
222
|
+
])
|
|
223
|
+
|
|
224
|
+
it('allows moving a page under an unrelated page', () => {
|
|
225
|
+
expect(checkReparent(roots(), 'blog', 'team')).toEqual({ ok: true })
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('allows moving a subtree up to an ancestor that is not its parent', () => {
|
|
229
|
+
// deep currently sits under team; moving it directly under about is legal.
|
|
230
|
+
expect(checkReparent(roots(), 'deep', 'about')).toEqual({ ok: true })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('rejects self-drops', () => {
|
|
234
|
+
expect(checkReparent(roots(), 'about', 'about').ok).toBe(false)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('rejects drops into the page own subtree (cycle)', () => {
|
|
238
|
+
expect(checkReparent(roots(), 'about', 'team').ok).toBe(false)
|
|
239
|
+
expect(checkReparent(roots(), 'about', 'deep').ok).toBe(false)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('rejects no-op moves onto the current parent', () => {
|
|
243
|
+
expect(checkReparent(roots(), 'team', 'about').ok).toBe(false)
|
|
244
|
+
expect(checkReparent(roots(), 'deep', 'team').ok).toBe(false)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
|
|
3
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
const cmsApi = vi.fn()
|
|
6
|
+
vi.mock('../../lib/api.js', () => ({ cmsApi: (...args: unknown[]) => cmsApi(...args) }))
|
|
7
|
+
vi.mock('sonner', () => ({
|
|
8
|
+
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() },
|
|
9
|
+
Toaster: () => null,
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const { PageBuilder } = await import('../../views/page-builder/PageBuilder.js')
|
|
13
|
+
|
|
14
|
+
const TEMPLATE_TREE = {
|
|
15
|
+
id: 'tpl-page',
|
|
16
|
+
type: 'page',
|
|
17
|
+
children: [
|
|
18
|
+
{
|
|
19
|
+
id: 'sec1',
|
|
20
|
+
type: 'section',
|
|
21
|
+
settings: { label: 'Hero From Template' },
|
|
22
|
+
children: [],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
cmsApi.mockReset()
|
|
29
|
+
cmsApi.mockImplementation(async (endpoint: string) => {
|
|
30
|
+
if (endpoint === '/page-templates/tpl-1') {
|
|
31
|
+
return { data: { id: 'tpl-1', name: 'Landing', tree: TEMPLATE_TREE }, status: 200 }
|
|
32
|
+
}
|
|
33
|
+
return { data: {}, status: 200 }
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(cleanup)
|
|
38
|
+
|
|
39
|
+
describe('PageBuilder template seeding (/page-builder/new?template=)', () => {
|
|
40
|
+
it('fetches the template and seeds the canvas tree for a new page', async () => {
|
|
41
|
+
render(
|
|
42
|
+
<PageBuilder templateId="tpl-1" collectionSlug="pages" config={{}} onNavigate={() => {}} />,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
await waitFor(() => expect(cmsApi).toHaveBeenCalledWith('/page-templates/tpl-1'))
|
|
46
|
+
// replaceTree marks the builder dirty — the unsaved-changes dot is the
|
|
47
|
+
// observable proof the template tree was seeded into the canvas state.
|
|
48
|
+
expect(await screen.findByTitle('Unsaved changes')).toBeTruthy()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('starts from a blank, clean tree when no template id is given', async () => {
|
|
52
|
+
render(<PageBuilder collectionSlug="pages" config={{}} onNavigate={() => {}} />)
|
|
53
|
+
|
|
54
|
+
await waitFor(() =>
|
|
55
|
+
expect(cmsApi).not.toHaveBeenCalledWith(expect.stringContaining('/page-templates/')),
|
|
56
|
+
)
|
|
57
|
+
expect(screen.queryByTitle('Unsaved changes')).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -119,6 +119,46 @@ describe('PageTemplates card grid', () => {
|
|
|
119
119
|
})
|
|
120
120
|
})
|
|
121
121
|
|
|
122
|
+
it('routes "Use template" into the builder with the template id', async () => {
|
|
123
|
+
const onNavigate = vi.fn()
|
|
124
|
+
render(<PageTemplates onNavigate={onNavigate} />)
|
|
125
|
+
await screen.findByText('Landing Page')
|
|
126
|
+
|
|
127
|
+
fireEvent.click(screen.getByRole('button', { name: 'Create page from Landing Page' }))
|
|
128
|
+
|
|
129
|
+
expect(onNavigate).toHaveBeenCalledWith('/page-builder/new?template=builtin-landing')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('duplicates a template (built-ins included) via POST with a "(copy)" name', async () => {
|
|
133
|
+
render(<PageTemplates onNavigate={() => {}} />)
|
|
134
|
+
await screen.findByText('Landing Page')
|
|
135
|
+
|
|
136
|
+
fireEvent.click(screen.getByRole('button', { name: 'Duplicate Landing Page' }))
|
|
137
|
+
|
|
138
|
+
await waitFor(() =>
|
|
139
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
140
|
+
'/page-templates',
|
|
141
|
+
expect.objectContaining({ method: 'POST' }),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
const postCall = cmsApi.mock.calls.find(
|
|
145
|
+
(c) => (c[1] as RequestInit | undefined)?.method === 'POST',
|
|
146
|
+
)!
|
|
147
|
+
expect(JSON.parse((postCall[1] as RequestInit).body as string)).toMatchObject({
|
|
148
|
+
name: 'Landing Page (copy)',
|
|
149
|
+
category: 'landing',
|
|
150
|
+
tree: TEMPLATES[0]!.tree,
|
|
151
|
+
})
|
|
152
|
+
// The grid refetches so the copy appears.
|
|
153
|
+
await waitFor(() =>
|
|
154
|
+
expect(
|
|
155
|
+
cmsApi.mock.calls.filter(
|
|
156
|
+
(c) => c[0] === '/page-templates' && !(c[1] as RequestInit | undefined)?.method,
|
|
157
|
+
).length,
|
|
158
|
+
).toBeGreaterThanOrEqual(2),
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
|
|
122
162
|
it('filters cards by search text', async () => {
|
|
123
163
|
render(<PageTemplates onNavigate={() => {}} />)
|
|
124
164
|
await screen.findByText('Team Member')
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
// Drive the Script Tags list through a mocked cmsApi so the test owns the
|
|
6
|
+
// data and can assert the design-parity surface: stat pills, search,
|
|
7
|
+
// location/consent filter pills, badges, and row actions.
|
|
8
|
+
function makeTag(over: Record<string, unknown> = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: 't1',
|
|
11
|
+
name: 'Google Analytics 4',
|
|
12
|
+
code: '<script>/* ga */</script>',
|
|
13
|
+
placement: 'head',
|
|
14
|
+
scope: 'site',
|
|
15
|
+
targetPaths: [],
|
|
16
|
+
priority: 1,
|
|
17
|
+
enabled: true,
|
|
18
|
+
loadAsync: true,
|
|
19
|
+
disableInPreview: true,
|
|
20
|
+
consentCategory: 'analytics',
|
|
21
|
+
createdAt: '2026-05-20T10:00:00.000Z',
|
|
22
|
+
updatedAt: '2026-05-20T10:00:00.000Z',
|
|
23
|
+
...over,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TAGS = [
|
|
28
|
+
makeTag(),
|
|
29
|
+
makeTag({
|
|
30
|
+
id: 't2',
|
|
31
|
+
name: 'Live Chat Widget',
|
|
32
|
+
placement: 'body_close',
|
|
33
|
+
priority: 5,
|
|
34
|
+
consentCategory: 'functional',
|
|
35
|
+
}),
|
|
36
|
+
makeTag({
|
|
37
|
+
id: 't3',
|
|
38
|
+
name: 'Org Schema',
|
|
39
|
+
placement: 'head',
|
|
40
|
+
priority: 3,
|
|
41
|
+
enabled: false,
|
|
42
|
+
consentCategory: 'none',
|
|
43
|
+
}),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const cmsApi = vi.fn(async (endpoint: string, init?: RequestInit) => {
|
|
47
|
+
if (!init?.method) return { data: TAGS }
|
|
48
|
+
if (init.method === 'POST') return { data: makeTag({ id: 'new-copy' }) }
|
|
49
|
+
return { data: {} }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
vi.mock('../../lib/api.js', () => ({
|
|
53
|
+
cmsApi: (endpoint: string, init?: RequestInit) => cmsApi(endpoint, init),
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
57
|
+
|
|
58
|
+
const { ScriptTags } = await import('../../views/ScriptTags.js')
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
cmsApi.mockClear()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('ScriptTags list', () => {
|
|
65
|
+
it('renders rows, stat pills (incl. consent-gated), and colored badges', async () => {
|
|
66
|
+
render(<ScriptTags />)
|
|
67
|
+
|
|
68
|
+
expect(await screen.findByText('Google Analytics 4')).toBeTruthy()
|
|
69
|
+
expect(screen.getByText('Live Chat Widget')).toBeTruthy()
|
|
70
|
+
|
|
71
|
+
// Stat pills: 3 total, 2 active, 1 inactive, 2 consent-gated.
|
|
72
|
+
expect(screen.getByText('Consent-gated')).toBeTruthy()
|
|
73
|
+
expect(screen.getByText('Consent-gated').closest('span')?.textContent).toContain('2')
|
|
74
|
+
|
|
75
|
+
// Location badges use the design palette (head=accent, body_close=info).
|
|
76
|
+
// Note: "Body end" also matches the filter pill, so pick the badge chip.
|
|
77
|
+
const headBadge = screen.getAllByText('Head').find((el) => el.className.includes('--acc-l'))
|
|
78
|
+
expect(headBadge).toBeTruthy()
|
|
79
|
+
const bodyEndBadge = screen
|
|
80
|
+
.getAllByText('Body end')
|
|
81
|
+
.find((el) => el.className.includes('--info-l'))
|
|
82
|
+
expect(bodyEndBadge).toBeTruthy()
|
|
83
|
+
|
|
84
|
+
// Consent badges are per-category colored chips.
|
|
85
|
+
const analyticsBadge = screen
|
|
86
|
+
.getAllByText('Analytics')
|
|
87
|
+
.find((el) => el.className.includes('--acc-l'))
|
|
88
|
+
expect(analyticsBadge).toBeTruthy()
|
|
89
|
+
const functionalBadge = screen
|
|
90
|
+
.getAllByText('Functional')
|
|
91
|
+
.find((el) => el.className.includes('--suc-l'))
|
|
92
|
+
expect(functionalBadge).toBeTruthy()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('filters by location pill and shows the filtered count', async () => {
|
|
96
|
+
render(<ScriptTags />)
|
|
97
|
+
await screen.findByText('Google Analytics 4')
|
|
98
|
+
|
|
99
|
+
expect(screen.getByText('3 scripts')).toBeTruthy()
|
|
100
|
+
fireEvent.click(screen.getByRole('button', { name: 'Body end', pressed: false }))
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(screen.getByText('Live Chat Widget')).toBeTruthy()
|
|
104
|
+
expect(screen.queryByText('Google Analytics 4')).toBeNull()
|
|
105
|
+
expect(screen.getByText('1 script')).toBeTruthy()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('filters by consent pill', async () => {
|
|
110
|
+
render(<ScriptTags />)
|
|
111
|
+
await screen.findByText('Google Analytics 4')
|
|
112
|
+
|
|
113
|
+
fireEvent.click(screen.getByRole('button', { name: 'No consent', pressed: false }))
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(screen.getByText('Org Schema')).toBeTruthy()
|
|
117
|
+
expect(screen.queryByText('Google Analytics 4')).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('searches by name and offers clear-filters from the empty state', async () => {
|
|
122
|
+
render(<ScriptTags />)
|
|
123
|
+
await screen.findByText('Google Analytics 4')
|
|
124
|
+
|
|
125
|
+
fireEvent.change(screen.getByPlaceholderText('Search scripts…'), {
|
|
126
|
+
target: { value: 'zzz-no-match' },
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(await screen.findByText('No scripts match your filters')).toBeTruthy()
|
|
130
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
|
|
131
|
+
expect(await screen.findByText('Google Analytics 4')).toBeTruthy()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('select-all checks visible rows and shows selected count with clear', async () => {
|
|
135
|
+
render(<ScriptTags />)
|
|
136
|
+
await screen.findByText('Google Analytics 4')
|
|
137
|
+
|
|
138
|
+
fireEvent.click(screen.getByLabelText('Select all scripts'))
|
|
139
|
+
expect(await screen.findByText(/3 selected/)).toBeTruthy()
|
|
140
|
+
|
|
141
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
|
142
|
+
await waitFor(() => expect(screen.queryByText(/selected/)).toBeNull())
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('row duplicate posts a disabled copy and refetches', async () => {
|
|
146
|
+
render(<ScriptTags />)
|
|
147
|
+
await screen.findByText('Google Analytics 4')
|
|
148
|
+
|
|
149
|
+
fireEvent.click(screen.getByRole('button', { name: 'Duplicate Google Analytics 4' }))
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
const post = cmsApi.mock.calls.find(
|
|
153
|
+
(c) => (c[1] as RequestInit | undefined)?.method === 'POST',
|
|
154
|
+
)
|
|
155
|
+
expect(post).toBeTruthy()
|
|
156
|
+
const body = JSON.parse(String((post![1] as RequestInit).body))
|
|
157
|
+
expect(body.name).toBe('Google Analytics 4 (Copy)')
|
|
158
|
+
expect(body.enabled).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('row delete asks for confirmation, then issues DELETE', async () => {
|
|
163
|
+
render(<ScriptTags />)
|
|
164
|
+
await screen.findByText('Org Schema')
|
|
165
|
+
|
|
166
|
+
fireEvent.click(screen.getByRole('button', { name: 'Delete Org Schema' }))
|
|
167
|
+
expect(await screen.findByText('Delete script?')).toBeTruthy()
|
|
168
|
+
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
169
|
+
|
|
170
|
+
await waitFor(() => {
|
|
171
|
+
const del = cmsApi.mock.calls.find(
|
|
172
|
+
(c) => (c[1] as RequestInit | undefined)?.method === 'DELETE',
|
|
173
|
+
)
|
|
174
|
+
expect(del).toBeTruthy()
|
|
175
|
+
expect(del![0]).toBe('/script-tags/t3')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -155,13 +155,13 @@ const editorStyles = `
|
|
|
155
155
|
}
|
|
156
156
|
.ProseMirror th,
|
|
157
157
|
.ProseMirror td {
|
|
158
|
-
border: 1px solid
|
|
158
|
+
border: 1px solid var(--bdr);
|
|
159
159
|
padding: 0.5rem;
|
|
160
160
|
text-align: left;
|
|
161
161
|
min-width: 80px;
|
|
162
162
|
}
|
|
163
163
|
.ProseMirror th {
|
|
164
|
-
background-color:
|
|
164
|
+
background-color: var(--surface-muted);
|
|
165
165
|
font-weight: 600;
|
|
166
166
|
}
|
|
167
167
|
.ProseMirror img {
|
|
@@ -170,11 +170,13 @@ const editorStyles = `
|
|
|
170
170
|
border-radius: 0.375rem;
|
|
171
171
|
}
|
|
172
172
|
.ProseMirror blockquote {
|
|
173
|
-
border-left: 3px solid
|
|
173
|
+
border-left: 3px solid var(--acc);
|
|
174
174
|
padding-left: 1rem;
|
|
175
175
|
margin: 1rem 0;
|
|
176
|
-
color:
|
|
176
|
+
color: var(--sub);
|
|
177
177
|
}
|
|
178
|
+
/* Code blocks use a fixed dark slate theme in both light and dark modes
|
|
179
|
+
(standard "dark editor chrome" convention; no theme token maps to it). */
|
|
178
180
|
.ProseMirror pre {
|
|
179
181
|
background: #1e293b;
|
|
180
182
|
color: #e2e8f0;
|
|
@@ -184,7 +186,7 @@ const editorStyles = `
|
|
|
184
186
|
margin: 0.75rem 0;
|
|
185
187
|
}
|
|
186
188
|
.ProseMirror code {
|
|
187
|
-
background:
|
|
189
|
+
background: var(--surface-muted);
|
|
188
190
|
padding: 0.125rem 0.375rem;
|
|
189
191
|
border-radius: 0.25rem;
|
|
190
192
|
font-size: 0.875em;
|
|
@@ -197,18 +199,18 @@ const editorStyles = `
|
|
|
197
199
|
}
|
|
198
200
|
.ProseMirror hr {
|
|
199
201
|
border: none;
|
|
200
|
-
border-top: 2px solid
|
|
202
|
+
border-top: 2px solid var(--bdr);
|
|
201
203
|
margin: 1.5rem 0;
|
|
202
204
|
}
|
|
203
205
|
.ProseMirror p.is-editor-empty:first-child::before {
|
|
204
206
|
content: attr(data-placeholder);
|
|
205
207
|
float: left;
|
|
206
|
-
color:
|
|
208
|
+
color: var(--muted);
|
|
207
209
|
pointer-events: none;
|
|
208
210
|
height: 0;
|
|
209
211
|
}
|
|
210
212
|
.ProseMirror .selectedCell {
|
|
211
|
-
background-color:
|
|
213
|
+
background-color: var(--acc-l);
|
|
212
214
|
}
|
|
213
215
|
`
|
|
214
216
|
|
|
@@ -659,7 +661,7 @@ export function TipTapEditor({
|
|
|
659
661
|
className={`border-border bg-card flex-1 overflow-hidden rounded-lg border ${className ?? ''}`}
|
|
660
662
|
>
|
|
661
663
|
{collaboration && awareness && (
|
|
662
|
-
<div className="bg-card flex items-center justify-between gap-2 border-b
|
|
664
|
+
<div className="bg-card border-border flex items-center justify-between gap-2 border-b px-2 py-1.5">
|
|
663
665
|
<PresenceChips awareness={awareness} status={collabStatus} />
|
|
664
666
|
<OfflineStatus connection={collabStatus} offline={offlineStatus} />
|
|
665
667
|
</div>
|
|
@@ -43,6 +43,33 @@ export const CONSENT_LABELS: Record<string, string> = {
|
|
|
43
43
|
preferences: 'Preferences',
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Design-system location badges (`.loc-head` / `.loc-bs` / `.loc-be`). */
|
|
47
|
+
export const PLACEMENT_COLORS: Record<string, string> = {
|
|
48
|
+
head: 'bg-[var(--acc-l)] text-[var(--acc)]',
|
|
49
|
+
body_open: 'bg-[var(--suc-l)] text-[var(--suc)]',
|
|
50
|
+
body_close: 'bg-[var(--info-l)] text-[var(--info)]',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConsentBadgeColors {
|
|
54
|
+
badge: string
|
|
55
|
+
dot: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Neutral "No consent" chip — also the fallback for unknown categories. */
|
|
59
|
+
export const NO_CONSENT_COLORS: ConsentBadgeColors = {
|
|
60
|
+
badge: 'border border-[var(--bdr)] bg-[var(--bg)] text-[var(--muted)]',
|
|
61
|
+
dot: 'bg-[var(--muted)]',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Design-system consent badges (`.cb-*`): tinted chip + colored dot per category. */
|
|
65
|
+
export const CONSENT_COLORS: Record<string, ConsentBadgeColors> = {
|
|
66
|
+
none: NO_CONSENT_COLORS,
|
|
67
|
+
analytics: { badge: 'bg-[var(--acc-l)] text-[var(--acc)]', dot: 'bg-[var(--acc)]' },
|
|
68
|
+
marketing: { badge: 'bg-[var(--err-l)] text-[var(--err)]', dot: 'bg-[var(--err)]' },
|
|
69
|
+
functional: { badge: 'bg-[var(--suc-l)] text-[var(--suc)]', dot: 'bg-[var(--suc)]' },
|
|
70
|
+
preferences: { badge: 'bg-[var(--warn-l)] text-[var(--warn)]', dot: 'bg-[var(--warn)]' },
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
const PLACEMENT_OPTIONS = [
|
|
47
74
|
{ value: 'head', label: 'Head', hint: '<head>' },
|
|
48
75
|
{ value: 'body_open', label: 'Body start', hint: '<body>' },
|
|
@@ -248,10 +275,18 @@ export function ScriptTagEditorPane({ tag, onClose, onChanged }: ScriptTagEditor
|
|
|
248
275
|
/>
|
|
249
276
|
</div>
|
|
250
277
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 px-1.5">
|
|
251
|
-
<span
|
|
278
|
+
<span
|
|
279
|
+
className={`rounded-[5px] px-1.5 py-0.5 text-[10.5px] font-medium ${PLACEMENT_COLORS[placement] ?? 'border border-[var(--bdr)] bg-[var(--bg)] text-[var(--sub)]'}`}
|
|
280
|
+
>
|
|
252
281
|
{PLACEMENT_LABELS[placement] ?? placement}
|
|
253
282
|
</span>
|
|
254
|
-
<span
|
|
283
|
+
<span
|
|
284
|
+
className={`inline-flex items-center gap-[3px] rounded-[5px] px-1.5 py-0.5 text-[10.5px] font-medium ${(CONSENT_COLORS[consentCategory] ?? NO_CONSENT_COLORS).badge}`}
|
|
285
|
+
>
|
|
286
|
+
<span
|
|
287
|
+
className={`inline-block h-[5px] w-[5px] rounded-full ${(CONSENT_COLORS[consentCategory] ?? NO_CONSENT_COLORS).dot}`}
|
|
288
|
+
aria-hidden
|
|
289
|
+
/>
|
|
255
290
|
{CONSENT_LABELS[consentCategory] ?? consentCategory}
|
|
256
291
|
</span>
|
|
257
292
|
</div>
|
|
@@ -407,8 +442,9 @@ export function ScriptTagEditorPane({ tag, onClose, onChanged }: ScriptTagEditor
|
|
|
407
442
|
</div>
|
|
408
443
|
{consentCategory !== 'none' && (
|
|
409
444
|
<p className="mt-2 text-[11px] text-[var(--muted)]">
|
|
410
|
-
The script is injected only after the visitor
|
|
411
|
-
|
|
445
|
+
The script is injected only after the visitor grants the{' '}
|
|
446
|
+
{CONSENT_LABELS[consentCategory] ?? consentCategory} consent category (Actuate Consent
|
|
447
|
+
banner).
|
|
412
448
|
</p>
|
|
413
449
|
)}
|
|
414
450
|
</InspectorPaneSection>
|
package/src/index.ts
CHANGED
|
@@ -50,13 +50,16 @@ export type {
|
|
|
50
50
|
} from './layout/primitives/index.js'
|
|
51
51
|
|
|
52
52
|
export { Dashboard } from './views/Dashboard.js'
|
|
53
|
+
/** @deprecated Superseded by `PagesListView` routing in `AdminRoot`; will be removed in the next major. */
|
|
53
54
|
export { Pages } from './views/Pages.js'
|
|
55
|
+
/** @deprecated Superseded by the visual page builder (`PageBuilder`); will be removed in the next major. */
|
|
54
56
|
export { PageEditor } from './views/PageEditor.js'
|
|
55
57
|
export { MediaBrowser } from './views/MediaBrowser.js'
|
|
56
58
|
export { Forms } from './views/Forms.js'
|
|
57
59
|
export { FormEditor } from './views/FormEditor.js'
|
|
58
60
|
export type { FormEditorProps } from './views/FormEditor.js'
|
|
59
61
|
export { FormSubmissions } from './views/FormSubmissions.js'
|
|
62
|
+
/** @deprecated Superseded by the Redirects tab inside the `SEO` view; will be removed in the next major. */
|
|
60
63
|
export { Redirects } from './views/Redirects.js'
|
|
61
64
|
export { Users } from './views/Users.js'
|
|
62
65
|
export { Settings } from './views/Settings.js'
|
package/src/lib/pages-service.ts
CHANGED
|
@@ -520,6 +520,21 @@ export async function duplicatePage(id: string): Promise<{ id?: string; error?:
|
|
|
520
520
|
return { id: res.data?.id }
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Reparent a page in the tree. `parentPageId: null` makes it top-level.
|
|
525
|
+
* Document updates merge `data`, so only the parent link changes.
|
|
526
|
+
*/
|
|
527
|
+
export async function movePage(
|
|
528
|
+
id: string,
|
|
529
|
+
parentPageId: string | null,
|
|
530
|
+
): Promise<{ error?: string }> {
|
|
531
|
+
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
|
532
|
+
method: 'PUT',
|
|
533
|
+
body: JSON.stringify({ data: { parentPageId } }),
|
|
534
|
+
})
|
|
535
|
+
return res.error ? { error: res.error } : {}
|
|
536
|
+
}
|
|
537
|
+
|
|
523
538
|
/** Soft-delete a single page. */
|
|
524
539
|
export async function deletePage(id: string): Promise<{ error?: string }> {
|
|
525
540
|
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
package/src/lib/pages-tree.ts
CHANGED
|
@@ -143,3 +143,57 @@ export function flattenPageTree(nodes: PageTreeNode[], expanded: Set<string>): P
|
|
|
143
143
|
export function defaultExpandedIds(roots: PageTreeNode[]): Set<string> {
|
|
144
144
|
return new Set(roots.filter((n) => n.children.length > 0).map((n) => n.page.id))
|
|
145
145
|
}
|
|
146
|
+
|
|
147
|
+
/** Ids of every page in `id`'s subtree (excluding `id` itself). */
|
|
148
|
+
export function collectDescendantIds(roots: PageTreeNode[], id: string): Set<string> {
|
|
149
|
+
const out = new Set<string>()
|
|
150
|
+
const collect = (node: PageTreeNode) => {
|
|
151
|
+
for (const child of node.children) {
|
|
152
|
+
out.add(child.page.id)
|
|
153
|
+
collect(child)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const find = (nodes: PageTreeNode[]): PageTreeNode | undefined => {
|
|
157
|
+
for (const n of nodes) {
|
|
158
|
+
if (n.page.id === id) return n
|
|
159
|
+
const hit = find(n.children)
|
|
160
|
+
if (hit) return hit
|
|
161
|
+
}
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
const node = find(roots)
|
|
165
|
+
if (node) collect(node)
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type ReparentCheck = { ok: true } | { ok: false; reason: string }
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Validate a drag-reparent: `sourceId` becomes a child of `targetId`
|
|
173
|
+
* (or a root when `targetId` is null). Rejects self-drops, drops into the
|
|
174
|
+
* page's own subtree (would create a cycle), and no-op moves.
|
|
175
|
+
*/
|
|
176
|
+
export function checkReparent(
|
|
177
|
+
roots: PageTreeNode[],
|
|
178
|
+
sourceId: string,
|
|
179
|
+
targetId: string | null,
|
|
180
|
+
): ReparentCheck {
|
|
181
|
+
if (targetId === sourceId) {
|
|
182
|
+
return { ok: false, reason: 'A page cannot be its own parent' }
|
|
183
|
+
}
|
|
184
|
+
if (targetId !== null && collectDescendantIds(roots, sourceId).has(targetId)) {
|
|
185
|
+
return { ok: false, reason: 'Cannot move a page into its own sub-pages' }
|
|
186
|
+
}
|
|
187
|
+
const parentOf = (nodes: PageTreeNode[], parent: string | null): string | null | undefined => {
|
|
188
|
+
for (const n of nodes) {
|
|
189
|
+
if (n.page.id === sourceId) return parent
|
|
190
|
+
const hit = parentOf(n.children, n.page.id)
|
|
191
|
+
if (hit !== undefined) return hit
|
|
192
|
+
}
|
|
193
|
+
return undefined
|
|
194
|
+
}
|
|
195
|
+
if ((parentOf(roots, null) ?? null) === targetId) {
|
|
196
|
+
return { ok: false, reason: 'Page is already there' }
|
|
197
|
+
}
|
|
198
|
+
return { ok: true }
|
|
199
|
+
}
|
package/src/views/FormEditor.tsx
CHANGED
|
@@ -351,11 +351,11 @@ export function FormEditor({ formId, onNavigate }: FormEditorProps) {
|
|
|
351
351
|
</label>
|
|
352
352
|
<div className="flex gap-3">
|
|
353
353
|
<label
|
|
354
|
-
className=
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}}
|
|
354
|
+
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors ${
|
|
355
|
+
confirmation.type === 'message'
|
|
356
|
+
? 'border-primary bg-accent'
|
|
357
|
+
: 'border-border hover:bg-muted bg-transparent'
|
|
358
|
+
}`}
|
|
359
359
|
>
|
|
360
360
|
<input
|
|
361
361
|
type="radio"
|
|
@@ -369,11 +369,11 @@ export function FormEditor({ formId, onNavigate }: FormEditorProps) {
|
|
|
369
369
|
<span className="text-sm">Show Message</span>
|
|
370
370
|
</label>
|
|
371
371
|
<label
|
|
372
|
-
className=
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}}
|
|
372
|
+
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors ${
|
|
373
|
+
confirmation.type === 'redirect'
|
|
374
|
+
? 'border-primary bg-accent'
|
|
375
|
+
: 'border-border hover:bg-muted bg-transparent'
|
|
376
|
+
}`}
|
|
377
377
|
>
|
|
378
378
|
<input
|
|
379
379
|
type="radio"
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* (`config.admin.loginPath` set). Mimics the framework's default 404 page so
|
|
6
6
|
* hitting `/admin` reveals nothing — no login form, no admin branding, and no
|
|
7
7
|
* redirect that would leak the real login location.
|
|
8
|
+
*
|
|
9
|
+
* INTENTIONAL raw colors: although this mounts inside `.actuate-admin` (so the
|
|
10
|
+
* theme CSS vars are technically available), it must NOT use them. Painting
|
|
11
|
+
* this screen with the admin palette (or its dark-mode variant) would make it
|
|
12
|
+
* visibly differ from the framework's stock 404 and fingerprint the hidden
|
|
13
|
+
* admin to anyone probing `/admin`. Plain #fff/#000 matches the default 404.
|
|
8
14
|
*/
|
|
9
15
|
export function HiddenLoginNotFound() {
|
|
10
16
|
return (
|
|
@@ -31,6 +31,7 @@ import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js'
|
|
|
31
31
|
import { useApiData } from '../lib/useApiData.js'
|
|
32
32
|
import { cmsApi } from '../lib/api.js'
|
|
33
33
|
import { FolderTree, type FolderSelection } from '../components/FolderTree.js'
|
|
34
|
+
import { Button } from '../components/ui/Button.js'
|
|
34
35
|
import { StatusBadge } from '../components/ui/StatusBadge.js'
|
|
35
36
|
import { StatPill } from '../components/ui/StatPill.js'
|
|
36
37
|
import { MediaSeoAudit, type MediaSeoAuditItem } from './MediaSeoAudit.js'
|
|
@@ -508,18 +509,16 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
|
|
|
508
509
|
className="hidden"
|
|
509
510
|
onChange={(e) => handleUploadFiles(e.target.files)}
|
|
510
511
|
/>
|
|
511
|
-
<
|
|
512
|
+
<Button
|
|
513
|
+
type="button"
|
|
514
|
+
variant="primary"
|
|
515
|
+
size="md"
|
|
512
516
|
onClick={() => fileInputRef.current?.click()}
|
|
513
|
-
|
|
514
|
-
|
|
517
|
+
loading={uploading}
|
|
518
|
+
leftIcon={<Upload size={16} aria-hidden />}
|
|
515
519
|
>
|
|
516
|
-
{uploading ? (
|
|
517
|
-
<Loader2 className="h-4 w-4 animate-spin" />
|
|
518
|
-
) : (
|
|
519
|
-
<Upload className="h-4 w-4" />
|
|
520
|
-
)}
|
|
521
520
|
{uploading ? 'Uploading...' : 'Upload Files'}
|
|
522
|
-
</
|
|
521
|
+
</Button>
|
|
523
522
|
</div>
|
|
524
523
|
</div>
|
|
525
524
|
|