@actuate-media/cms-admin 0.46.0 → 0.48.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 +17 -0
- package/dist/__tests__/views/collection-list-facets.render.test.d.ts +2 -0
- package/dist/__tests__/views/collection-list-facets.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/collection-list-facets.render.test.js +131 -0
- package/dist/__tests__/views/collection-list-facets.render.test.js.map +1 -0
- package/dist/__tests__/views/use-site-preview.render.test.d.ts +2 -0
- package/dist/__tests__/views/use-site-preview.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/use-site-preview.render.test.js +86 -0
- package/dist/__tests__/views/use-site-preview.render.test.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +121 -11
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +8 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +7 -3
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +14 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SitePreviewFrame.d.ts +18 -0
- package/dist/views/page-editor/SitePreviewFrame.d.ts.map +1 -0
- package/dist/views/page-editor/SitePreviewFrame.js +45 -0
- package/dist/views/page-editor/SitePreviewFrame.js.map +1 -0
- package/dist/views/page-editor/useSitePreview.d.ts +33 -0
- package/dist/views/page-editor/useSitePreview.d.ts.map +1 -0
- package/dist/views/page-editor/useSitePreview.js +72 -0
- package/dist/views/page-editor/useSitePreview.js.map +1 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +14 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/views/collection-list-facets.render.test.tsx +148 -0
- package/src/__tests__/views/use-site-preview.render.test.tsx +122 -0
- package/src/views/CollectionList.tsx +187 -23
- package/src/views/page-editor/EditorTopBar.tsx +31 -0
- package/src/views/page-editor/PageSectionEditor.tsx +33 -8
- package/src/views/page-editor/SitePreviewFrame.tsx +85 -0
- package/src/views/page-editor/useSitePreview.ts +117 -0
- package/src/views/post-editor/PostSectionEditor.tsx +35 -10
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CollectionList facet filters + taxonomy columns (taxonomy Phase B).
|
|
7
|
+
* Pins: facet dropdowns appear for select fields (config options), free-text
|
|
8
|
+
* `category`, and tag-shaped arrays (in-use values via field-values API);
|
|
9
|
+
* picking one adds `filter.<field>=` to the list endpoint; and taxonomy
|
|
10
|
+
* columns surface category/tags values in the table.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let fieldValues: Record<string, Array<{ value: string; count: number }>> = {}
|
|
14
|
+
|
|
15
|
+
const cmsApi = vi.fn(async (endpoint: string, _options?: unknown) => {
|
|
16
|
+
const match = endpoint.match(/field-values\?field=([^&]+)/)
|
|
17
|
+
if (match) {
|
|
18
|
+
const field = decodeURIComponent(match[1] ?? '')
|
|
19
|
+
return { data: { field, values: fieldValues[field] ?? [] }, status: 200 }
|
|
20
|
+
}
|
|
21
|
+
return { data: {}, status: 200 }
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
vi.mock('../../lib/api.js', () => ({
|
|
25
|
+
cmsApi: (e: string, o?: unknown) => cmsApi(e, o as never),
|
|
26
|
+
ensureCsrfToken: vi.fn(async () => {}),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
let lastEndpoint = ''
|
|
30
|
+
let listDocs: any[] = []
|
|
31
|
+
|
|
32
|
+
vi.mock('../../lib/useApiData.js', () => ({
|
|
33
|
+
useApiData: (endpoint: string) => {
|
|
34
|
+
lastEndpoint = endpoint
|
|
35
|
+
return {
|
|
36
|
+
data: { docs: listDocs, total: listDocs.length },
|
|
37
|
+
loading: false,
|
|
38
|
+
error: null,
|
|
39
|
+
refetch: vi.fn(),
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
const { CollectionList } = await import('../../views/CollectionList.js')
|
|
45
|
+
|
|
46
|
+
const CONFIG = {
|
|
47
|
+
collections: {
|
|
48
|
+
posts: {
|
|
49
|
+
slug: 'posts',
|
|
50
|
+
labels: { singular: 'Post', plural: 'Posts' },
|
|
51
|
+
fields: {
|
|
52
|
+
title: { type: 'text', label: 'Title' },
|
|
53
|
+
topic: {
|
|
54
|
+
type: 'select',
|
|
55
|
+
label: 'Topic',
|
|
56
|
+
options: [
|
|
57
|
+
{ label: 'News', value: 'news' },
|
|
58
|
+
{ label: 'Tutorials', value: 'tutorials' },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
category: { type: 'text', label: 'Category', maxLength: 80 },
|
|
62
|
+
body: { type: 'richText', label: 'Body' },
|
|
63
|
+
tags: {
|
|
64
|
+
type: 'array',
|
|
65
|
+
label: 'Tags',
|
|
66
|
+
fields: { tag: { type: 'text', label: 'Tag', required: true } },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderList() {
|
|
74
|
+
return render(<CollectionList collectionSlug="posts" config={CONFIG} onNavigate={vi.fn()} />)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
cmsApi.mockClear()
|
|
79
|
+
fieldValues = {}
|
|
80
|
+
listDocs = []
|
|
81
|
+
lastEndpoint = ''
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('CollectionList facets', () => {
|
|
85
|
+
it('renders facet dropdowns for select, category, and tag-shaped fields', async () => {
|
|
86
|
+
fieldValues = {
|
|
87
|
+
category: [{ value: 'Releases', count: 4 }],
|
|
88
|
+
tags: [{ value: 'AI', count: 3 }],
|
|
89
|
+
}
|
|
90
|
+
renderList()
|
|
91
|
+
|
|
92
|
+
expect(screen.getByLabelText('Filter by Topic')).toBeTruthy()
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(screen.getByLabelText('Filter by Category')).toBeTruthy()
|
|
95
|
+
expect(screen.getByLabelText('Filter by Tags')).toBeTruthy()
|
|
96
|
+
})
|
|
97
|
+
// Select facets list config option labels, not in-use values.
|
|
98
|
+
expect(screen.getByRole('option', { name: 'Tutorials' })).toBeTruthy()
|
|
99
|
+
expect(screen.getByRole('option', { name: 'Releases' })).toBeTruthy()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('adds filter.<field> to the list endpoint when a facet is picked', async () => {
|
|
103
|
+
fieldValues = { tags: [{ value: 'AI', count: 3 }] }
|
|
104
|
+
renderList()
|
|
105
|
+
|
|
106
|
+
fireEvent.change(screen.getByLabelText('Filter by Topic'), { target: { value: 'news' } })
|
|
107
|
+
await waitFor(() => expect(lastEndpoint).toContain('filter.topic=news'))
|
|
108
|
+
|
|
109
|
+
await waitFor(() => expect(screen.getByLabelText('Filter by Tags')).toBeTruthy())
|
|
110
|
+
fireEvent.change(screen.getByLabelText('Filter by Tags'), { target: { value: 'AI' } })
|
|
111
|
+
await waitFor(() => {
|
|
112
|
+
expect(lastEndpoint).toContain('filter.topic=news')
|
|
113
|
+
expect(lastEndpoint).toContain('filter.tags=AI')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('clears all facets via the clear button', async () => {
|
|
118
|
+
renderList()
|
|
119
|
+
fireEvent.change(screen.getByLabelText('Filter by Topic'), { target: { value: 'news' } })
|
|
120
|
+
await waitFor(() => expect(lastEndpoint).toContain('filter.topic=news'))
|
|
121
|
+
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
|
|
123
|
+
await waitFor(() => expect(lastEndpoint).not.toContain('filter.'))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('shows taxonomy columns with select labels and tag lists', async () => {
|
|
127
|
+
listDocs = [
|
|
128
|
+
{
|
|
129
|
+
id: '1',
|
|
130
|
+
title: 'Hello',
|
|
131
|
+
status: 'PUBLISHED',
|
|
132
|
+
updatedAt: '2026-06-01T00:00:00Z',
|
|
133
|
+
data: { topic: 'news', category: 'Releases', tags: [{ tag: 'AI' }, 'CMS'] },
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
renderList()
|
|
137
|
+
|
|
138
|
+
// Column headers for each facet field.
|
|
139
|
+
expect(screen.getByRole('columnheader', { name: 'Topic' })).toBeTruthy()
|
|
140
|
+
expect(screen.getByRole('columnheader', { name: 'Category' })).toBeTruthy()
|
|
141
|
+
expect(screen.getByRole('columnheader', { name: 'Tags' })).toBeTruthy()
|
|
142
|
+
// Select value maps to its config label; tags render row + string shapes.
|
|
143
|
+
const table = within(screen.getByRole('table'))
|
|
144
|
+
expect(table.getByText('News')).toBeTruthy()
|
|
145
|
+
expect(table.getByText('Releases')).toBeTruthy()
|
|
146
|
+
expect(table.getByText('AI, CMS')).toBeTruthy()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* useSitePreview — drives the inline branded preview (iframe of the public
|
|
7
|
+
* site rendering a draft). Pins: token minted + URL built on activation,
|
|
8
|
+
* unsaved drafts are persisted first (a failed save cancels activation), and
|
|
9
|
+
* the frame reloads (reloadKey bump) after each completed save.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const createPreviewToken = vi.fn(async () => ({
|
|
13
|
+
token: 'tok-123',
|
|
14
|
+
expiresAt: '2026-06-19T00:00:00Z',
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
vi.mock('../../lib/preview-link.js', () => ({
|
|
18
|
+
createPreviewToken: (...args: unknown[]) => createPreviewToken(...(args as [])),
|
|
19
|
+
buildPublicPreviewUrl: ({ siteUrl, urlPrefix, slug, token }: any) =>
|
|
20
|
+
`${siteUrl}${urlPrefix ? `/${urlPrefix}` : ''}/${slug}?preview=${token}`,
|
|
21
|
+
resolveSiteUrl: (u?: string | null) => u ?? 'https://fallback.test',
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
vi.mock('sonner', () => ({
|
|
25
|
+
toast: { error: vi.fn(), success: vi.fn() },
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
const { useSitePreview } = await import('../../views/page-editor/useSitePreview.js')
|
|
29
|
+
const { toast } = await import('sonner')
|
|
30
|
+
|
|
31
|
+
import type { SaveState } from '../../views/page-editor/EditorTopBar.js'
|
|
32
|
+
|
|
33
|
+
function Harness({
|
|
34
|
+
documentId = 'doc-1',
|
|
35
|
+
slug = 'about',
|
|
36
|
+
dirty = false,
|
|
37
|
+
saveState = 'idle' as SaveState,
|
|
38
|
+
saveDraft = vi.fn(async () => true),
|
|
39
|
+
}: {
|
|
40
|
+
documentId?: string | null
|
|
41
|
+
slug?: string | null
|
|
42
|
+
dirty?: boolean
|
|
43
|
+
saveState?: SaveState
|
|
44
|
+
saveDraft?: () => Promise<boolean>
|
|
45
|
+
}) {
|
|
46
|
+
const sp = useSitePreview({
|
|
47
|
+
collection: 'pages',
|
|
48
|
+
documentId,
|
|
49
|
+
slug,
|
|
50
|
+
urlPrefix: '',
|
|
51
|
+
siteUrl: 'https://site.test',
|
|
52
|
+
dirty,
|
|
53
|
+
saveState,
|
|
54
|
+
saveDraft,
|
|
55
|
+
})
|
|
56
|
+
return (
|
|
57
|
+
<div>
|
|
58
|
+
<button type="button" onClick={sp.toggle}>
|
|
59
|
+
toggle
|
|
60
|
+
</button>
|
|
61
|
+
<output data-testid="state">
|
|
62
|
+
{JSON.stringify({ active: sp.active, url: sp.url, reloadKey: sp.reloadKey })}
|
|
63
|
+
</output>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function state(): { active: boolean; url: string | null; reloadKey: number } {
|
|
69
|
+
return JSON.parse(screen.getByTestId('state').textContent ?? '{}')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
createPreviewToken.mockClear()
|
|
74
|
+
vi.mocked(toast.error).mockClear()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('useSitePreview', () => {
|
|
78
|
+
it('mints a token and builds the public draft URL on activation', async () => {
|
|
79
|
+
render(<Harness />)
|
|
80
|
+
fireEvent.click(screen.getByText('toggle'))
|
|
81
|
+
expect(state().active).toBe(true)
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(state().url).toBe('https://site.test/about?preview=tok-123')
|
|
84
|
+
})
|
|
85
|
+
expect(createPreviewToken).toHaveBeenCalledWith('pages', 'doc-1')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('saves a dirty draft before minting; a failed save cancels activation', async () => {
|
|
89
|
+
const saveDraft = vi.fn(async () => false)
|
|
90
|
+
render(<Harness dirty saveDraft={saveDraft} />)
|
|
91
|
+
fireEvent.click(screen.getByText('toggle'))
|
|
92
|
+
await waitFor(() => expect(saveDraft).toHaveBeenCalled())
|
|
93
|
+
await waitFor(() => expect(state().active).toBe(false))
|
|
94
|
+
expect(createPreviewToken).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('refuses to activate for unsaved documents', () => {
|
|
98
|
+
render(<Harness documentId={null} />)
|
|
99
|
+
fireEvent.click(screen.getByText('toggle'))
|
|
100
|
+
expect(state().active).toBe(false)
|
|
101
|
+
expect(toast.error).toHaveBeenCalled()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('toggles back to the canvas on second click', async () => {
|
|
105
|
+
render(<Harness />)
|
|
106
|
+
fireEvent.click(screen.getByText('toggle'))
|
|
107
|
+
await waitFor(() => expect(state().url).not.toBeNull())
|
|
108
|
+
fireEvent.click(screen.getByText('toggle'))
|
|
109
|
+
expect(state().active).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('bumps reloadKey after a save completes while the preview is open', async () => {
|
|
113
|
+
const { rerender } = render(<Harness saveState="idle" />)
|
|
114
|
+
fireEvent.click(screen.getByText('toggle'))
|
|
115
|
+
await waitFor(() => expect(state().url).not.toBeNull())
|
|
116
|
+
expect(state().reloadKey).toBe(0)
|
|
117
|
+
|
|
118
|
+
rerender(<Harness saveState="saving" />)
|
|
119
|
+
rerender(<Harness saveState="saved" />)
|
|
120
|
+
await waitFor(() => expect(state().reloadKey).toBe(1))
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -24,14 +24,79 @@ export interface CollectionListProps {
|
|
|
24
24
|
type SortField = 'title' | 'status' | 'updatedAt'
|
|
25
25
|
type SortOrder = 'asc' | 'desc'
|
|
26
26
|
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
if (!config?.collections) return fallback
|
|
27
|
+
function resolveCollection(config: any, slug: string): any | undefined {
|
|
28
|
+
if (!config?.collections) return undefined
|
|
30
29
|
const list = Array.isArray(config.collections)
|
|
31
30
|
? config.collections
|
|
32
31
|
: Object.values(config.collections)
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
return (list as any[]).find((c: any) => c.slug === slug)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveLabel(config: any, slug: string): { singular: string; plural: string } {
|
|
36
|
+
return resolveCollection(config, slug)?.labels ?? { singular: slug, plural: slug }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One facet dropdown in the filter bar. */
|
|
40
|
+
interface FacetDef {
|
|
41
|
+
name: string
|
|
42
|
+
label: string
|
|
43
|
+
/** `options` = fixed list from the config; `values` = in-use values from the field-values API. */
|
|
44
|
+
source: 'options' | 'values'
|
|
45
|
+
options?: Array<{ label: string; value: string }>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True for array fields shaped like tags: a single required text sub-field. */
|
|
49
|
+
function isTagShapedArray(field: any): boolean {
|
|
50
|
+
if (field?.type !== 'array' || !field.fields) return false
|
|
51
|
+
const subs = Object.values(field.fields) as any[]
|
|
52
|
+
return subs.length === 1 && subs[0]?.type === 'text'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Facetable fields for the filter bar (taxonomy Phase B): structured selects
|
|
57
|
+
* use their config options; free-text `category` and tag-shaped arrays load
|
|
58
|
+
* the collection's in-use values. Capped so the bar stays scannable.
|
|
59
|
+
*/
|
|
60
|
+
function facetFieldsFor(config: any, slug: string): FacetDef[] {
|
|
61
|
+
const fields = resolveCollection(config, slug)?.fields
|
|
62
|
+
if (!fields || typeof fields !== 'object') return []
|
|
63
|
+
const out: FacetDef[] = []
|
|
64
|
+
for (const [name, field] of Object.entries(fields) as Array<[string, any]>) {
|
|
65
|
+
if (field?.hidden) continue
|
|
66
|
+
if (field?.type === 'select' && Array.isArray(field.options)) {
|
|
67
|
+
out.push({ name, label: field.label ?? name, source: 'options', options: field.options })
|
|
68
|
+
} else if (field?.type === 'text' && name === 'category') {
|
|
69
|
+
out.push({ name, label: field.label ?? 'Category', source: 'values' })
|
|
70
|
+
} else if (isTagShapedArray(field)) {
|
|
71
|
+
out.push({ name, label: field.label ?? name, source: 'values' })
|
|
72
|
+
}
|
|
73
|
+
if (out.length >= 3) break
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read a doc's display value for a facet column (select values map to labels). */
|
|
79
|
+
function facetCellValue(doc: any, facet: FacetDef): string {
|
|
80
|
+
const raw = doc?.data?.[facet.name]
|
|
81
|
+
if (typeof raw === 'string') {
|
|
82
|
+
if (!raw) return ''
|
|
83
|
+
const opt = facet.options?.find((o) => o.value === raw)
|
|
84
|
+
return opt?.label ?? raw
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(raw)) {
|
|
87
|
+
const tags = raw
|
|
88
|
+
.map((item) =>
|
|
89
|
+
typeof item === 'string'
|
|
90
|
+
? item
|
|
91
|
+
: item && typeof item === 'object'
|
|
92
|
+
? Object.values(item).find((v) => typeof v === 'string')
|
|
93
|
+
: undefined,
|
|
94
|
+
)
|
|
95
|
+
.filter((v): v is string => Boolean(v))
|
|
96
|
+
if (tags.length === 0) return ''
|
|
97
|
+
return tags.length > 3 ? `${tags.slice(0, 3).join(', ')} +${tags.length - 3}` : tags.join(', ')
|
|
98
|
+
}
|
|
99
|
+
return ''
|
|
35
100
|
}
|
|
36
101
|
|
|
37
102
|
function statusColor(status: string): string {
|
|
@@ -75,6 +140,7 @@ function formatDate(d: string | undefined): string {
|
|
|
75
140
|
|
|
76
141
|
export function CollectionList({ collectionSlug, config, onNavigate }: CollectionListProps) {
|
|
77
142
|
const labels = useMemo(() => resolveLabel(config, collectionSlug), [config, collectionSlug])
|
|
143
|
+
const facets = useMemo(() => facetFieldsFor(config, collectionSlug), [config, collectionSlug])
|
|
78
144
|
|
|
79
145
|
const [page, setPage] = useState(1)
|
|
80
146
|
const [search, setSearch] = useState('')
|
|
@@ -82,6 +148,9 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
82
148
|
const [sort, setSort] = useState<SortField | null>(null)
|
|
83
149
|
const [order, setOrder] = useState<SortOrder>('asc')
|
|
84
150
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
151
|
+
const [filters, setFilters] = useState<Record<string, string>>({})
|
|
152
|
+
// In-use values per `values`-sourced facet, fetched once per collection.
|
|
153
|
+
const [facetValues, setFacetValues] = useState<Record<string, string[]>>({})
|
|
85
154
|
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
86
155
|
|
|
87
156
|
useEffect(() => {
|
|
@@ -97,8 +166,11 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
97
166
|
let url = `/collections/${collectionSlug}?page=${page}&pageSize=25`
|
|
98
167
|
if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`
|
|
99
168
|
if (sort) url += `&sort=${sort}&order=${order}`
|
|
169
|
+
for (const [field, value] of Object.entries(filters)) {
|
|
170
|
+
if (value) url += `&filter.${encodeURIComponent(field)}=${encodeURIComponent(value)}`
|
|
171
|
+
}
|
|
100
172
|
return url
|
|
101
|
-
}, [collectionSlug, page, debouncedSearch, sort, order])
|
|
173
|
+
}, [collectionSlug, page, debouncedSearch, sort, order, filters])
|
|
102
174
|
|
|
103
175
|
const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>(endpoint)
|
|
104
176
|
const docs = data?.docs ?? []
|
|
@@ -109,8 +181,40 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
109
181
|
setPage(1)
|
|
110
182
|
setSelected(new Set())
|
|
111
183
|
setSearch('')
|
|
184
|
+
setFilters({})
|
|
185
|
+
setFacetValues({})
|
|
112
186
|
}, [collectionSlug])
|
|
113
187
|
|
|
188
|
+
// Load in-use values for facets that aren't config-defined selects.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
let cancelled = false
|
|
191
|
+
for (const facet of facets) {
|
|
192
|
+
if (facet.source !== 'values') continue
|
|
193
|
+
cmsApi<{ field: string; values: Array<{ value: string; count: number }> }>(
|
|
194
|
+
`/collections/${collectionSlug}/field-values?field=${encodeURIComponent(facet.name)}`,
|
|
195
|
+
).then((res) => {
|
|
196
|
+
if (cancelled || !res.data?.values) return
|
|
197
|
+
setFacetValues((prev) => ({
|
|
198
|
+
...prev,
|
|
199
|
+
[facet.name]: res.data!.values.map((v) => v.value),
|
|
200
|
+
}))
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
return () => {
|
|
204
|
+
cancelled = true
|
|
205
|
+
}
|
|
206
|
+
}, [collectionSlug, facets])
|
|
207
|
+
|
|
208
|
+
const setFilter = useCallback((field: string, value: string) => {
|
|
209
|
+
setFilters((prev) => {
|
|
210
|
+
const next = { ...prev }
|
|
211
|
+
if (value) next[field] = value
|
|
212
|
+
else delete next[field]
|
|
213
|
+
return next
|
|
214
|
+
})
|
|
215
|
+
setPage(1)
|
|
216
|
+
}, [])
|
|
217
|
+
|
|
114
218
|
const toggleSort = useCallback((field: SortField) => {
|
|
115
219
|
setSort((prev) => {
|
|
116
220
|
if (prev === field) {
|
|
@@ -198,23 +302,64 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
198
302
|
</button>
|
|
199
303
|
</div>
|
|
200
304
|
|
|
201
|
-
{/* Search */}
|
|
202
|
-
<div className="
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
305
|
+
{/* Search + facet filters */}
|
|
306
|
+
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
307
|
+
<div className="relative min-w-[200px] flex-1">
|
|
308
|
+
<Search
|
|
309
|
+
className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
|
310
|
+
style={{ color: 'var(--actuate-text-muted, #9ca3af)' }}
|
|
311
|
+
/>
|
|
312
|
+
<input
|
|
313
|
+
type="text"
|
|
314
|
+
placeholder={`Search ${labels.plural.toLowerCase()}...`}
|
|
315
|
+
value={search}
|
|
316
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
317
|
+
className="w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus:ring-2 focus:outline-none"
|
|
318
|
+
style={{
|
|
319
|
+
borderColor: 'var(--actuate-border, #d1d5db)',
|
|
320
|
+
color: 'var(--actuate-text, #111827)',
|
|
321
|
+
}}
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
{facets.map((facet) => {
|
|
325
|
+
const values =
|
|
326
|
+
facet.source === 'options'
|
|
327
|
+
? (facet.options ?? [])
|
|
328
|
+
: (facetValues[facet.name] ?? []).map((v) => ({ label: v, value: v }))
|
|
329
|
+
if (values.length === 0) return null
|
|
330
|
+
return (
|
|
331
|
+
<select
|
|
332
|
+
key={facet.name}
|
|
333
|
+
aria-label={`Filter by ${facet.label}`}
|
|
334
|
+
value={filters[facet.name] ?? ''}
|
|
335
|
+
onChange={(e) => setFilter(facet.name, e.target.value)}
|
|
336
|
+
className="rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
|
|
337
|
+
style={{
|
|
338
|
+
borderColor: 'var(--actuate-border, #d1d5db)',
|
|
339
|
+
color: filters[facet.name]
|
|
340
|
+
? 'var(--actuate-text, #111827)'
|
|
341
|
+
: 'var(--actuate-text-secondary, #6b7280)',
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
<option value="">All {facet.label.toLowerCase()}</option>
|
|
345
|
+
{values.map((opt) => (
|
|
346
|
+
<option key={opt.value} value={opt.value}>
|
|
347
|
+
{opt.label}
|
|
348
|
+
</option>
|
|
349
|
+
))}
|
|
350
|
+
</select>
|
|
351
|
+
)
|
|
352
|
+
})}
|
|
353
|
+
{Object.keys(filters).length > 0 && (
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
onClick={() => setFilters({})}
|
|
357
|
+
className="rounded-lg px-2 py-2 text-sm underline transition-opacity hover:opacity-75"
|
|
358
|
+
style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}
|
|
359
|
+
>
|
|
360
|
+
Clear filters
|
|
361
|
+
</button>
|
|
362
|
+
)}
|
|
218
363
|
</div>
|
|
219
364
|
|
|
220
365
|
{/* Bulk actions */}
|
|
@@ -317,6 +462,15 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
317
462
|
<th className="px-3 py-2 text-left">
|
|
318
463
|
<SortCol field="title">Title</SortCol>
|
|
319
464
|
</th>
|
|
465
|
+
{facets.map((facet) => (
|
|
466
|
+
<th
|
|
467
|
+
key={facet.name}
|
|
468
|
+
className="px-3 py-2 text-left text-xs font-medium"
|
|
469
|
+
style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}
|
|
470
|
+
>
|
|
471
|
+
{facet.label}
|
|
472
|
+
</th>
|
|
473
|
+
))}
|
|
320
474
|
<th className="px-3 py-2 text-left">
|
|
321
475
|
<SortCol field="status">Status</SortCol>
|
|
322
476
|
</th>
|
|
@@ -356,6 +510,16 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
356
510
|
{doc.title || doc.name || `#${doc.id}`}
|
|
357
511
|
</button>
|
|
358
512
|
</td>
|
|
513
|
+
{facets.map((facet) => (
|
|
514
|
+
<td
|
|
515
|
+
key={facet.name}
|
|
516
|
+
className="max-w-[200px] truncate px-3 py-2"
|
|
517
|
+
style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}
|
|
518
|
+
title={facetCellValue(doc, facet) || undefined}
|
|
519
|
+
>
|
|
520
|
+
{facetCellValue(doc, facet) || '—'}
|
|
521
|
+
</td>
|
|
522
|
+
))}
|
|
359
523
|
<td className="px-3 py-2">
|
|
360
524
|
<span
|
|
361
525
|
className="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Check,
|
|
5
5
|
ExternalLink,
|
|
6
6
|
Eye,
|
|
7
|
+
Globe,
|
|
7
8
|
History,
|
|
8
9
|
Laptop,
|
|
9
10
|
Loader2,
|
|
@@ -44,6 +45,13 @@ interface EditorTopBarProps {
|
|
|
44
45
|
* hidden — e.g. for unsaved documents that have no versions yet.
|
|
45
46
|
*/
|
|
46
47
|
onHistory?: () => void
|
|
48
|
+
/** Whether the inline site preview (iframe of the public site) is showing. */
|
|
49
|
+
sitePreview?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* Toggle the inline site preview — renders the draft through the consumer's
|
|
52
|
+
* real public route inside the editor. Hidden when omitted (unsaved docs).
|
|
53
|
+
*/
|
|
54
|
+
onToggleSitePreview?: () => void
|
|
47
55
|
onOpenSettings: () => void
|
|
48
56
|
onSaveDraft: () => void
|
|
49
57
|
onPublish: () => void
|
|
@@ -85,6 +93,8 @@ export function EditorTopBar({
|
|
|
85
93
|
onPreview,
|
|
86
94
|
onViewOnSite,
|
|
87
95
|
onHistory,
|
|
96
|
+
sitePreview = false,
|
|
97
|
+
onToggleSitePreview,
|
|
88
98
|
onOpenSettings,
|
|
89
99
|
onSaveDraft,
|
|
90
100
|
onPublish,
|
|
@@ -187,6 +197,27 @@ export function EditorTopBar({
|
|
|
187
197
|
<Eye className="h-4 w-4" aria-hidden />
|
|
188
198
|
</button>
|
|
189
199
|
|
|
200
|
+
{onToggleSitePreview && (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={onToggleSitePreview}
|
|
204
|
+
aria-pressed={sitePreview}
|
|
205
|
+
aria-label={sitePreview ? 'Back to editor canvas' : 'Inline site preview'}
|
|
206
|
+
title={
|
|
207
|
+
sitePreview
|
|
208
|
+
? 'Back to editor canvas'
|
|
209
|
+
: 'Inline site preview — render the draft through your public site, in place'
|
|
210
|
+
}
|
|
211
|
+
className={`rounded-lg p-2 transition-colors ${
|
|
212
|
+
sitePreview
|
|
213
|
+
? 'bg-primary text-primary-foreground'
|
|
214
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
215
|
+
}`}
|
|
216
|
+
>
|
|
217
|
+
<Globe className="h-4 w-4" aria-hidden />
|
|
218
|
+
</button>
|
|
219
|
+
)}
|
|
220
|
+
|
|
190
221
|
{onViewOnSite &&
|
|
191
222
|
(structural ? (
|
|
192
223
|
<button
|
|
@@ -37,6 +37,8 @@ import { hasCanvasRenderer, useAdminSectionRenderers } from './sections/index.js
|
|
|
37
37
|
import { EditorTopBar, type SaveState } from './EditorTopBar.js'
|
|
38
38
|
import { SectionsPanel } from './SectionsPanel.js'
|
|
39
39
|
import { EditorCanvas } from './EditorCanvas.js'
|
|
40
|
+
import { SitePreviewFrame } from './SitePreviewFrame.js'
|
|
41
|
+
import { useSitePreview } from './useSitePreview.js'
|
|
40
42
|
import { SectionInspector } from './SectionInspector.js'
|
|
41
43
|
import { AddSectionModal } from './AddSectionModal.js'
|
|
42
44
|
import { PageSettingsModal } from './PageSettingsModal.js'
|
|
@@ -263,6 +265,18 @@ export function PageSectionEditor({
|
|
|
263
265
|
}
|
|
264
266
|
}, [page, documentId, onNavigate])
|
|
265
267
|
|
|
268
|
+
// Inline branded preview — iframe of the public site rendering this draft.
|
|
269
|
+
const sitePreview = useSitePreview({
|
|
270
|
+
collection: 'pages',
|
|
271
|
+
documentId: page?.id,
|
|
272
|
+
slug: page?.slug,
|
|
273
|
+
urlPrefix: '',
|
|
274
|
+
siteUrl: config?.seo?.siteUrl,
|
|
275
|
+
dirty,
|
|
276
|
+
saveState,
|
|
277
|
+
saveDraft: handleSaveDraft,
|
|
278
|
+
})
|
|
279
|
+
|
|
266
280
|
const requestPublish = useCallback(() => {
|
|
267
281
|
if (!page) return
|
|
268
282
|
const result = validatePage(page, true)
|
|
@@ -450,6 +464,8 @@ export function PageSectionEditor({
|
|
|
450
464
|
onBack={() => guardedNavigate('/pages')}
|
|
451
465
|
onPreview={handlePreview}
|
|
452
466
|
onViewOnSite={page.id ? handleViewOnSite : undefined}
|
|
467
|
+
sitePreview={sitePreview.active}
|
|
468
|
+
onToggleSitePreview={page.id && page.slug ? sitePreview.toggle : undefined}
|
|
453
469
|
onHistory={page.id ? () => setHistoryOpen(true) : undefined}
|
|
454
470
|
onOpenSettings={() => setSettingsOpen(true)}
|
|
455
471
|
onSaveDraft={handleSaveDraft}
|
|
@@ -519,14 +535,23 @@ export function PageSectionEditor({
|
|
|
519
535
|
/>
|
|
520
536
|
|
|
521
537
|
<div className="flex-1 overflow-hidden">
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
538
|
+
{sitePreview.active ? (
|
|
539
|
+
<SitePreviewFrame
|
|
540
|
+
src={sitePreview.url}
|
|
541
|
+
viewport={viewport}
|
|
542
|
+
reloadKey={sitePreview.reloadKey}
|
|
543
|
+
title={`Site preview: ${page.title || 'Untitled page'}`}
|
|
544
|
+
/>
|
|
545
|
+
) : (
|
|
546
|
+
<EditorCanvas
|
|
547
|
+
sections={page.sections}
|
|
548
|
+
selectedId={selectedId}
|
|
549
|
+
viewport={viewport}
|
|
550
|
+
onSelect={setSelectedId}
|
|
551
|
+
onAddSection={() => setAddOpen(true)}
|
|
552
|
+
structuralTypes={structuralTypes}
|
|
553
|
+
/>
|
|
554
|
+
)}
|
|
530
555
|
</div>
|
|
531
556
|
|
|
532
557
|
{selected && (
|