@duffcloudservices/site-forms 0.1.1 → 0.1.3
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 +274 -260
- package/dist/composables/useFormSubmission.d.ts +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +554 -398
- package/dist/index.js.map +1 -1
- package/dist/presets.d.ts +13 -0
- package/dist/site-forms.css +1 -0
- package/dist/types.d.ts +12 -2
- package/package.json +72 -73
- package/src/DcsForm.vue +295 -303
- package/src/__tests__/fields.test.ts +81 -82
- package/src/__tests__/multi-step.test.ts +45 -46
- package/src/__tests__/presets.test.ts +64 -0
- package/src/__tests__/schema.test.ts +41 -42
- package/src/__tests__/style-import.test.ts +9 -0
- package/src/__tests__/submission.test.ts +115 -77
- package/src/__tests__/validation.test.ts +29 -0
- package/src/__tests__/visible-if.test.ts +110 -111
- package/src/composables/useDcsForm.ts +201 -201
- package/src/composables/useFormSubmission.ts +113 -113
- package/src/composables/useFormValidation.ts +128 -127
- package/src/fields/DcsFormCheckbox.vue +35 -35
- package/src/fields/DcsFormCheckboxGroup.vue +52 -52
- package/src/fields/DcsFormDate.vue +34 -34
- package/src/fields/DcsFormFieldWrapper.vue +39 -39
- package/src/fields/DcsFormFile.vue +38 -38
- package/src/fields/DcsFormHidden.vue +17 -17
- package/src/fields/DcsFormHtmlBlock.vue +19 -19
- package/src/fields/DcsFormRadio.vue +45 -45
- package/src/fields/DcsFormSection.vue +19 -19
- package/src/fields/DcsFormSelect.vue +62 -62
- package/src/fields/DcsFormText.vue +54 -54
- package/src/fields/DcsFormTextarea.vue +43 -43
- package/src/index.ts +64 -51
- package/src/loaders/yaml.ts +51 -51
- package/src/presets.ts +192 -0
- package/src/schema/form-definition.schema.json +410 -45
- package/src/schema/validate.ts +58 -58
- package/src/shims.d.ts +10 -10
- package/src/style.css +256 -0
- package/src/types.ts +164 -140
|
@@ -1,111 +1,110 @@
|
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{ value: '
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
await wrapper.find('
|
|
57
|
-
await
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
expect(body.values).
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
await
|
|
92
|
-
|
|
93
|
-
expect(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
await wrapper.find('
|
|
97
|
-
await
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
submission: { kind: 'lead' },
|
|
9
|
+
fields: [
|
|
10
|
+
{
|
|
11
|
+
id: 'kind',
|
|
12
|
+
type: 'radio',
|
|
13
|
+
label: 'Kind',
|
|
14
|
+
required: true,
|
|
15
|
+
options: [
|
|
16
|
+
{ value: 'a', label: 'A' },
|
|
17
|
+
{ value: 'b', label: 'B' },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'detail',
|
|
22
|
+
type: 'text',
|
|
23
|
+
label: 'Detail',
|
|
24
|
+
required: true,
|
|
25
|
+
visibleIf: { fieldId: 'kind', equals: 'b' },
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('visibleIf', () => {
|
|
31
|
+
it('skips validation and submission for hidden gated fields', async () => {
|
|
32
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
33
|
+
ok: true,
|
|
34
|
+
status: 200,
|
|
35
|
+
json: async () => ({ id: '1' }),
|
|
36
|
+
})
|
|
37
|
+
// Stub global fetch
|
|
38
|
+
const originalFetch = globalThis.fetch
|
|
39
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch =
|
|
40
|
+
fetchMock as unknown as typeof fetch
|
|
41
|
+
|
|
42
|
+
const wrapper = mount(DcsForm, {
|
|
43
|
+
props: {
|
|
44
|
+
formId: 'gated',
|
|
45
|
+
siteSlug: 'site-x',
|
|
46
|
+
definitionOverride: def,
|
|
47
|
+
apiBase: 'https://api.test',
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// detail field is hidden initially
|
|
52
|
+
expect(wrapper.find('[data-form-field-key="detail"]').exists()).toBe(false)
|
|
53
|
+
|
|
54
|
+
// Choose kind=a (does not reveal detail)
|
|
55
|
+
await wrapper.find('input[type="radio"][value="a"]').setChecked(true)
|
|
56
|
+
await wrapper.find('form').trigger('submit')
|
|
57
|
+
await flushPromises()
|
|
58
|
+
|
|
59
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
60
|
+
const [, init] = fetchMock.mock.calls[0]
|
|
61
|
+
const body = JSON.parse((init as RequestInit).body as string)
|
|
62
|
+
expect(body.values).toEqual({ kind: 'a' })
|
|
63
|
+
expect('detail' in body.values).toBe(false)
|
|
64
|
+
|
|
65
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('validates and submits the gated field once visible', async () => {
|
|
69
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
70
|
+
ok: true,
|
|
71
|
+
status: 200,
|
|
72
|
+
json: async () => ({}),
|
|
73
|
+
})
|
|
74
|
+
const originalFetch = globalThis.fetch
|
|
75
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch =
|
|
76
|
+
fetchMock as unknown as typeof fetch
|
|
77
|
+
|
|
78
|
+
const wrapper = mount(DcsForm, {
|
|
79
|
+
props: {
|
|
80
|
+
formId: 'gated',
|
|
81
|
+
siteSlug: 'site-x',
|
|
82
|
+
definitionOverride: def,
|
|
83
|
+
apiBase: 'https://api.test',
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
await wrapper.find('input[type="radio"][value="b"]').setChecked(true)
|
|
87
|
+
// detail should now be visible and required
|
|
88
|
+
expect(wrapper.find('[data-form-field-key="detail"]').exists()).toBe(true)
|
|
89
|
+
|
|
90
|
+
await wrapper.find('form').trigger('submit')
|
|
91
|
+
await flushPromises()
|
|
92
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
93
|
+
expect(wrapper.text()).toContain('Detail is required')
|
|
94
|
+
|
|
95
|
+
await wrapper.find('[data-form-field-key="detail"] input').setValue('xyz')
|
|
96
|
+
await wrapper.find('form').trigger('submit')
|
|
97
|
+
await flushPromises()
|
|
98
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
99
|
+
const [url, init] = fetchMock.mock.calls[0]
|
|
100
|
+
expect(url).toBe('https://api.test/sites/site-x/forms/gated/submissions')
|
|
101
|
+
const body = JSON.parse((init as RequestInit).body as string)
|
|
102
|
+
expect(body).toEqual({
|
|
103
|
+
formId: 'gated',
|
|
104
|
+
values: { kind: 'b', detail: 'xyz' },
|
|
105
|
+
captchaToken: undefined,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -1,201 +1,201 @@
|
|
|
1
|
-
import { computed, reactive, ref, type ComputedRef, type Ref } from 'vue'
|
|
2
|
-
import type {
|
|
3
|
-
PortalFormDefinition,
|
|
4
|
-
PortalFormField,
|
|
5
|
-
PortalFormStep,
|
|
6
|
-
FormErrors,
|
|
7
|
-
FormValues,
|
|
8
|
-
} from '../types'
|
|
9
|
-
import {
|
|
10
|
-
hasErrors,
|
|
11
|
-
isFieldVisible,
|
|
12
|
-
validateForm,
|
|
13
|
-
} from './useFormValidation'
|
|
14
|
-
|
|
15
|
-
export interface UseDcsFormOptions {
|
|
16
|
-
definition: PortalFormDefinition
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface UseDcsFormReturn {
|
|
20
|
-
values: FormValues
|
|
21
|
-
errors: Ref<FormErrors>
|
|
22
|
-
touched: Ref<Record<string, boolean>>
|
|
23
|
-
submitting: Ref<boolean>
|
|
24
|
-
submitted: Ref<boolean>
|
|
25
|
-
submitError: Ref<string | null>
|
|
26
|
-
/** All fields, with layout-only types preserved. */
|
|
27
|
-
fields: ComputedRef<PortalFormField[]>
|
|
28
|
-
/** Steps if multi-step, else null. */
|
|
29
|
-
steps: ComputedRef<PortalFormStep[] | null>
|
|
30
|
-
currentStepIndex: Ref<number>
|
|
31
|
-
currentStep: ComputedRef<PortalFormStep | null>
|
|
32
|
-
currentStepFields: ComputedRef<PortalFormField[]>
|
|
33
|
-
isFirstStep: ComputedRef<boolean>
|
|
34
|
-
isLastStep: ComputedRef<boolean>
|
|
35
|
-
visibleFields: ComputedRef<PortalFormField[]>
|
|
36
|
-
/** Sets a field value and clears that field's error. */
|
|
37
|
-
setValue: (id: string, value: unknown) => void
|
|
38
|
-
/** Marks a field as touched + revalidates only that field's row. */
|
|
39
|
-
touch: (id: string) => void
|
|
40
|
-
/** Validates the current step (multi-step) or the entire form. */
|
|
41
|
-
validateCurrentScope: () => boolean
|
|
42
|
-
validateAll: () => boolean
|
|
43
|
-
next: () => boolean
|
|
44
|
-
prev: () => void
|
|
45
|
-
reset: () => void
|
|
46
|
-
/** Returns the values that should actually be submitted (visible only). */
|
|
47
|
-
collectSubmissionValues: () => FormValues
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function defaultsFor(def: PortalFormDefinition): FormValues {
|
|
51
|
-
const out: FormValues = {}
|
|
52
|
-
for (const f of def.fields) {
|
|
53
|
-
if (f.defaultValue !== undefined) {
|
|
54
|
-
out[f.id] = f.defaultValue as unknown
|
|
55
|
-
} else if (f.type === 'checkbox') {
|
|
56
|
-
out[f.id] = false
|
|
57
|
-
} else if (f.type === 'multiselect' || f.type === 'checkbox-group') {
|
|
58
|
-
out[f.id] = [] as string[]
|
|
59
|
-
} else {
|
|
60
|
-
out[f.id] = ''
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return out
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function useDcsForm(opts: UseDcsFormOptions): UseDcsFormReturn {
|
|
67
|
-
const { definition } = opts
|
|
68
|
-
const values = reactive<FormValues>(defaultsFor(definition))
|
|
69
|
-
const errors = ref<FormErrors>({})
|
|
70
|
-
const touched = ref<Record<string, boolean>>({})
|
|
71
|
-
const submitting = ref(false)
|
|
72
|
-
const submitted = ref(false)
|
|
73
|
-
const submitError = ref<string | null>(null)
|
|
74
|
-
const currentStepIndex = ref(0)
|
|
75
|
-
|
|
76
|
-
const fields = computed(() => definition.fields)
|
|
77
|
-
const steps = computed<PortalFormStep[] | null>(() =>
|
|
78
|
-
definition.steps && definition.steps.length > 0 ? definition.steps : null,
|
|
79
|
-
)
|
|
80
|
-
const currentStep = computed<PortalFormStep | null>(() => {
|
|
81
|
-
const s = steps.value
|
|
82
|
-
return s ? (s[currentStepIndex.value] ?? null) : null
|
|
83
|
-
})
|
|
84
|
-
const currentStepFields = computed<PortalFormField[]>(() => {
|
|
85
|
-
const step = currentStep.value
|
|
86
|
-
if (!step) return definition.fields
|
|
87
|
-
const ids = new Set(step.fieldIds)
|
|
88
|
-
return definition.fields.filter((f) => ids.has(f.id))
|
|
89
|
-
})
|
|
90
|
-
const isFirstStep = computed(() => currentStepIndex.value === 0)
|
|
91
|
-
const isLastStep = computed(() => {
|
|
92
|
-
const s = steps.value
|
|
93
|
-
return !s || currentStepIndex.value >= s.length - 1
|
|
94
|
-
})
|
|
95
|
-
const visibleFields = computed(() =>
|
|
96
|
-
currentStepFields.value.filter((f) => isFieldVisible(f, values as FormValues)),
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
function setValue(id: string, value: unknown): void {
|
|
100
|
-
;(values as FormValues)[id] = value
|
|
101
|
-
if (errors.value[id]) {
|
|
102
|
-
errors.value = { ...errors.value, [id]: undefined }
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function touch(id: string): void {
|
|
107
|
-
touched.value = { ...touched.value, [id]: true }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function validateScope(scopeFieldIds?: string[]): boolean {
|
|
111
|
-
const next = validateForm(definition, values as FormValues, scopeFieldIds)
|
|
112
|
-
// Merge with existing errors but only for the validated scope so we
|
|
113
|
-
// don't wipe out errors from other steps.
|
|
114
|
-
if (scopeFieldIds) {
|
|
115
|
-
const merged = { ...errors.value }
|
|
116
|
-
for (const id of scopeFieldIds) {
|
|
117
|
-
merged[id] = next[id]
|
|
118
|
-
}
|
|
119
|
-
errors.value = merged
|
|
120
|
-
} else {
|
|
121
|
-
errors.value = next
|
|
122
|
-
}
|
|
123
|
-
return !hasErrors(errors.value)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function validateCurrentScope(): boolean {
|
|
127
|
-
const scope = currentStep.value?.fieldIds
|
|
128
|
-
return validateScope(scope)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function validateAll(): boolean {
|
|
132
|
-
return validateScope()
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function next(): boolean {
|
|
136
|
-
if (!steps.value) return false
|
|
137
|
-
if (!validateCurrentScope()) return false
|
|
138
|
-
if (currentStepIndex.value < steps.value.length - 1) {
|
|
139
|
-
currentStepIndex.value++
|
|
140
|
-
return true
|
|
141
|
-
}
|
|
142
|
-
return false
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function prev(): void {
|
|
146
|
-
if (currentStepIndex.value > 0) currentStepIndex.value--
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function reset(): void {
|
|
150
|
-
const fresh = defaultsFor(definition)
|
|
151
|
-
for (const k of Object.keys(values as object)) {
|
|
152
|
-
delete (values as FormValues)[k]
|
|
153
|
-
}
|
|
154
|
-
Object.assign(values as FormValues, fresh)
|
|
155
|
-
errors.value = {}
|
|
156
|
-
touched.value = {}
|
|
157
|
-
submitting.value = false
|
|
158
|
-
submitted.value = false
|
|
159
|
-
submitError.value = null
|
|
160
|
-
currentStepIndex.value = 0
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function collectSubmissionValues(): FormValues {
|
|
164
|
-
const out: FormValues = {}
|
|
165
|
-
for (const f of definition.fields) {
|
|
166
|
-
if (
|
|
167
|
-
f.type === 'section-heading' ||
|
|
168
|
-
f.type === 'html-block'
|
|
169
|
-
)
|
|
170
|
-
continue
|
|
171
|
-
if (!isFieldVisible(f, values as FormValues)) continue
|
|
172
|
-
out[f.id] = (values as FormValues)[f.id]
|
|
173
|
-
}
|
|
174
|
-
return out
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return {
|
|
178
|
-
values: values as FormValues,
|
|
179
|
-
errors,
|
|
180
|
-
touched,
|
|
181
|
-
submitting,
|
|
182
|
-
submitted,
|
|
183
|
-
submitError,
|
|
184
|
-
fields,
|
|
185
|
-
steps,
|
|
186
|
-
currentStepIndex,
|
|
187
|
-
currentStep,
|
|
188
|
-
currentStepFields,
|
|
189
|
-
isFirstStep,
|
|
190
|
-
isLastStep,
|
|
191
|
-
visibleFields,
|
|
192
|
-
setValue,
|
|
193
|
-
touch,
|
|
194
|
-
validateCurrentScope,
|
|
195
|
-
validateAll,
|
|
196
|
-
next,
|
|
197
|
-
prev,
|
|
198
|
-
reset,
|
|
199
|
-
collectSubmissionValues,
|
|
200
|
-
}
|
|
201
|
-
}
|
|
1
|
+
import { computed, reactive, ref, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
import type {
|
|
3
|
+
PortalFormDefinition,
|
|
4
|
+
PortalFormField,
|
|
5
|
+
PortalFormStep,
|
|
6
|
+
FormErrors,
|
|
7
|
+
FormValues,
|
|
8
|
+
} from '../types'
|
|
9
|
+
import {
|
|
10
|
+
hasErrors,
|
|
11
|
+
isFieldVisible,
|
|
12
|
+
validateForm,
|
|
13
|
+
} from './useFormValidation'
|
|
14
|
+
|
|
15
|
+
export interface UseDcsFormOptions {
|
|
16
|
+
definition: PortalFormDefinition
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseDcsFormReturn {
|
|
20
|
+
values: FormValues
|
|
21
|
+
errors: Ref<FormErrors>
|
|
22
|
+
touched: Ref<Record<string, boolean>>
|
|
23
|
+
submitting: Ref<boolean>
|
|
24
|
+
submitted: Ref<boolean>
|
|
25
|
+
submitError: Ref<string | null>
|
|
26
|
+
/** All fields, with layout-only types preserved. */
|
|
27
|
+
fields: ComputedRef<PortalFormField[]>
|
|
28
|
+
/** Steps if multi-step, else null. */
|
|
29
|
+
steps: ComputedRef<PortalFormStep[] | null>
|
|
30
|
+
currentStepIndex: Ref<number>
|
|
31
|
+
currentStep: ComputedRef<PortalFormStep | null>
|
|
32
|
+
currentStepFields: ComputedRef<PortalFormField[]>
|
|
33
|
+
isFirstStep: ComputedRef<boolean>
|
|
34
|
+
isLastStep: ComputedRef<boolean>
|
|
35
|
+
visibleFields: ComputedRef<PortalFormField[]>
|
|
36
|
+
/** Sets a field value and clears that field's error. */
|
|
37
|
+
setValue: (id: string, value: unknown) => void
|
|
38
|
+
/** Marks a field as touched + revalidates only that field's row. */
|
|
39
|
+
touch: (id: string) => void
|
|
40
|
+
/** Validates the current step (multi-step) or the entire form. */
|
|
41
|
+
validateCurrentScope: () => boolean
|
|
42
|
+
validateAll: () => boolean
|
|
43
|
+
next: () => boolean
|
|
44
|
+
prev: () => void
|
|
45
|
+
reset: () => void
|
|
46
|
+
/** Returns the values that should actually be submitted (visible only). */
|
|
47
|
+
collectSubmissionValues: () => FormValues
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function defaultsFor(def: PortalFormDefinition): FormValues {
|
|
51
|
+
const out: FormValues = {}
|
|
52
|
+
for (const f of def.fields) {
|
|
53
|
+
if (f.defaultValue !== undefined) {
|
|
54
|
+
out[f.id] = f.defaultValue as unknown
|
|
55
|
+
} else if (f.type === 'checkbox') {
|
|
56
|
+
out[f.id] = false
|
|
57
|
+
} else if (f.type === 'multiselect' || f.type === 'checkbox-group') {
|
|
58
|
+
out[f.id] = [] as string[]
|
|
59
|
+
} else {
|
|
60
|
+
out[f.id] = ''
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useDcsForm(opts: UseDcsFormOptions): UseDcsFormReturn {
|
|
67
|
+
const { definition } = opts
|
|
68
|
+
const values = reactive<FormValues>(defaultsFor(definition))
|
|
69
|
+
const errors = ref<FormErrors>({})
|
|
70
|
+
const touched = ref<Record<string, boolean>>({})
|
|
71
|
+
const submitting = ref(false)
|
|
72
|
+
const submitted = ref(false)
|
|
73
|
+
const submitError = ref<string | null>(null)
|
|
74
|
+
const currentStepIndex = ref(0)
|
|
75
|
+
|
|
76
|
+
const fields = computed(() => definition.fields)
|
|
77
|
+
const steps = computed<PortalFormStep[] | null>(() =>
|
|
78
|
+
definition.steps && definition.steps.length > 0 ? definition.steps : null,
|
|
79
|
+
)
|
|
80
|
+
const currentStep = computed<PortalFormStep | null>(() => {
|
|
81
|
+
const s = steps.value
|
|
82
|
+
return s ? (s[currentStepIndex.value] ?? null) : null
|
|
83
|
+
})
|
|
84
|
+
const currentStepFields = computed<PortalFormField[]>(() => {
|
|
85
|
+
const step = currentStep.value
|
|
86
|
+
if (!step) return definition.fields
|
|
87
|
+
const ids = new Set(step.fieldIds)
|
|
88
|
+
return definition.fields.filter((f) => ids.has(f.id))
|
|
89
|
+
})
|
|
90
|
+
const isFirstStep = computed(() => currentStepIndex.value === 0)
|
|
91
|
+
const isLastStep = computed(() => {
|
|
92
|
+
const s = steps.value
|
|
93
|
+
return !s || currentStepIndex.value >= s.length - 1
|
|
94
|
+
})
|
|
95
|
+
const visibleFields = computed(() =>
|
|
96
|
+
currentStepFields.value.filter((f) => isFieldVisible(f, values as FormValues)),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
function setValue(id: string, value: unknown): void {
|
|
100
|
+
;(values as FormValues)[id] = value
|
|
101
|
+
if (errors.value[id]) {
|
|
102
|
+
errors.value = { ...errors.value, [id]: undefined }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function touch(id: string): void {
|
|
107
|
+
touched.value = { ...touched.value, [id]: true }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateScope(scopeFieldIds?: string[]): boolean {
|
|
111
|
+
const next = validateForm(definition, values as FormValues, scopeFieldIds)
|
|
112
|
+
// Merge with existing errors but only for the validated scope so we
|
|
113
|
+
// don't wipe out errors from other steps.
|
|
114
|
+
if (scopeFieldIds) {
|
|
115
|
+
const merged = { ...errors.value }
|
|
116
|
+
for (const id of scopeFieldIds) {
|
|
117
|
+
merged[id] = next[id]
|
|
118
|
+
}
|
|
119
|
+
errors.value = merged
|
|
120
|
+
} else {
|
|
121
|
+
errors.value = next
|
|
122
|
+
}
|
|
123
|
+
return !hasErrors(errors.value)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateCurrentScope(): boolean {
|
|
127
|
+
const scope = currentStep.value?.fieldIds
|
|
128
|
+
return validateScope(scope)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateAll(): boolean {
|
|
132
|
+
return validateScope()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function next(): boolean {
|
|
136
|
+
if (!steps.value) return false
|
|
137
|
+
if (!validateCurrentScope()) return false
|
|
138
|
+
if (currentStepIndex.value < steps.value.length - 1) {
|
|
139
|
+
currentStepIndex.value++
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function prev(): void {
|
|
146
|
+
if (currentStepIndex.value > 0) currentStepIndex.value--
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function reset(): void {
|
|
150
|
+
const fresh = defaultsFor(definition)
|
|
151
|
+
for (const k of Object.keys(values as object)) {
|
|
152
|
+
delete (values as FormValues)[k]
|
|
153
|
+
}
|
|
154
|
+
Object.assign(values as FormValues, fresh)
|
|
155
|
+
errors.value = {}
|
|
156
|
+
touched.value = {}
|
|
157
|
+
submitting.value = false
|
|
158
|
+
submitted.value = false
|
|
159
|
+
submitError.value = null
|
|
160
|
+
currentStepIndex.value = 0
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function collectSubmissionValues(): FormValues {
|
|
164
|
+
const out: FormValues = {}
|
|
165
|
+
for (const f of definition.fields) {
|
|
166
|
+
if (
|
|
167
|
+
f.type === 'section-heading' ||
|
|
168
|
+
f.type === 'html-block'
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
if (!isFieldVisible(f, values as FormValues)) continue
|
|
172
|
+
out[f.id] = (values as FormValues)[f.id]
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
values: values as FormValues,
|
|
179
|
+
errors,
|
|
180
|
+
touched,
|
|
181
|
+
submitting,
|
|
182
|
+
submitted,
|
|
183
|
+
submitError,
|
|
184
|
+
fields,
|
|
185
|
+
steps,
|
|
186
|
+
currentStepIndex,
|
|
187
|
+
currentStep,
|
|
188
|
+
currentStepFields,
|
|
189
|
+
isFirstStep,
|
|
190
|
+
isLastStep,
|
|
191
|
+
visibleFields,
|
|
192
|
+
setValue,
|
|
193
|
+
touch,
|
|
194
|
+
validateCurrentScope,
|
|
195
|
+
validateAll,
|
|
196
|
+
next,
|
|
197
|
+
prev,
|
|
198
|
+
reset,
|
|
199
|
+
collectSubmissionValues,
|
|
200
|
+
}
|
|
201
|
+
}
|