@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.
@@ -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
+ })
@@ -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: Partial<Pick<FormDefinition, 'name' | 'slug' | 'description' | 'status'>>,
164
+ payload: UpdateFormPayload,
150
165
  ): Promise<{ data?: FormDefinition; error?: string }> {
151
166
  const res = await cmsApi<FormDefinition>(`/forms/${encodeURIComponent(id)}`, {
152
167
  method: 'PATCH',