@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.
Files changed (146) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +29 -5
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
  6. package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
  7. package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
  8. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
  9. package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
  10. package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/post-types-service.test.js +88 -0
  12. package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
  13. package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
  14. package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
  15. package/dist/__tests__/views/forms-list.render.test.js +25 -6
  16. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  17. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
  18. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
  20. package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
  21. package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/page-templates.render.test.js +105 -0
  24. package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
  25. package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
  26. package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
  28. package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
  29. package/dist/__tests__/views/security-settings.render.test.js +85 -4
  30. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  31. package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
  34. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
  35. package/dist/actuate-admin.css +1 -1
  36. package/dist/components/collections/EntryDetailPane.d.ts +10 -0
  37. package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
  38. package/dist/components/collections/EntryDetailPane.js +92 -0
  39. package/dist/components/collections/EntryDetailPane.js.map +1 -0
  40. package/dist/components/forms/FieldsPanel.js +1 -1
  41. package/dist/components/forms/FieldsPanel.js.map +1 -1
  42. package/dist/components/forms/FormSchemaPane.d.ts +8 -0
  43. package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
  44. package/dist/components/forms/FormSchemaPane.js +95 -0
  45. package/dist/components/forms/FormSchemaPane.js.map +1 -0
  46. package/dist/components/seo/SeoEditorPane.d.ts +9 -0
  47. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
  48. package/dist/components/seo/SeoEditorPane.js +175 -0
  49. package/dist/components/seo/SeoEditorPane.js.map +1 -0
  50. package/dist/components/ui/Badge.d.ts +1 -1
  51. package/dist/components/ui/Button.d.ts +1 -1
  52. package/dist/components/ui/StatusBadge.d.ts +1 -1
  53. package/dist/hooks/useIdleLogout.d.ts +18 -0
  54. package/dist/hooks/useIdleLogout.d.ts.map +1 -0
  55. package/dist/hooks/useIdleLogout.js +66 -0
  56. package/dist/hooks/useIdleLogout.js.map +1 -0
  57. package/dist/lib/api.d.ts +1 -0
  58. package/dist/lib/api.d.ts.map +1 -1
  59. package/dist/lib/api.js +7 -1
  60. package/dist/lib/api.js.map +1 -1
  61. package/dist/lib/post-types-service.d.ts +44 -0
  62. package/dist/lib/post-types-service.d.ts.map +1 -0
  63. package/dist/lib/post-types-service.js +83 -0
  64. package/dist/lib/post-types-service.js.map +1 -0
  65. package/dist/views/CollectionList.d.ts.map +1 -1
  66. package/dist/views/CollectionList.js +23 -4
  67. package/dist/views/CollectionList.js.map +1 -1
  68. package/dist/views/Forms.d.ts.map +1 -1
  69. package/dist/views/Forms.js +25 -21
  70. package/dist/views/Forms.js.map +1 -1
  71. package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
  72. package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
  73. package/dist/views/Posts/PostTypeEditor.js +159 -0
  74. package/dist/views/Posts/PostTypeEditor.js.map +1 -0
  75. package/dist/views/Posts/PostTypesView.d.ts +3 -2
  76. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  77. package/dist/views/Posts/PostTypesView.js +1 -1
  78. package/dist/views/Posts/PostTypesView.js.map +1 -1
  79. package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
  80. package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
  81. package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
  82. package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
  83. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  84. package/dist/views/page-builder/PageBuilder.js +51 -2
  85. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  86. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  87. package/dist/views/page-builder/PageTemplates.js +113 -6
  88. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  89. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  90. package/dist/views/seo/ContentTab.js +22 -6
  91. package/dist/views/seo/ContentTab.js.map +1 -1
  92. package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
  93. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
  94. package/dist/views/settings/AccessProtectionCard.js +13 -10
  95. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  96. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  97. package/dist/views/settings/SecurityTab.js +3 -3
  98. package/dist/views/settings/SecurityTab.js.map +1 -1
  99. package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
  100. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  101. package/dist/views/settings/SessionPolicyCard.js +12 -5
  102. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  103. package/dist/views/settings/useSecuritySettings.d.ts +13 -1
  104. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  105. package/dist/views/settings/useSecuritySettings.js +47 -4
  106. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  107. package/package.json +2 -2
  108. package/src/AdminRoot.tsx +36 -7
  109. package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
  110. package/src/__tests__/lib/post-types-service.test.ts +112 -0
  111. package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
  112. package/src/__tests__/views/forms-list.render.test.tsx +34 -6
  113. package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
  114. package/src/__tests__/views/page-templates.render.test.tsx +142 -0
  115. package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
  116. package/src/__tests__/views/security-settings.render.test.tsx +111 -4
  117. package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
  118. package/src/components/collections/EntryDetailPane.tsx +182 -0
  119. package/src/components/forms/FieldsPanel.tsx +3 -1
  120. package/src/components/forms/FormSchemaPane.tsx +192 -0
  121. package/src/components/seo/SeoEditorPane.tsx +533 -0
  122. package/src/hooks/useIdleLogout.ts +77 -0
  123. package/src/lib/api.ts +8 -2
  124. package/src/lib/post-types-service.ts +129 -0
  125. package/src/views/CollectionList.tsx +158 -110
  126. package/src/views/Forms.tsx +154 -126
  127. package/src/views/Posts/PostTypeEditor.tsx +568 -0
  128. package/src/views/Posts/PostTypesView.tsx +5 -4
  129. package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
  130. package/src/views/page-builder/PageBuilder.tsx +69 -2
  131. package/src/views/page-builder/PageTemplates.tsx +340 -57
  132. package/src/views/seo/ContentTab.tsx +113 -80
  133. package/src/views/settings/AccessProtectionCard.tsx +83 -53
  134. package/src/views/settings/SecurityTab.tsx +23 -2
  135. package/src/views/settings/SessionPolicyCard.tsx +45 -9
  136. package/src/views/settings/useSecuritySettings.ts +78 -22
  137. package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
  138. package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
  139. package/dist/components/forms/FormSchemaDrawer.js +0 -99
  140. package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
  141. package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
  142. package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
  143. package/dist/components/seo/SeoEditorDrawer.js +0 -172
  144. package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
  145. package/src/components/forms/FormSchemaDrawer.tsx +0 -192
  146. 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
- <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
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
+ }