@actuate-media/cms-admin 0.76.7 → 0.77.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 +14 -0
- package/dist/__tests__/views/form-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-editor.render.test.js +146 -0
- package/dist/__tests__/views/form-editor.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/lib/forms-service.d.ts +15 -3
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/views/FormEditor.d.ts.map +1 -1
- package/dist/views/FormEditor.js +211 -116
- package/dist/views/FormEditor.js.map +1 -1
- package/package.json +9 -9
- package/src/__tests__/views/form-editor.render.test.tsx +186 -0
- package/src/lib/forms-service.ts +16 -1
- package/src/views/FormEditor.tsx +655 -510
|
@@ -0,0 +1,186 @@
|
|
|
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 editor through a mocked forms-service so the test owns the data
|
|
6
|
+
// and can assert that every tab persists through the typed Forms API (the
|
|
7
|
+
// same backend as the Forms list's Schema pane — no more legacy
|
|
8
|
+
// /collections/forms writes).
|
|
9
|
+
const FORM = {
|
|
10
|
+
id: 'f1',
|
|
11
|
+
name: 'Contact Us',
|
|
12
|
+
slug: 'contact-us',
|
|
13
|
+
description: 'General contact',
|
|
14
|
+
status: 'active' as const,
|
|
15
|
+
fields: [
|
|
16
|
+
{ id: 'c1', label: 'Full Name', key: 'name', type: 'text', required: true, sortOrder: 0 },
|
|
17
|
+
{ id: 'c2', label: 'Email', key: 'email', type: 'email', required: true, sortOrder: 1 },
|
|
18
|
+
],
|
|
19
|
+
successMessage: 'Thanks for reaching out!',
|
|
20
|
+
redirectUrl: '',
|
|
21
|
+
analytics: { enabled: false },
|
|
22
|
+
totalEntries: 3,
|
|
23
|
+
notifyEnabled: true,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fetchFormById = vi.fn(async (_id: string) => ({ ...FORM }))
|
|
27
|
+
const fetchFormSchema = vi.fn(async (_id: string) => ({
|
|
28
|
+
fields: FORM.fields,
|
|
29
|
+
activeVersionId: 'v1',
|
|
30
|
+
versions: [],
|
|
31
|
+
}))
|
|
32
|
+
const updateForm = vi.fn(async (_id: string, patch: Record<string, unknown>) => ({
|
|
33
|
+
data: { ...FORM, ...patch },
|
|
34
|
+
}))
|
|
35
|
+
const saveFormSchema = vi.fn(async (_id: string, _fields: unknown[]) => ({ data: {} }))
|
|
36
|
+
const createForm = vi.fn(async (payload: Record<string, unknown>) => ({
|
|
37
|
+
data: { ...FORM, id: 'new-form-id', ...payload },
|
|
38
|
+
}))
|
|
39
|
+
const fetchNotificationSettings = vi.fn(async (_id: string) => ({
|
|
40
|
+
enabled: true,
|
|
41
|
+
recipients: ['team@example.com'],
|
|
42
|
+
}))
|
|
43
|
+
const updateNotificationSettings = vi.fn(async () => ({ data: {} }))
|
|
44
|
+
const fetchEmbedSettings = vi.fn(async () => ({}))
|
|
45
|
+
const fetchFormWebhooks = vi.fn(async () => [])
|
|
46
|
+
|
|
47
|
+
vi.mock('../../lib/forms-service.js', () => ({
|
|
48
|
+
fetchFormById: (id: string) => fetchFormById(id),
|
|
49
|
+
fetchFormSchema: (id: string) => fetchFormSchema(id),
|
|
50
|
+
updateForm: (id: string, patch: Record<string, unknown>) => updateForm(id, patch),
|
|
51
|
+
saveFormSchema: (id: string, fields: unknown[]) => saveFormSchema(id, fields),
|
|
52
|
+
createForm: (payload: Record<string, unknown>) => createForm(payload),
|
|
53
|
+
fetchNotificationSettings: (id: string) => fetchNotificationSettings(id),
|
|
54
|
+
updateNotificationSettings: () => updateNotificationSettings(),
|
|
55
|
+
fetchEmbedSettings: () => fetchEmbedSettings(),
|
|
56
|
+
updateEmbedSettings: vi.fn(async () => ({ data: {} })),
|
|
57
|
+
fetchFormWebhooks: () => fetchFormWebhooks(),
|
|
58
|
+
createFormWebhook: vi.fn(async () => ({ data: {} })),
|
|
59
|
+
updateFormWebhook: vi.fn(async () => ({ data: {} })),
|
|
60
|
+
deleteFormWebhook: vi.fn(async () => ({})),
|
|
61
|
+
testFormWebhook: vi.fn(async () => ({ data: { ok: true } })),
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
65
|
+
|
|
66
|
+
const { FormEditor } = await import('../../views/FormEditor.js')
|
|
67
|
+
|
|
68
|
+
// Radix Tabs activates on mousedown (button 0), not a bare click.
|
|
69
|
+
function selectTab(name: string) {
|
|
70
|
+
const tab = screen.getByRole('tab', { name })
|
|
71
|
+
fireEvent.mouseDown(tab, { button: 0 })
|
|
72
|
+
fireEvent.click(tab)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
fetchFormById.mockClear()
|
|
77
|
+
fetchFormSchema.mockClear()
|
|
78
|
+
updateForm.mockClear()
|
|
79
|
+
saveFormSchema.mockClear()
|
|
80
|
+
createForm.mockClear()
|
|
81
|
+
fetchNotificationSettings.mockClear()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('FormEditor (existing form)', () => {
|
|
85
|
+
it('loads through the typed Forms API and renders every settings tab', async () => {
|
|
86
|
+
render(<FormEditor formId="f1" onNavigate={() => {}} />)
|
|
87
|
+
|
|
88
|
+
expect(await screen.findByRole('heading', { name: 'Contact Us' })).toBeTruthy()
|
|
89
|
+
expect(fetchFormById).toHaveBeenCalledWith('f1')
|
|
90
|
+
expect(fetchFormSchema).toHaveBeenCalledWith('f1')
|
|
91
|
+
|
|
92
|
+
const tabs = screen.getAllByRole('tab').map((t) => t.textContent)
|
|
93
|
+
expect(tabs).toEqual([
|
|
94
|
+
'Details',
|
|
95
|
+
'Fields',
|
|
96
|
+
'Notifications',
|
|
97
|
+
'Analytics',
|
|
98
|
+
'Embed',
|
|
99
|
+
'Integrations',
|
|
100
|
+
])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('saves details (message confirmation clears the redirect URL)', async () => {
|
|
104
|
+
render(<FormEditor formId="f1" onNavigate={() => {}} />)
|
|
105
|
+
await screen.findByRole('heading', { name: 'Contact Us' })
|
|
106
|
+
|
|
107
|
+
fireEvent.change(screen.getByLabelText(/Form Name/), { target: { value: 'Contact' } })
|
|
108
|
+
fireEvent.click(screen.getByRole('button', { name: /Save Details/ }))
|
|
109
|
+
|
|
110
|
+
await waitFor(() =>
|
|
111
|
+
expect(updateForm).toHaveBeenCalledWith(
|
|
112
|
+
'f1',
|
|
113
|
+
expect.objectContaining({
|
|
114
|
+
name: 'Contact',
|
|
115
|
+
status: 'active',
|
|
116
|
+
successMessage: 'Thanks for reaching out!',
|
|
117
|
+
redirectUrl: '',
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('exposes notification settings from the editor (the old editor had none)', async () => {
|
|
124
|
+
render(<FormEditor formId="f1" onNavigate={() => {}} />)
|
|
125
|
+
await screen.findByRole('heading', { name: 'Contact Us' })
|
|
126
|
+
|
|
127
|
+
selectTab('Notifications')
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(fetchNotificationSettings).toHaveBeenCalledWith('f1'))
|
|
130
|
+
expect(await screen.findByText('Enable notifications')).toBeTruthy()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('persists GA4 analytics through the typed update endpoint', async () => {
|
|
134
|
+
render(<FormEditor formId="f1" onNavigate={() => {}} />)
|
|
135
|
+
await screen.findByRole('heading', { name: 'Contact Us' })
|
|
136
|
+
|
|
137
|
+
selectTab('Analytics')
|
|
138
|
+
fireEvent.click(await screen.findByRole('switch', { name: /Google Analytics 4/ }))
|
|
139
|
+
fireEvent.click(screen.getByRole('button', { name: /Save Analytics/ }))
|
|
140
|
+
|
|
141
|
+
await waitFor(() =>
|
|
142
|
+
expect(updateForm).toHaveBeenCalledWith('f1', {
|
|
143
|
+
analytics: expect.objectContaining({ enabled: true }),
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('saves the field schema through the versioned schema endpoint', async () => {
|
|
149
|
+
render(<FormEditor formId="f1" onNavigate={() => {}} />)
|
|
150
|
+
await screen.findByRole('heading', { name: 'Contact Us' })
|
|
151
|
+
|
|
152
|
+
selectTab('Fields')
|
|
153
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Add Field' }))
|
|
154
|
+
fireEvent.click(screen.getByRole('button', { name: /Save Schema/ }))
|
|
155
|
+
|
|
156
|
+
await waitFor(() =>
|
|
157
|
+
expect(saveFormSchema).toHaveBeenCalledWith(
|
|
158
|
+
'f1',
|
|
159
|
+
expect.arrayContaining([expect.objectContaining({ key: 'name' })]),
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('FormEditor (new form)', () => {
|
|
166
|
+
it('creates through the typed API and routes into the full editor', async () => {
|
|
167
|
+
const onNavigate = vi.fn()
|
|
168
|
+
render(<FormEditor onNavigate={onNavigate} />)
|
|
169
|
+
|
|
170
|
+
fireEvent.change(screen.getByLabelText(/Form Name/), { target: { value: 'Newsletter' } })
|
|
171
|
+
fireEvent.click(screen.getByRole('button', { name: /Create Form/ }))
|
|
172
|
+
|
|
173
|
+
await waitFor(() =>
|
|
174
|
+
expect(createForm).toHaveBeenCalledWith(
|
|
175
|
+
expect.objectContaining({ name: 'Newsletter', status: 'active' }),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
await waitFor(() => expect(onNavigate).toHaveBeenCalledWith('/forms/new-form-id/edit'))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('disables Create until a name is entered', () => {
|
|
182
|
+
render(<FormEditor onNavigate={() => {}} />)
|
|
183
|
+
const create = screen.getByRole('button', { name: /Create Form/ }) as HTMLButtonElement
|
|
184
|
+
expect(create.disabled).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
})
|
package/src/lib/forms-service.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
FormDefinition,
|
|
16
16
|
FormSchemaVersion,
|
|
17
17
|
FormField,
|
|
18
|
+
FormAnalyticsConfig,
|
|
18
19
|
FormNotificationSettings,
|
|
19
20
|
FormEmbedSettings,
|
|
20
21
|
FormWebhookConfig,
|
|
@@ -35,6 +36,7 @@ export type {
|
|
|
35
36
|
FormFieldType,
|
|
36
37
|
FormFieldOption,
|
|
37
38
|
FieldValidationRules,
|
|
39
|
+
FormAnalyticsConfig,
|
|
38
40
|
FormNotificationSettings,
|
|
39
41
|
FormEmbedSettings,
|
|
40
42
|
FormWebhookConfig,
|
|
@@ -132,6 +134,9 @@ export interface CreateFormPayload {
|
|
|
132
134
|
status?: FormStatus
|
|
133
135
|
template?: string
|
|
134
136
|
fields?: FormField[]
|
|
137
|
+
successMessage?: string
|
|
138
|
+
redirectUrl?: string
|
|
139
|
+
analytics?: FormAnalyticsConfig
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
export async function createForm(
|
|
@@ -144,9 +149,19 @@ export async function createForm(
|
|
|
144
149
|
return { data: res.data, error: res.error }
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
/** Patch shape accepted by `PATCH /forms/:id` (cms-core `UpdateFormInput`). */
|
|
153
|
+
export interface UpdateFormPayload {
|
|
154
|
+
name?: string
|
|
155
|
+
description?: string
|
|
156
|
+
status?: FormStatus
|
|
157
|
+
successMessage?: string
|
|
158
|
+
redirectUrl?: string
|
|
159
|
+
analytics?: FormAnalyticsConfig
|
|
160
|
+
}
|
|
161
|
+
|
|
147
162
|
export async function updateForm(
|
|
148
163
|
id: string,
|
|
149
|
-
payload:
|
|
164
|
+
payload: UpdateFormPayload,
|
|
150
165
|
): Promise<{ data?: FormDefinition; error?: string }> {
|
|
151
166
|
const res = await cmsApi<FormDefinition>(`/forms/${encodeURIComponent(id)}`, {
|
|
152
167
|
method: 'PATCH',
|