@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.
- package/README.md +213 -0
- package/dist/DcsForm.vue.d.ts +67 -0
- package/dist/composables/useDcsForm.d.ts +36 -0
- package/dist/composables/useFormSubmission.d.ts +18 -0
- package/dist/composables/useFormValidation.d.ts +19 -0
- package/dist/fields/DcsFormCheckbox.vue.d.ts +12 -0
- package/dist/fields/DcsFormCheckboxGroup.vue.d.ts +12 -0
- package/dist/fields/DcsFormDate.vue.d.ts +14 -0
- package/dist/fields/DcsFormFieldWrapper.vue.d.ts +27 -0
- package/dist/fields/DcsFormFile.vue.d.ts +12 -0
- package/dist/fields/DcsFormHidden.vue.d.ts +7 -0
- package/dist/fields/DcsFormHtmlBlock.vue.d.ts +6 -0
- package/dist/fields/DcsFormRadio.vue.d.ts +12 -0
- package/dist/fields/DcsFormSection.vue.d.ts +21 -0
- package/dist/fields/DcsFormSelect.vue.d.ts +35 -0
- package/dist/fields/DcsFormText.vue.d.ts +34 -0
- package/dist/fields/DcsFormTextarea.vue.d.ts +34 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +918 -0
- package/dist/index.js.map +1 -0
- package/dist/loaders/yaml.d.ts +12 -0
- package/dist/schema/validate.d.ts +9 -0
- package/dist/types.d.ts +106 -0
- package/package.json +73 -0
- package/src/DcsForm.vue +299 -0
- package/src/__tests__/fields.test.ts +82 -0
- package/src/__tests__/multi-step.test.ts +46 -0
- package/src/__tests__/schema.test.ts +42 -0
- package/src/__tests__/submission.test.ts +77 -0
- package/src/__tests__/visible-if.test.ts +111 -0
- package/src/composables/useDcsForm.ts +201 -0
- package/src/composables/useFormSubmission.ts +113 -0
- package/src/composables/useFormValidation.ts +127 -0
- package/src/fields/DcsFormCheckbox.vue +35 -0
- package/src/fields/DcsFormCheckboxGroup.vue +52 -0
- package/src/fields/DcsFormDate.vue +34 -0
- package/src/fields/DcsFormFieldWrapper.vue +39 -0
- package/src/fields/DcsFormFile.vue +38 -0
- package/src/fields/DcsFormHidden.vue +17 -0
- package/src/fields/DcsFormHtmlBlock.vue +19 -0
- package/src/fields/DcsFormRadio.vue +45 -0
- package/src/fields/DcsFormSection.vue +19 -0
- package/src/fields/DcsFormSelect.vue +62 -0
- package/src/fields/DcsFormText.vue +54 -0
- package/src/fields/DcsFormTextarea.vue +43 -0
- package/src/index.ts +51 -0
- package/src/loaders/yaml.ts +51 -0
- package/src/schema/form-definition.schema.json +633 -0
- package/src/schema/validate.ts +58 -0
- package/src/shims.d.ts +10 -0
- package/src/types.ts +140 -0
package/src/DcsForm.vue
ADDED
|
@@ -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 “{{ formId }}” 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
|
+
})
|