@duffcloudservices/site-forms 0.1.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 (51) hide show
  1. package/README.md +213 -0
  2. package/dist/DcsForm.vue.d.ts +67 -0
  3. package/dist/composables/useDcsForm.d.ts +36 -0
  4. package/dist/composables/useFormSubmission.d.ts +18 -0
  5. package/dist/composables/useFormValidation.d.ts +19 -0
  6. package/dist/fields/DcsFormCheckbox.vue.d.ts +12 -0
  7. package/dist/fields/DcsFormCheckboxGroup.vue.d.ts +12 -0
  8. package/dist/fields/DcsFormDate.vue.d.ts +14 -0
  9. package/dist/fields/DcsFormFieldWrapper.vue.d.ts +27 -0
  10. package/dist/fields/DcsFormFile.vue.d.ts +12 -0
  11. package/dist/fields/DcsFormHidden.vue.d.ts +7 -0
  12. package/dist/fields/DcsFormHtmlBlock.vue.d.ts +6 -0
  13. package/dist/fields/DcsFormRadio.vue.d.ts +12 -0
  14. package/dist/fields/DcsFormSection.vue.d.ts +21 -0
  15. package/dist/fields/DcsFormSelect.vue.d.ts +35 -0
  16. package/dist/fields/DcsFormText.vue.d.ts +34 -0
  17. package/dist/fields/DcsFormTextarea.vue.d.ts +34 -0
  18. package/dist/index.d.ts +22 -0
  19. package/dist/index.js +918 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/loaders/yaml.d.ts +12 -0
  22. package/dist/schema/validate.d.ts +9 -0
  23. package/dist/types.d.ts +106 -0
  24. package/package.json +73 -0
  25. package/src/DcsForm.vue +299 -0
  26. package/src/__tests__/fields.test.ts +82 -0
  27. package/src/__tests__/multi-step.test.ts +46 -0
  28. package/src/__tests__/schema.test.ts +42 -0
  29. package/src/__tests__/submission.test.ts +77 -0
  30. package/src/__tests__/visible-if.test.ts +111 -0
  31. package/src/composables/useDcsForm.ts +201 -0
  32. package/src/composables/useFormSubmission.ts +113 -0
  33. package/src/composables/useFormValidation.ts +127 -0
  34. package/src/fields/DcsFormCheckbox.vue +35 -0
  35. package/src/fields/DcsFormCheckboxGroup.vue +52 -0
  36. package/src/fields/DcsFormDate.vue +34 -0
  37. package/src/fields/DcsFormFieldWrapper.vue +39 -0
  38. package/src/fields/DcsFormFile.vue +38 -0
  39. package/src/fields/DcsFormHidden.vue +17 -0
  40. package/src/fields/DcsFormHtmlBlock.vue +19 -0
  41. package/src/fields/DcsFormRadio.vue +45 -0
  42. package/src/fields/DcsFormSection.vue +19 -0
  43. package/src/fields/DcsFormSelect.vue +62 -0
  44. package/src/fields/DcsFormText.vue +54 -0
  45. package/src/fields/DcsFormTextarea.vue +43 -0
  46. package/src/index.ts +51 -0
  47. package/src/loaders/yaml.ts +51 -0
  48. package/src/schema/form-definition.schema.json +633 -0
  49. package/src/schema/validate.ts +58 -0
  50. package/src/shims.d.ts +10 -0
  51. package/src/types.ts +140 -0
@@ -0,0 +1,299 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import type {
4
+ DcsFormSubmitError,
5
+ DcsFormSubmitSuccess,
6
+ PortalFormDefinition,
7
+ PortalFormField,
8
+ } from './types'
9
+ import { useDcsForm } from './composables/useDcsForm'
10
+ import { submitFormValues } from './composables/useFormSubmission'
11
+ import { warnIfInvalid } from './schema/validate'
12
+ import { loadFormDefinitions } from './loaders/yaml'
13
+
14
+ import DcsFormText from './fields/DcsFormText.vue'
15
+ import DcsFormTextarea from './fields/DcsFormTextarea.vue'
16
+ import DcsFormSelect from './fields/DcsFormSelect.vue'
17
+ import DcsFormRadio from './fields/DcsFormRadio.vue'
18
+ import DcsFormCheckboxGroup from './fields/DcsFormCheckboxGroup.vue'
19
+ import DcsFormCheckbox from './fields/DcsFormCheckbox.vue'
20
+ import DcsFormDate from './fields/DcsFormDate.vue'
21
+ import DcsFormFile from './fields/DcsFormFile.vue'
22
+ import DcsFormHidden from './fields/DcsFormHidden.vue'
23
+ import DcsFormSection from './fields/DcsFormSection.vue'
24
+ import DcsFormHtmlBlock from './fields/DcsFormHtmlBlock.vue'
25
+
26
+ interface Props {
27
+ /** Kebab-case form id; matches `.dcs/forms/<formId>.yaml`. */
28
+ formId: string
29
+ /** Site slug used for the submission endpoint path. */
30
+ siteSlug?: string
31
+ /** Override the loaded definition (used by the portal preview iframe). */
32
+ definitionOverride?: PortalFormDefinition
33
+ /** Override the API base URL; defaults to `VITE_DCS_PUBLIC_API`. */
34
+ apiBase?: string
35
+ /** Optional captcha token, attached to the submission payload. */
36
+ captchaToken?: string
37
+ /**
38
+ * Optional override for the YAML modules map. Mostly for tests; in
39
+ * real apps the build-time `import.meta.glob` call below is used.
40
+ */
41
+ formsModules?: Record<string, unknown>
42
+ }
43
+
44
+ const props = defineProps<Props>()
45
+
46
+ const emit = defineEmits<{
47
+ 'submit-success': [event: DcsFormSubmitSuccess]
48
+ 'submit-error': [event: DcsFormSubmitError]
49
+ 'validation-error': [errors: Record<string, string | undefined>]
50
+ }>()
51
+
52
+ // Eager glob — Vite inlines every site form at build time. Customer
53
+ // sites should configure `vite-plugin-yaml` so YAML is parsed to an
54
+ // object; without it the loader falls back to parsing raw strings.
55
+ const eagerFormsModules = (import.meta as unknown as {
56
+ glob: (
57
+ pattern: string,
58
+ opts: { eager: true; import: string; query?: string },
59
+ ) => Record<string, unknown>
60
+ }).glob('/.dcs/forms/*.yaml', { eager: true, import: 'default' })
61
+
62
+ const definitions = computed(() =>
63
+ loadFormDefinitions(props.formsModules ?? eagerFormsModules),
64
+ )
65
+
66
+ const definition = computed<PortalFormDefinition | null>(() => {
67
+ if (props.definitionOverride) return props.definitionOverride
68
+ return definitions.value[props.formId] ?? null
69
+ })
70
+
71
+ const isDev = !!(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV
72
+
73
+ watch(
74
+ definition,
75
+ (def) => {
76
+ if (def) warnIfInvalid(props.formId, def, isDev)
77
+ },
78
+ { immediate: true },
79
+ )
80
+
81
+ // We always call useDcsForm with *some* definition so hooks render
82
+ // stably; if the form is missing we'll show an error state instead.
83
+ const safeDefinition = computed<PortalFormDefinition>(
84
+ () =>
85
+ definition.value ?? {
86
+ formId: props.formId,
87
+ title: '',
88
+ submission: { kind: 'lead' },
89
+ fields: [],
90
+ },
91
+ )
92
+
93
+ const form = useDcsForm({ definition: safeDefinition.value })
94
+
95
+ // If the resolved definition changes (e.g. preview iframe updates),
96
+ // re-create derived state by resetting.
97
+ watch(
98
+ () => safeDefinition.value,
99
+ () => form.reset(),
100
+ )
101
+
102
+ const apiBase = computed(
103
+ () =>
104
+ props.apiBase ??
105
+ (import.meta as unknown as { env?: { VITE_DCS_PUBLIC_API?: string } }).env
106
+ ?.VITE_DCS_PUBLIC_API ??
107
+ '',
108
+ )
109
+
110
+ const resolvedSiteSlug = computed(
111
+ () =>
112
+ props.siteSlug ??
113
+ (import.meta as unknown as { env?: { VITE_DCS_SITE_SLUG?: string } }).env
114
+ ?.VITE_DCS_SITE_SLUG ??
115
+ '',
116
+ )
117
+
118
+ const submitLabel = computed(
119
+ () => safeDefinition.value.submitLabel ?? 'Send',
120
+ )
121
+
122
+ function fieldComponent(field: PortalFormField) {
123
+ switch (field.type) {
124
+ case 'text':
125
+ case 'email':
126
+ case 'tel':
127
+ return DcsFormText
128
+ case 'textarea':
129
+ return DcsFormTextarea
130
+ case 'select':
131
+ case 'multiselect':
132
+ return DcsFormSelect
133
+ case 'radio':
134
+ return DcsFormRadio
135
+ case 'checkbox-group':
136
+ return DcsFormCheckboxGroup
137
+ case 'checkbox':
138
+ return DcsFormCheckbox
139
+ case 'date':
140
+ return DcsFormDate
141
+ case 'file':
142
+ return DcsFormFile
143
+ case 'hidden':
144
+ return DcsFormHidden
145
+ case 'section-heading':
146
+ return DcsFormSection
147
+ case 'html-block':
148
+ return DcsFormHtmlBlock
149
+ default:
150
+ return DcsFormText
151
+ }
152
+ }
153
+
154
+ const formEl = ref<HTMLFormElement | null>(null)
155
+
156
+ async function onSubmit(e: Event): Promise<void> {
157
+ e.preventDefault()
158
+ if (!definition.value) return
159
+ const ok = form.validateAll()
160
+ if (!ok) {
161
+ emit('validation-error', form.errors.value)
162
+ return
163
+ }
164
+ form.submitting.value = true
165
+ form.submitError.value = null
166
+ const payload = {
167
+ formId: props.formId,
168
+ values: form.collectSubmissionValues(),
169
+ captchaToken: props.captchaToken,
170
+ }
171
+ try {
172
+ const result = await submitFormValues({
173
+ apiBase: apiBase.value,
174
+ siteSlug: resolvedSiteSlug.value,
175
+ payload,
176
+ })
177
+ form.submitted.value = true
178
+ emit('submit-success', result)
179
+ } catch (err) {
180
+ const e2 = err as DcsFormSubmitError
181
+ form.submitError.value = e2.error?.message ?? 'Submission failed'
182
+ emit('submit-error', e2)
183
+ } finally {
184
+ form.submitting.value = false
185
+ }
186
+ }
187
+
188
+ onMounted(() => {
189
+ if (!definition.value) {
190
+ // eslint-disable-next-line no-console
191
+ console.warn(
192
+ `[@duffcloudservices/site-forms] No form definition found for "${props.formId}". ` +
193
+ `Expected a YAML at /.dcs/forms/${props.formId}.yaml.`,
194
+ )
195
+ }
196
+ })
197
+ </script>
198
+
199
+ <template>
200
+ <div v-if="!definition" class="dcs-form dcs-form--missing" :data-form-key="formId">
201
+ <slot name="missing" :form-id="formId">
202
+ <p>Form &ldquo;{{ formId }}&rdquo; is not configured.</p>
203
+ </slot>
204
+ </div>
205
+
206
+ <div v-else-if="form.submitted.value" class="dcs-form dcs-form--success" :data-form-key="formId">
207
+ <slot name="success" :definition="definition">
208
+ <p>{{ definition.successMessage ?? 'Thanks — we received your message.' }}</p>
209
+ </slot>
210
+ </div>
211
+
212
+ <form
213
+ v-else
214
+ ref="formEl"
215
+ class="dcs-form"
216
+ :data-form-key="formId"
217
+ novalidate
218
+ @submit="onSubmit"
219
+ >
220
+ <slot name="header" :definition="definition">
221
+ <header class="dcs-form__header">
222
+ <h2 class="dcs-form__title">{{ definition.title }}</h2>
223
+ <p v-if="definition.description" class="dcs-form__description">
224
+ {{ definition.description }}
225
+ </p>
226
+ </header>
227
+ </slot>
228
+
229
+ <div v-if="form.steps.value" class="dcs-form__progress" aria-live="polite">
230
+ <slot
231
+ name="progress"
232
+ :current="form.currentStepIndex.value"
233
+ :total="form.steps.value.length"
234
+ :step="form.currentStep.value"
235
+ >
236
+ <p>
237
+ Step {{ form.currentStepIndex.value + 1 }} of
238
+ {{ form.steps.value.length }}
239
+ <template v-if="form.currentStep.value">
240
+ — {{ form.currentStep.value.title }}
241
+ </template>
242
+ </p>
243
+ </slot>
244
+ </div>
245
+
246
+ <div class="dcs-form__fields">
247
+ <component
248
+ :is="fieldComponent(field)"
249
+ v-for="field in form.visibleFields.value"
250
+ :key="field.id"
251
+ :field="field"
252
+ :model-value="form.values[field.id]"
253
+ :error="form.errors.value[field.id]"
254
+ @update:model-value="(v: unknown) => form.setValue(field.id, v)"
255
+ @blur="form.touch(field.id)"
256
+ />
257
+ </div>
258
+
259
+ <div v-if="form.submitError.value" class="dcs-form__submit-error" role="alert">
260
+ {{ form.submitError.value }}
261
+ </div>
262
+
263
+ <div class="dcs-form__actions">
264
+ <slot
265
+ name="actions"
266
+ :is-first-step="form.isFirstStep.value"
267
+ :is-last-step="form.isLastStep.value"
268
+ :submitting="form.submitting.value"
269
+ :prev="form.prev"
270
+ :next="form.next"
271
+ >
272
+ <button
273
+ v-if="form.steps.value && !form.isFirstStep.value"
274
+ type="button"
275
+ class="dcs-form__btn dcs-form__btn--prev"
276
+ @click="form.prev()"
277
+ >
278
+ Previous
279
+ </button>
280
+ <button
281
+ v-if="form.steps.value && !form.isLastStep.value"
282
+ type="button"
283
+ class="dcs-form__btn dcs-form__btn--next"
284
+ @click="form.next()"
285
+ >
286
+ Next
287
+ </button>
288
+ <button
289
+ v-if="!form.steps.value || form.isLastStep.value"
290
+ type="submit"
291
+ class="dcs-form__btn dcs-form__btn--submit"
292
+ :disabled="form.submitting.value"
293
+ >
294
+ {{ form.submitting.value ? 'Sending…' : submitLabel }}
295
+ </button>
296
+ </slot>
297
+ </div>
298
+ </form>
299
+ </template>
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import DcsForm from '../DcsForm.vue'
4
+ import type { PortalFormDefinition } from '../types'
5
+
6
+ function makeDef(
7
+ overrides: Partial<PortalFormDefinition> = {},
8
+ ): PortalFormDefinition {
9
+ return {
10
+ formId: 'contact',
11
+ title: 'Contact',
12
+ submission: { kind: 'lead' },
13
+ fields: [
14
+ { id: 'name', type: 'text', label: 'Name', required: true },
15
+ { id: 'email', type: 'email', label: 'Email', required: true },
16
+ { id: 'message', type: 'textarea', label: 'Message' },
17
+ ],
18
+ ...overrides,
19
+ }
20
+ }
21
+
22
+ describe('field rendering', () => {
23
+ it('emits data-form-key on the form element', () => {
24
+ const wrapper = mount(DcsForm, {
25
+ props: { formId: 'contact', siteSlug: 's', definitionOverride: makeDef() },
26
+ })
27
+ const form = wrapper.find('form')
28
+ expect(form.attributes('data-form-key')).toBe('contact')
29
+ })
30
+
31
+ it('renders an input per text field with data-form-field-key', () => {
32
+ const wrapper = mount(DcsForm, {
33
+ props: { formId: 'contact', siteSlug: 's', definitionOverride: makeDef() },
34
+ })
35
+ expect(wrapper.find('[data-form-field-key="name"] input[type="text"]').exists()).toBe(true)
36
+ expect(wrapper.find('[data-form-field-key="email"] input[type="email"]').exists()).toBe(true)
37
+ expect(wrapper.find('[data-form-field-key="message"] textarea').exists()).toBe(true)
38
+ })
39
+
40
+ it('renders select / radio / checkbox-group / date / file / hidden / section / html-block', () => {
41
+ const def = makeDef({
42
+ fields: [
43
+ {
44
+ id: 'sel',
45
+ type: 'select',
46
+ label: 'Sel',
47
+ options: [{ value: 'a', label: 'A' }],
48
+ },
49
+ {
50
+ id: 'rad',
51
+ type: 'radio',
52
+ label: 'Rad',
53
+ options: [{ value: 'a', label: 'A' }],
54
+ },
55
+ {
56
+ id: 'chg',
57
+ type: 'checkbox-group',
58
+ label: 'Chg',
59
+ options: [{ value: 'a', label: 'A' }],
60
+ },
61
+ { id: 'cb', type: 'checkbox', label: 'CB' },
62
+ { id: 'dt', type: 'date', label: 'Dt' },
63
+ { id: 'f', type: 'file', label: 'F' },
64
+ { id: 'h', type: 'hidden', label: 'H', defaultValue: 'x' },
65
+ { id: 'sec', type: 'section-heading', label: 'Sec' },
66
+ { id: 'html', type: 'html-block', label: 'HTML', html: '<p>hi</p>' },
67
+ ],
68
+ })
69
+ const wrapper = mount(DcsForm, {
70
+ props: { formId: 'contact', siteSlug: 's', definitionOverride: def },
71
+ })
72
+ expect(wrapper.find('[data-form-field-key="sel"] select').exists()).toBe(true)
73
+ expect(wrapper.find('[data-form-field-key="rad"] input[type="radio"]').exists()).toBe(true)
74
+ expect(wrapper.find('[data-form-field-key="chg"] input[type="checkbox"]').exists()).toBe(true)
75
+ expect(wrapper.find('[data-form-field-key="cb"] input[type="checkbox"]').exists()).toBe(true)
76
+ expect(wrapper.find('[data-form-field-key="dt"] input[type="date"]').exists()).toBe(true)
77
+ expect(wrapper.find('[data-form-field-key="f"] input[type="file"]').exists()).toBe(true)
78
+ expect(wrapper.find('input[type="hidden"][name="h"]').exists()).toBe(true)
79
+ expect(wrapper.find('[data-form-field-key="sec"] h3').exists()).toBe(true)
80
+ expect(wrapper.find('[data-form-field-key="html"]').html()).toContain('<p>hi</p>')
81
+ })
82
+ })
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import DcsForm from '../DcsForm.vue'
4
+ import type { PortalFormDefinition } from '../types'
5
+
6
+ const def: PortalFormDefinition = {
7
+ formId: 'wiz',
8
+ title: 'Wizard',
9
+ submission: { kind: 'lead' },
10
+ steps: [
11
+ { id: 's1', title: 'One', fieldIds: ['a'] },
12
+ { id: 's2', title: 'Two', fieldIds: ['b'] },
13
+ ],
14
+ fields: [
15
+ { id: 'a', type: 'text', label: 'A', required: true },
16
+ { id: 'b', type: 'text', label: 'B', required: true },
17
+ ],
18
+ }
19
+
20
+ describe('multi-step navigation', () => {
21
+ it('starts on step 1, blocks Next until current step is valid', async () => {
22
+ const wrapper = mount(DcsForm, {
23
+ props: { formId: 'wiz', siteSlug: 's', definitionOverride: def },
24
+ })
25
+ expect(wrapper.text()).toContain('Step 1 of 2')
26
+ // Step 1 field B is not visible yet
27
+ expect(wrapper.find('[data-form-field-key="a"]').exists()).toBe(true)
28
+ expect(wrapper.find('[data-form-field-key="b"]').exists()).toBe(false)
29
+
30
+ // Click Next without filling A — should not advance
31
+ await wrapper.find('.dcs-form__btn--next').trigger('click')
32
+ expect(wrapper.text()).toContain('Step 1 of 2')
33
+ expect(wrapper.text()).toContain('A is required')
34
+
35
+ // Fill A, then advance
36
+ await wrapper.find('[data-form-field-key="a"] input').setValue('hi')
37
+ await wrapper.find('.dcs-form__btn--next').trigger('click')
38
+ await flushPromises()
39
+ expect(wrapper.text()).toContain('Step 2 of 2')
40
+ expect(wrapper.find('[data-form-field-key="b"]').exists()).toBe(true)
41
+
42
+ // Prev returns
43
+ await wrapper.find('.dcs-form__btn--prev').trigger('click')
44
+ expect(wrapper.text()).toContain('Step 1 of 2')
45
+ })
46
+ })
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { validateFormDefinition, warnIfInvalid } from '../schema/validate'
3
+ import type { PortalFormDefinition } from '../types'
4
+
5
+ const valid: PortalFormDefinition = {
6
+ formId: 'contact',
7
+ title: 'Contact',
8
+ submission: { kind: 'lead' },
9
+ fields: [
10
+ { id: 'name', type: 'text', label: 'Name', required: true },
11
+ { id: 'email', type: 'email', label: 'Email', required: true },
12
+ ],
13
+ }
14
+
15
+ describe('schema validate', () => {
16
+ it('passes for a well-formed definition', () => {
17
+ const r = validateFormDefinition(valid)
18
+ expect(r.valid).toBe(true)
19
+ expect(r.errors).toEqual([])
20
+ })
21
+
22
+ it('fails when required keys are missing', () => {
23
+ const bad = { formId: 'x' } as unknown as PortalFormDefinition
24
+ const r = validateFormDefinition(bad)
25
+ expect(r.valid).toBe(false)
26
+ expect(r.errors.length).toBeGreaterThan(0)
27
+ })
28
+
29
+ it('emits a dev-only console.warn for malformed definitions', () => {
30
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
31
+ warnIfInvalid('contact', { formId: 'contact' }, true)
32
+ expect(spy).toHaveBeenCalled()
33
+ spy.mockRestore()
34
+ })
35
+
36
+ it('does NOT warn when not in dev mode', () => {
37
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
38
+ warnIfInvalid('contact', { formId: 'contact' }, false)
39
+ expect(spy).not.toHaveBeenCalled()
40
+ spy.mockRestore()
41
+ })
42
+ })
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import DcsForm from '../DcsForm.vue'
4
+ import type { PortalFormDefinition } from '../types'
5
+
6
+ const def: PortalFormDefinition = {
7
+ formId: 'contact',
8
+ title: 'Contact',
9
+ submission: { kind: 'lead' },
10
+ fields: [
11
+ { id: 'name', type: 'text', label: 'Name', required: true },
12
+ { id: 'email', type: 'email', label: 'Email', required: true },
13
+ { id: 'message', type: 'textarea', label: 'Message' },
14
+ ],
15
+ }
16
+
17
+ describe('submission happy path', () => {
18
+ it('POSTs the right payload shape and emits submit-success', async () => {
19
+ const fetchMock = vi.fn().mockResolvedValue({
20
+ ok: true,
21
+ status: 200,
22
+ json: async () => ({ leadId: 'L-1' }),
23
+ })
24
+ const originalFetch = globalThis.fetch
25
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch =
26
+ fetchMock as unknown as typeof fetch
27
+
28
+ const wrapper = mount(DcsForm, {
29
+ props: {
30
+ formId: 'contact',
31
+ siteSlug: 'kept',
32
+ definitionOverride: def,
33
+ apiBase: 'https://api.example.com/',
34
+ captchaToken: 'tok-abc',
35
+ },
36
+ })
37
+
38
+ await wrapper.find('[data-form-field-key="name"] input').setValue('Jane')
39
+ await wrapper.find('[data-form-field-key="email"] input').setValue('jane@example.com')
40
+ await wrapper.find('[data-form-field-key="message"] textarea').setValue('hello')
41
+
42
+ await wrapper.find('form').trigger('submit')
43
+ await flushPromises()
44
+
45
+ expect(fetchMock).toHaveBeenCalledTimes(1)
46
+ const [url, init] = fetchMock.mock.calls[0]
47
+ expect(url).toBe('https://api.example.com/public/sites/kept/form-submissions')
48
+ expect((init as RequestInit).method).toBe('POST')
49
+ expect(((init as RequestInit).headers as Record<string, string>)['Content-Type']).toBe(
50
+ 'application/json',
51
+ )
52
+ const body = JSON.parse((init as RequestInit).body as string)
53
+ expect(body).toEqual({
54
+ formId: 'contact',
55
+ values: { name: 'Jane', email: 'jane@example.com', message: 'hello' },
56
+ captchaToken: 'tok-abc',
57
+ })
58
+
59
+ expect(wrapper.emitted('submit-success')?.length).toBe(1)
60
+
61
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
62
+ })
63
+
64
+ it('emits validation-error when required fields are missing', async () => {
65
+ const wrapper = mount(DcsForm, {
66
+ props: {
67
+ formId: 'contact',
68
+ siteSlug: 'kept',
69
+ definitionOverride: def,
70
+ apiBase: 'https://api.example.com',
71
+ },
72
+ })
73
+ await wrapper.find('form').trigger('submit')
74
+ await flushPromises()
75
+ expect(wrapper.emitted('validation-error')?.length).toBe(1)
76
+ })
77
+ })
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import DcsForm from '../DcsForm.vue'
4
+ import type { PortalFormDefinition } from '../types'
5
+
6
+ const def: PortalFormDefinition = {
7
+ formId: 'gated',
8
+ title: 'Gated',
9
+ submission: { kind: 'lead' },
10
+ fields: [
11
+ {
12
+ id: 'kind',
13
+ type: 'radio',
14
+ label: 'Kind',
15
+ required: true,
16
+ options: [
17
+ { value: 'a', label: 'A' },
18
+ { value: 'b', label: 'B' },
19
+ ],
20
+ },
21
+ {
22
+ id: 'detail',
23
+ type: 'text',
24
+ label: 'Detail',
25
+ required: true,
26
+ visibleIf: { fieldId: 'kind', equals: 'b' },
27
+ },
28
+ ],
29
+ }
30
+
31
+ describe('visibleIf', () => {
32
+ it('skips validation and submission for hidden gated fields', async () => {
33
+ const fetchMock = vi.fn().mockResolvedValue({
34
+ ok: true,
35
+ status: 200,
36
+ json: async () => ({ id: '1' }),
37
+ })
38
+ // Stub global fetch
39
+ const originalFetch = globalThis.fetch
40
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch =
41
+ fetchMock as unknown as typeof fetch
42
+
43
+ const wrapper = mount(DcsForm, {
44
+ props: {
45
+ formId: 'gated',
46
+ siteSlug: 'site-x',
47
+ definitionOverride: def,
48
+ apiBase: 'https://api.test',
49
+ },
50
+ })
51
+
52
+ // detail field is hidden initially
53
+ expect(wrapper.find('[data-form-field-key="detail"]').exists()).toBe(false)
54
+
55
+ // Choose kind=a (does not reveal detail)
56
+ await wrapper.find('input[type="radio"][value="a"]').setChecked(true)
57
+ await wrapper.find('form').trigger('submit')
58
+ await flushPromises()
59
+
60
+ expect(fetchMock).toHaveBeenCalledTimes(1)
61
+ const [, init] = fetchMock.mock.calls[0]
62
+ const body = JSON.parse((init as RequestInit).body as string)
63
+ expect(body.values).toEqual({ kind: 'a' })
64
+ expect('detail' in body.values).toBe(false)
65
+
66
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
67
+ })
68
+
69
+ it('validates and submits the gated field once visible', async () => {
70
+ const fetchMock = vi.fn().mockResolvedValue({
71
+ ok: true,
72
+ status: 200,
73
+ json: async () => ({}),
74
+ })
75
+ const originalFetch = globalThis.fetch
76
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch =
77
+ fetchMock as unknown as typeof fetch
78
+
79
+ const wrapper = mount(DcsForm, {
80
+ props: {
81
+ formId: 'gated',
82
+ siteSlug: 'site-x',
83
+ definitionOverride: def,
84
+ apiBase: 'https://api.test',
85
+ },
86
+ })
87
+ await wrapper.find('input[type="radio"][value="b"]').setChecked(true)
88
+ // detail should now be visible and required
89
+ expect(wrapper.find('[data-form-field-key="detail"]').exists()).toBe(true)
90
+
91
+ await wrapper.find('form').trigger('submit')
92
+ await flushPromises()
93
+ expect(fetchMock).not.toHaveBeenCalled()
94
+ expect(wrapper.text()).toContain('Detail is required')
95
+
96
+ await wrapper.find('[data-form-field-key="detail"] input').setValue('xyz')
97
+ await wrapper.find('form').trigger('submit')
98
+ await flushPromises()
99
+ expect(fetchMock).toHaveBeenCalledTimes(1)
100
+ const [url, init] = fetchMock.mock.calls[0]
101
+ expect(url).toBe('https://api.test/public/sites/site-x/form-submissions')
102
+ const body = JSON.parse((init as RequestInit).body as string)
103
+ expect(body).toEqual({
104
+ formId: 'gated',
105
+ values: { kind: 'b', detail: 'xyz' },
106
+ captchaToken: undefined,
107
+ })
108
+
109
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
110
+ })
111
+ })