@actuate-media/cms-admin 0.67.0 → 0.68.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 +16 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +29 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/hooks/useIdleLogout.d.ts +18 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +66 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -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 +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +3 -3
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +36 -7
- package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/hooks/useIdleLogout.ts +77 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/post-types-service.ts +129 -0
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Forms.tsx +154 -126
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +23 -2
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- package/src/components/seo/SeoEditorDrawer.tsx +0 -529
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
* Content SEO tab with the inline editor pane (design split-pane pattern —
|
|
7
|
+
* previously the `SeoEditorDrawer` overlay). Pins: row click toggles the
|
|
8
|
+
* 380px pane, the pane loads the record through `fetchSeoContentRecord`, and
|
|
9
|
+
* Save flows through the same `updateSeoFields` service call.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function makeRecord(over: Record<string, unknown> = {}) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'seo1',
|
|
15
|
+
entityType: 'page' as const,
|
|
16
|
+
entityId: 'p1',
|
|
17
|
+
title: 'Home',
|
|
18
|
+
url: 'https://example.com/',
|
|
19
|
+
canonicalUrl: null,
|
|
20
|
+
metaTitle: 'Home — Example',
|
|
21
|
+
metaDescription: 'Welcome to Example.',
|
|
22
|
+
focusKeyword: null,
|
|
23
|
+
keyTakeaway: null,
|
|
24
|
+
ogTitle: null,
|
|
25
|
+
ogDescription: null,
|
|
26
|
+
ogImageId: null,
|
|
27
|
+
ogImage: null,
|
|
28
|
+
twitterTitle: null,
|
|
29
|
+
twitterDescription: null,
|
|
30
|
+
twitterImageId: null,
|
|
31
|
+
noindex: false,
|
|
32
|
+
nofollow: false,
|
|
33
|
+
structuredDataType: 'WebPage',
|
|
34
|
+
structuredData: null,
|
|
35
|
+
llmsInclude: 'AUTO' as const,
|
|
36
|
+
llmsDescription: null,
|
|
37
|
+
llmsSection: null,
|
|
38
|
+
llmsOptional: false,
|
|
39
|
+
seoScore: 82,
|
|
40
|
+
seoGrade: 'good' as const,
|
|
41
|
+
updatedAt: '2026-06-01T00:00:00Z',
|
|
42
|
+
...over,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const RECORDS = [
|
|
47
|
+
makeRecord(),
|
|
48
|
+
makeRecord({ id: 'seo2', entityType: 'post', entityId: 'p2', title: 'First Post' }),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const fetchSeoContentRecords = vi.fn(async () => RECORDS)
|
|
52
|
+
const fetchSeoContentRecord = vi.fn(async (entityType: string, entityId: string) => {
|
|
53
|
+
return RECORDS.find((r) => r.entityType === entityType && r.entityId === entityId) ?? null
|
|
54
|
+
})
|
|
55
|
+
const updateSeoFields = vi.fn(async (_t: string, _i: string, _p: unknown) => ({}))
|
|
56
|
+
|
|
57
|
+
vi.mock('../../lib/seo-service.js', () => ({
|
|
58
|
+
fetchSeoContentRecords: () => fetchSeoContentRecords(),
|
|
59
|
+
fetchSeoContentRecord: (t: string, i: string) => fetchSeoContentRecord(t, i),
|
|
60
|
+
updateSeoFields: (t: string, i: string, p: unknown) => updateSeoFields(t, i, p),
|
|
61
|
+
generateSeoField: vi.fn(async () => ({ unavailable: true })),
|
|
62
|
+
SEO_SCHEMA_TYPES: ['Article', 'WebPage'],
|
|
63
|
+
applyContentSeoQuery: (
|
|
64
|
+
records: Array<{ entityType: string; title: string }>,
|
|
65
|
+
query: { search?: string; filter?: string },
|
|
66
|
+
) =>
|
|
67
|
+
records.filter((r) => {
|
|
68
|
+
if (query.filter === 'pages' && r.entityType !== 'page') return false
|
|
69
|
+
if (query.filter === 'posts' && r.entityType !== 'post') return false
|
|
70
|
+
if (query.search && !r.title.toLowerCase().includes(query.search.toLowerCase())) return false
|
|
71
|
+
return true
|
|
72
|
+
}),
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
vi.mock('../../lib/brand-voice-client.js', () => ({
|
|
76
|
+
fixBrandVoiceContent: vi.fn(async () => ({ ok: false })),
|
|
77
|
+
brandAlignmentTone: () => 'text-success',
|
|
78
|
+
brandAlignmentLabel: () => 'On brand',
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
82
|
+
|
|
83
|
+
const { ContentTab } = await import('../../views/seo/ContentTab.js')
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
fetchSeoContentRecords.mockClear()
|
|
87
|
+
fetchSeoContentRecord.mockClear()
|
|
88
|
+
updateSeoFields.mockClear()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('Content SEO inline editor pane', () => {
|
|
92
|
+
it('opens the pane on row click and loads the record', async () => {
|
|
93
|
+
render(<ContentTab />)
|
|
94
|
+
const rowButton = await screen.findByRole('button', { name: /Home/ })
|
|
95
|
+
expect(rowButton.getAttribute('aria-expanded')).toBe('false')
|
|
96
|
+
|
|
97
|
+
fireEvent.click(rowButton)
|
|
98
|
+
|
|
99
|
+
expect(screen.getByRole('region', { name: 'SEO editor' })).toBeTruthy()
|
|
100
|
+
expect(rowButton.getAttribute('aria-expanded')).toBe('true')
|
|
101
|
+
await waitFor(() => expect(fetchSeoContentRecord).toHaveBeenCalledWith('page', 'p1'))
|
|
102
|
+
// Loaded field values populate the form.
|
|
103
|
+
expect(await screen.findByDisplayValue('Home — Example')).toBeTruthy()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('toggles the pane closed when the same row is clicked again', async () => {
|
|
107
|
+
render(<ContentTab />)
|
|
108
|
+
const rowButton = await screen.findByRole('button', { name: /Home/ })
|
|
109
|
+
|
|
110
|
+
fireEvent.click(rowButton)
|
|
111
|
+
expect(screen.getByRole('region', { name: 'SEO editor' })).toBeTruthy()
|
|
112
|
+
|
|
113
|
+
fireEvent.click(rowButton)
|
|
114
|
+
expect(screen.queryByRole('region', { name: 'SEO editor' })).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('saves edited fields through updateSeoFields', async () => {
|
|
118
|
+
render(<ContentTab />)
|
|
119
|
+
fireEvent.click(await screen.findByRole('button', { name: /Home/ }))
|
|
120
|
+
|
|
121
|
+
const titleInput = await screen.findByDisplayValue('Home — Example')
|
|
122
|
+
fireEvent.change(titleInput, { target: { value: 'New Meta Title' } })
|
|
123
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
|
|
124
|
+
|
|
125
|
+
await waitFor(() =>
|
|
126
|
+
expect(updateSeoFields).toHaveBeenCalledWith(
|
|
127
|
+
'page',
|
|
128
|
+
'p1',
|
|
129
|
+
expect.objectContaining({ metaTitle: 'New Meta Title' }),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
// The list refetches after a successful save.
|
|
133
|
+
await waitFor(() => expect(fetchSeoContentRecords.mock.calls.length).toBeGreaterThan(1))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('opens the pane on mount for deep links', async () => {
|
|
137
|
+
render(<ContentTab initialEntityType="post" initialEntityId="p2" />)
|
|
138
|
+
|
|
139
|
+
await waitFor(() => expect(fetchSeoContentRecord).toHaveBeenCalledWith('post', 'p2'))
|
|
140
|
+
expect(screen.getByRole('region', { name: 'SEO editor' })).toBeTruthy()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Entry detail pane — lightweight read-only inspector shown beside the
|
|
5
|
+
* collection documents table (design's Entries split-pane, 400px). Surfaces
|
|
6
|
+
* the entry's key fields, status, and timestamps; the primary action opens
|
|
7
|
+
* the existing full editor route. Not an editor — editing stays in the
|
|
8
|
+
* dedicated editor views.
|
|
9
|
+
*/
|
|
10
|
+
import { ExternalLink } from 'lucide-react'
|
|
11
|
+
import {
|
|
12
|
+
InspectorPaneBody,
|
|
13
|
+
InspectorPaneFooter,
|
|
14
|
+
InspectorPaneHeader,
|
|
15
|
+
InspectorPaneSection,
|
|
16
|
+
} from '../ui/InspectorPane.js'
|
|
17
|
+
import { StatusBadge, type StatusBadgeTone } from '../ui/StatusBadge.js'
|
|
18
|
+
|
|
19
|
+
interface FieldDef {
|
|
20
|
+
type?: string
|
|
21
|
+
label?: string
|
|
22
|
+
hidden?: boolean
|
|
23
|
+
options?: Array<{ label: string; value: string }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SKIPPED_FIELD_TYPES = new Set(['richText', 'blocks', 'json', 'media', 'relationship'])
|
|
27
|
+
const MAX_FIELDS = 8
|
|
28
|
+
|
|
29
|
+
function statusTone(status: string | undefined): StatusBadgeTone {
|
|
30
|
+
switch (status?.toUpperCase()) {
|
|
31
|
+
case 'PUBLISHED':
|
|
32
|
+
return 'success'
|
|
33
|
+
case 'SCHEDULED':
|
|
34
|
+
return 'warning'
|
|
35
|
+
case 'ARCHIVED':
|
|
36
|
+
return 'muted'
|
|
37
|
+
default:
|
|
38
|
+
return 'neutral'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatDateTime(value: unknown): string {
|
|
43
|
+
if (typeof value !== 'string' || !value) return '—'
|
|
44
|
+
const date = new Date(value)
|
|
45
|
+
if (Number.isNaN(date.getTime())) return '—'
|
|
46
|
+
return date.toLocaleDateString(undefined, {
|
|
47
|
+
month: 'short',
|
|
48
|
+
day: 'numeric',
|
|
49
|
+
year: 'numeric',
|
|
50
|
+
hour: 'numeric',
|
|
51
|
+
minute: '2-digit',
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Human-readable display value for a scalar-ish field, or null to skip. */
|
|
56
|
+
function displayValue(raw: unknown, field: FieldDef): string | null {
|
|
57
|
+
if (raw == null || raw === '') return ''
|
|
58
|
+
if (typeof raw === 'boolean') return raw ? 'Yes' : 'No'
|
|
59
|
+
if (typeof raw === 'number') return String(raw)
|
|
60
|
+
if (typeof raw === 'string') {
|
|
61
|
+
const opt = field.options?.find((o) => o.value === raw)
|
|
62
|
+
return opt?.label ?? raw
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(raw)) {
|
|
65
|
+
const tags = raw
|
|
66
|
+
.map((item) =>
|
|
67
|
+
typeof item === 'string'
|
|
68
|
+
? item
|
|
69
|
+
: item && typeof item === 'object'
|
|
70
|
+
? Object.values(item).find((v): v is string => typeof v === 'string')
|
|
71
|
+
: undefined,
|
|
72
|
+
)
|
|
73
|
+
.filter((v): v is string => Boolean(v))
|
|
74
|
+
if (tags.length > 0) return tags.join(', ')
|
|
75
|
+
return `${raw.length} item${raw.length === 1 ? '' : 's'}`
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Key fields worth showing in the pane: labelled scalars from the config. */
|
|
81
|
+
function keyFields(
|
|
82
|
+
fields: Record<string, FieldDef> | undefined,
|
|
83
|
+
data: Record<string, unknown>,
|
|
84
|
+
): Array<{ name: string; label: string; value: string }> {
|
|
85
|
+
if (!fields || typeof fields !== 'object') return []
|
|
86
|
+
const out: Array<{ name: string; label: string; value: string }> = []
|
|
87
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
88
|
+
if (!field || field.hidden) continue
|
|
89
|
+
if (field.type && SKIPPED_FIELD_TYPES.has(field.type)) continue
|
|
90
|
+
const value = displayValue(data[name], field)
|
|
91
|
+
if (value === null) continue
|
|
92
|
+
out.push({ name, label: field.label ?? name, value })
|
|
93
|
+
if (out.length >= MAX_FIELDS) break
|
|
94
|
+
}
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface EntryDetailPaneProps {
|
|
99
|
+
doc: Record<string, any>
|
|
100
|
+
/** Collection definition from the CMS config (untyped upstream). */
|
|
101
|
+
collection: any
|
|
102
|
+
singularLabel: string
|
|
103
|
+
onClose: () => void
|
|
104
|
+
onOpenEditor: () => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function EntryDetailPane({
|
|
108
|
+
doc,
|
|
109
|
+
collection,
|
|
110
|
+
singularLabel,
|
|
111
|
+
onClose,
|
|
112
|
+
onOpenEditor,
|
|
113
|
+
}: EntryDetailPaneProps) {
|
|
114
|
+
const title = doc.title || doc.name || `#${doc.id}`
|
|
115
|
+
const data = (doc.data ?? {}) as Record<string, unknown>
|
|
116
|
+
const fields = keyFields(collection?.fields as Record<string, FieldDef> | undefined, data)
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<>
|
|
120
|
+
<InspectorPaneHeader onClose={onClose} closeLabel={`Close ${singularLabel} details`}>
|
|
121
|
+
<div className="truncate text-[13.5px] font-semibold text-[var(--txt)]">{title}</div>
|
|
122
|
+
<div className="mt-1 flex items-center gap-2">
|
|
123
|
+
<StatusBadge tone={statusTone(doc.status)}>{doc.status ?? 'DRAFT'}</StatusBadge>
|
|
124
|
+
<span className="text-[11px] text-[var(--muted)]">{singularLabel}</span>
|
|
125
|
+
</div>
|
|
126
|
+
</InspectorPaneHeader>
|
|
127
|
+
|
|
128
|
+
<InspectorPaneBody>
|
|
129
|
+
<InspectorPaneSection title="Fields">
|
|
130
|
+
{fields.length === 0 ? (
|
|
131
|
+
<p className="text-[12px] text-[var(--muted)]">No previewable fields.</p>
|
|
132
|
+
) : (
|
|
133
|
+
<dl>
|
|
134
|
+
{fields.map((f) => (
|
|
135
|
+
<div key={f.name} className="mb-2.5 last:mb-0">
|
|
136
|
+
<dt className="text-[10.5px] font-semibold tracking-[0.5px] text-[var(--muted)] uppercase">
|
|
137
|
+
{f.label}
|
|
138
|
+
</dt>
|
|
139
|
+
{f.value ? (
|
|
140
|
+
<dd className="mt-0.5 text-[12.5px] break-words text-[var(--txt)]">
|
|
141
|
+
{f.value}
|
|
142
|
+
</dd>
|
|
143
|
+
) : (
|
|
144
|
+
<dd className="mt-0.5 text-[12.5px] text-[var(--muted)] italic">
|
|
145
|
+
Not provided
|
|
146
|
+
</dd>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</dl>
|
|
151
|
+
)}
|
|
152
|
+
</InspectorPaneSection>
|
|
153
|
+
|
|
154
|
+
<InspectorPaneSection title="Record">
|
|
155
|
+
<div className="flex items-center justify-between gap-3 py-1 text-[12px]">
|
|
156
|
+
<span className="text-[var(--muted)]">Created</span>
|
|
157
|
+
<span className="text-[var(--sub)]">{formatDateTime(doc.createdAt)}</span>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="flex items-center justify-between gap-3 py-1 text-[12px]">
|
|
160
|
+
<span className="text-[var(--muted)]">Updated</span>
|
|
161
|
+
<span className="text-[var(--sub)]">{formatDateTime(doc.updatedAt)}</span>
|
|
162
|
+
</div>
|
|
163
|
+
<div className="flex items-center justify-between gap-3 py-1 text-[12px]">
|
|
164
|
+
<span className="text-[var(--muted)]">ID</span>
|
|
165
|
+
<span className="font-mono text-[11px] text-[var(--muted)]">{String(doc.id)}</span>
|
|
166
|
+
</div>
|
|
167
|
+
</InspectorPaneSection>
|
|
168
|
+
</InspectorPaneBody>
|
|
169
|
+
|
|
170
|
+
<InspectorPaneFooter>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={onOpenEditor}
|
|
174
|
+
className="flex w-full items-center justify-center gap-1.5 rounded-[7px] bg-[var(--acc)] px-3 py-2 text-[13px] font-semibold text-white transition-colors duration-[var(--motion-fast)] hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
175
|
+
>
|
|
176
|
+
<ExternalLink size={14} aria-hidden />
|
|
177
|
+
Open full editor
|
|
178
|
+
</button>
|
|
179
|
+
</InspectorPaneFooter>
|
|
180
|
+
</>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -268,7 +268,9 @@ function FieldEditor({
|
|
|
268
268
|
|
|
269
269
|
return (
|
|
270
270
|
<div className="border-border space-y-3 border-t px-3 py-3">
|
|
271
|
-
|
|
271
|
+
{/* Stacked (not sm:grid-cols-2): this editor now lives in the 390px
|
|
272
|
+
schema inspector pane, where viewport-based two-column grids cramp. */}
|
|
273
|
+
<div className="grid grid-cols-1 gap-3">
|
|
272
274
|
<Labeled label="Label">
|
|
273
275
|
<input
|
|
274
276
|
type="text"
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Form Schema pane — inline inspector opened from the Forms list "Schema"
|
|
5
|
+
* action (design's 390px schema panel; previously the `FormSchemaDrawer`
|
|
6
|
+
* overlay). Four tabs: Fields (versioned schema editor), Embed (public
|
|
7
|
+
* snippets + domain restrictions), Notifications (email settings),
|
|
8
|
+
* Integrations (CRM webhooks / Zapier).
|
|
9
|
+
*
|
|
10
|
+
* Saving the Fields tab creates a new schema version server-side; existing
|
|
11
|
+
* submissions keep referencing the version they were captured against, so
|
|
12
|
+
* historical entries never break when the schema changes.
|
|
13
|
+
*/
|
|
14
|
+
import * as Tabs from '@radix-ui/react-tabs'
|
|
15
|
+
import { useEffect, useState } from 'react'
|
|
16
|
+
import { toast } from 'sonner'
|
|
17
|
+
import { fetchFormSchema, saveFormSchema, type FormField } from '../../lib/forms-service.js'
|
|
18
|
+
import type { FormListItem } from '../../lib/forms-service.js'
|
|
19
|
+
import { emitFormsChanged } from '../../lib/forms-events.js'
|
|
20
|
+
import { ConfirmDialog } from '../ui/ConfirmDialog.js'
|
|
21
|
+
import { InspectorPaneBody, InspectorPaneFooter, InspectorPaneHeader } from '../ui/InspectorPane.js'
|
|
22
|
+
import { FieldsPanel } from './FieldsPanel.js'
|
|
23
|
+
import { EmbedPanel } from './EmbedPanel.js'
|
|
24
|
+
import { NotificationsPanel } from './NotificationsPanel.js'
|
|
25
|
+
import { WebhooksPanel } from './WebhooksPanel.js'
|
|
26
|
+
import { FormsErrorState, FormsLoading, btnPrimary } from './primitives.js'
|
|
27
|
+
|
|
28
|
+
const TABS = [
|
|
29
|
+
{ id: 'fields', label: 'Fields' },
|
|
30
|
+
{ id: 'embed', label: 'Embed' },
|
|
31
|
+
{ id: 'notifications', label: 'Notifications' },
|
|
32
|
+
{ id: 'integrations', label: 'Integrations' },
|
|
33
|
+
] as const
|
|
34
|
+
|
|
35
|
+
export function FormSchemaPane({
|
|
36
|
+
form,
|
|
37
|
+
onClose,
|
|
38
|
+
onSaved,
|
|
39
|
+
}: {
|
|
40
|
+
form: FormListItem
|
|
41
|
+
onClose: () => void
|
|
42
|
+
/** Called after a schema/settings change so the list can refetch. */
|
|
43
|
+
onSaved?: () => void
|
|
44
|
+
}) {
|
|
45
|
+
const [tab, setTab] = useState<string>('fields')
|
|
46
|
+
const [fields, setFields] = useState<FormField[]>([])
|
|
47
|
+
const [loading, setLoading] = useState(false)
|
|
48
|
+
const [error, setError] = useState<string | null>(null)
|
|
49
|
+
const [dirty, setDirty] = useState(false)
|
|
50
|
+
const [saving, setSaving] = useState(false)
|
|
51
|
+
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
|
52
|
+
|
|
53
|
+
const formId = form.id
|
|
54
|
+
const hasSubmissions = (form.totalEntries ?? 0) > 0
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
setTab('fields')
|
|
58
|
+
setDirty(false)
|
|
59
|
+
setLoading(true)
|
|
60
|
+
setError(null)
|
|
61
|
+
let active = true
|
|
62
|
+
fetchFormSchema(formId)
|
|
63
|
+
.then((schema) => {
|
|
64
|
+
if (active) setFields(schema.fields)
|
|
65
|
+
})
|
|
66
|
+
.catch((e: unknown) => {
|
|
67
|
+
if (active) setError(e instanceof Error ? e.message : 'Failed to load schema')
|
|
68
|
+
})
|
|
69
|
+
.finally(() => {
|
|
70
|
+
if (active) setLoading(false)
|
|
71
|
+
})
|
|
72
|
+
return () => {
|
|
73
|
+
active = false
|
|
74
|
+
}
|
|
75
|
+
}, [formId])
|
|
76
|
+
|
|
77
|
+
function handleFieldsChange(next: FormField[]) {
|
|
78
|
+
setFields(next)
|
|
79
|
+
setDirty(true)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handleSave() {
|
|
83
|
+
setSaving(true)
|
|
84
|
+
const res = await saveFormSchema(
|
|
85
|
+
formId,
|
|
86
|
+
fields.map((f, i) => ({ ...f, sortOrder: i })),
|
|
87
|
+
)
|
|
88
|
+
setSaving(false)
|
|
89
|
+
if (res.error) {
|
|
90
|
+
toast.error(res.error)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
setDirty(false)
|
|
94
|
+
toast.success('Schema saved.')
|
|
95
|
+
emitFormsChanged()
|
|
96
|
+
onSaved?.()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requestClose() {
|
|
100
|
+
if (dirty) {
|
|
101
|
+
setConfirmDiscard(true)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
onClose()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<InspectorPaneHeader onClose={requestClose} closeLabel="Close schema panel">
|
|
110
|
+
<div className="truncate text-[13.5px] font-semibold text-[var(--txt)]">{form.name}</div>
|
|
111
|
+
<div className="mt-0.5 truncate font-mono text-[11.5px] text-[var(--muted)]">
|
|
112
|
+
/{form.slug}
|
|
113
|
+
</div>
|
|
114
|
+
</InspectorPaneHeader>
|
|
115
|
+
|
|
116
|
+
<Tabs.Root value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
|
|
117
|
+
<Tabs.List
|
|
118
|
+
className="flex shrink-0 gap-0.5 overflow-x-auto border-b border-[var(--bdr)] px-2"
|
|
119
|
+
aria-label="Form schema sections"
|
|
120
|
+
>
|
|
121
|
+
{TABS.map((t) => (
|
|
122
|
+
<Tabs.Trigger
|
|
123
|
+
key={t.id}
|
|
124
|
+
value={t.id}
|
|
125
|
+
className="text-muted-foreground hover:text-foreground data-[state=active]:text-foreground data-[state=active]:border-primary focus-visible:ring-ring -mb-px border-b-2 border-transparent px-2.5 py-2 text-[12.5px] font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
126
|
+
>
|
|
127
|
+
{t.label}
|
|
128
|
+
</Tabs.Trigger>
|
|
129
|
+
))}
|
|
130
|
+
</Tabs.List>
|
|
131
|
+
|
|
132
|
+
<InspectorPaneBody>
|
|
133
|
+
<div className="px-4 py-3.5">
|
|
134
|
+
<Tabs.Content value="fields" tabIndex={-1}>
|
|
135
|
+
{loading ? (
|
|
136
|
+
<FormsLoading label="Loading schema…" />
|
|
137
|
+
) : error ? (
|
|
138
|
+
<FormsErrorState message={error} onRetry={() => setTab('fields')} />
|
|
139
|
+
) : (
|
|
140
|
+
<FieldsPanel
|
|
141
|
+
fields={fields}
|
|
142
|
+
onChange={handleFieldsChange}
|
|
143
|
+
hasSubmissions={hasSubmissions}
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
146
|
+
</Tabs.Content>
|
|
147
|
+
|
|
148
|
+
<Tabs.Content value="embed" tabIndex={-1}>
|
|
149
|
+
<EmbedPanel formId={formId} slug={form.slug} status={form.status} />
|
|
150
|
+
</Tabs.Content>
|
|
151
|
+
|
|
152
|
+
<Tabs.Content value="notifications" tabIndex={-1}>
|
|
153
|
+
<NotificationsPanel formId={formId} onChanged={onSaved} />
|
|
154
|
+
</Tabs.Content>
|
|
155
|
+
|
|
156
|
+
<Tabs.Content value="integrations" tabIndex={-1}>
|
|
157
|
+
<WebhooksPanel formId={formId} formSlug={form.slug} />
|
|
158
|
+
</Tabs.Content>
|
|
159
|
+
</div>
|
|
160
|
+
</InspectorPaneBody>
|
|
161
|
+
|
|
162
|
+
{tab === 'fields' && (
|
|
163
|
+
<InspectorPaneFooter>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
className={`${btnPrimary} w-full`}
|
|
167
|
+
onClick={() => void handleSave()}
|
|
168
|
+
disabled={saving || loading}
|
|
169
|
+
>
|
|
170
|
+
{saving ? 'Saving…' : 'Save Schema'}
|
|
171
|
+
</button>
|
|
172
|
+
</InspectorPaneFooter>
|
|
173
|
+
)}
|
|
174
|
+
</Tabs.Root>
|
|
175
|
+
|
|
176
|
+
<ConfirmDialog
|
|
177
|
+
open={confirmDiscard}
|
|
178
|
+
onClose={() => setConfirmDiscard(false)}
|
|
179
|
+
onConfirm={() => {
|
|
180
|
+
setDirty(false)
|
|
181
|
+
setConfirmDiscard(false)
|
|
182
|
+
onClose()
|
|
183
|
+
}}
|
|
184
|
+
title="Discard unsaved changes?"
|
|
185
|
+
description="You have unsaved schema changes. Closing now will discard them."
|
|
186
|
+
confirmLabel="Discard"
|
|
187
|
+
cancelLabel="Keep editing"
|
|
188
|
+
destructive
|
|
189
|
+
/>
|
|
190
|
+
</>
|
|
191
|
+
)
|
|
192
|
+
}
|