@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
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DcsFormSubmitPayload,
|
|
3
|
+
DcsFormSubmitSuccess,
|
|
4
|
+
DcsFormSubmitError,
|
|
5
|
+
} from '../types'
|
|
6
|
+
|
|
7
|
+
export interface SubmitOptions {
|
|
8
|
+
apiBase: string
|
|
9
|
+
siteSlug: string
|
|
10
|
+
payload: DcsFormSubmitPayload
|
|
11
|
+
/** Number of retries on 5xx / network errors. Default 1. */
|
|
12
|
+
retries?: number
|
|
13
|
+
/** Optional fetch implementation override (tests). */
|
|
14
|
+
fetchImpl?: typeof fetch
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* POSTs a form submission to
|
|
19
|
+
* `${apiBase}/public/sites/{siteSlug}/form-submissions`.
|
|
20
|
+
*
|
|
21
|
+
* Uses JSON for plain values and `multipart/form-data` when any
|
|
22
|
+
* value is a `File` (file-upload fields).
|
|
23
|
+
*/
|
|
24
|
+
export async function submitFormValues(
|
|
25
|
+
opts: SubmitOptions,
|
|
26
|
+
): Promise<DcsFormSubmitSuccess> {
|
|
27
|
+
const { apiBase, siteSlug, payload } = opts
|
|
28
|
+
const retries = opts.retries ?? 1
|
|
29
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
30
|
+
const url = `${apiBase.replace(/\/$/, '')}/public/sites/${encodeURIComponent(
|
|
31
|
+
siteSlug,
|
|
32
|
+
)}/form-submissions`
|
|
33
|
+
|
|
34
|
+
const hasFile = Object.values(payload.values).some(
|
|
35
|
+
(v) => typeof File !== 'undefined' && v instanceof File,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
let lastError: Error | undefined
|
|
39
|
+
let lastStatus: number | undefined
|
|
40
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
41
|
+
try {
|
|
42
|
+
const init: RequestInit = hasFile
|
|
43
|
+
? { method: 'POST', body: buildFormData(payload) }
|
|
44
|
+
: {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify(payload),
|
|
48
|
+
}
|
|
49
|
+
const res = await fetchImpl(url, init)
|
|
50
|
+
lastStatus = res.status
|
|
51
|
+
if (res.ok) {
|
|
52
|
+
const body = await safeJson(res)
|
|
53
|
+
return { payload, response: body }
|
|
54
|
+
}
|
|
55
|
+
// Retry only on 5xx
|
|
56
|
+
if (res.status < 500) {
|
|
57
|
+
const body = await safeText(res)
|
|
58
|
+
const err: DcsFormSubmitError = {
|
|
59
|
+
payload,
|
|
60
|
+
status: res.status,
|
|
61
|
+
error: new Error(`Submission failed (${res.status}): ${body}`),
|
|
62
|
+
}
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
lastError = new Error(`Server error ${res.status}`)
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// If `e` is already a DcsFormSubmitError-shaped object, rethrow.
|
|
68
|
+
if (e && typeof e === 'object' && 'payload' in e && 'error' in e) {
|
|
69
|
+
throw e
|
|
70
|
+
}
|
|
71
|
+
lastError = e as Error
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw {
|
|
76
|
+
payload,
|
|
77
|
+
status: lastStatus,
|
|
78
|
+
error: lastError ?? new Error('Submission failed'),
|
|
79
|
+
} satisfies DcsFormSubmitError
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildFormData(payload: DcsFormSubmitPayload): FormData {
|
|
83
|
+
const fd = new FormData()
|
|
84
|
+
fd.append('formId', payload.formId)
|
|
85
|
+
if (payload.captchaToken) fd.append('captchaToken', payload.captchaToken)
|
|
86
|
+
for (const [key, value] of Object.entries(payload.values)) {
|
|
87
|
+
if (value === undefined || value === null) continue
|
|
88
|
+
if (value instanceof File) {
|
|
89
|
+
fd.append(`values[${key}]`, value)
|
|
90
|
+
} else if (Array.isArray(value)) {
|
|
91
|
+
for (const item of value) fd.append(`values[${key}][]`, String(item))
|
|
92
|
+
} else {
|
|
93
|
+
fd.append(`values[${key}]`, String(value))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return fd
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function safeJson(res: Response): Promise<unknown> {
|
|
100
|
+
try {
|
|
101
|
+
return await res.json()
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function safeText(res: Response): Promise<string> {
|
|
108
|
+
try {
|
|
109
|
+
return await res.text()
|
|
110
|
+
} catch {
|
|
111
|
+
return ''
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PortalFormDefinition,
|
|
3
|
+
PortalFormField,
|
|
4
|
+
FormErrors,
|
|
5
|
+
FormValues,
|
|
6
|
+
} from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns true iff `field` is currently visible given the form's
|
|
10
|
+
* other values. Hidden fields are never validated and never sent.
|
|
11
|
+
*/
|
|
12
|
+
export function isFieldVisible(
|
|
13
|
+
field: PortalFormField,
|
|
14
|
+
values: FormValues,
|
|
15
|
+
): boolean {
|
|
16
|
+
if (!field.visibleIf) return true
|
|
17
|
+
const sibling = values[field.visibleIf.fieldId]
|
|
18
|
+
return sibling === field.visibleIf.equals
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validates a single field's value. Returns an error string or
|
|
23
|
+
* `undefined` if valid. Layout-only field types (section-heading,
|
|
24
|
+
* html-block) and hidden fields never produce errors.
|
|
25
|
+
*/
|
|
26
|
+
export function validateField(
|
|
27
|
+
field: PortalFormField,
|
|
28
|
+
value: unknown,
|
|
29
|
+
): string | undefined {
|
|
30
|
+
if (
|
|
31
|
+
field.type === 'section-heading' ||
|
|
32
|
+
field.type === 'html-block' ||
|
|
33
|
+
field.type === 'hidden'
|
|
34
|
+
) {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isEmpty =
|
|
39
|
+
value === undefined ||
|
|
40
|
+
value === null ||
|
|
41
|
+
value === '' ||
|
|
42
|
+
(Array.isArray(value) && value.length === 0)
|
|
43
|
+
|
|
44
|
+
if (field.required && isEmpty) {
|
|
45
|
+
return `${field.label} is required`
|
|
46
|
+
}
|
|
47
|
+
if (isEmpty) return undefined
|
|
48
|
+
|
|
49
|
+
// Type-specific built-ins
|
|
50
|
+
if (field.type === 'email' && typeof value === 'string') {
|
|
51
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
52
|
+
return `${field.label} must be a valid email address`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const v = field.validation
|
|
57
|
+
if (!v) return undefined
|
|
58
|
+
|
|
59
|
+
if (typeof value === 'string') {
|
|
60
|
+
if (v.minLength != null && value.length < v.minLength) {
|
|
61
|
+
return `${field.label} must be at least ${v.minLength} characters`
|
|
62
|
+
}
|
|
63
|
+
if (v.maxLength != null && value.length > v.maxLength) {
|
|
64
|
+
return `${field.label} must be at most ${v.maxLength} characters`
|
|
65
|
+
}
|
|
66
|
+
if (v.regex) {
|
|
67
|
+
try {
|
|
68
|
+
if (!new RegExp(v.regex).test(value)) {
|
|
69
|
+
return `${field.label} is invalid`
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// bad regex in the definition — treat as no rule
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof value === 'number') {
|
|
78
|
+
if (v.min != null && value < v.min) {
|
|
79
|
+
return `${field.label} must be at least ${v.min}`
|
|
80
|
+
}
|
|
81
|
+
if (v.max != null && value > v.max) {
|
|
82
|
+
return `${field.label} must be at most ${v.max}`
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (field.type === 'file' && v.accept && v.accept.length > 0) {
|
|
87
|
+
const file = value as File | null
|
|
88
|
+
if (file && typeof File !== 'undefined' && file instanceof File) {
|
|
89
|
+
const accepted = v.accept.some((a) => {
|
|
90
|
+
if (a.startsWith('.')) {
|
|
91
|
+
return file.name.toLowerCase().endsWith(a.toLowerCase())
|
|
92
|
+
}
|
|
93
|
+
return file.type === a
|
|
94
|
+
})
|
|
95
|
+
if (!accepted) {
|
|
96
|
+
return `${field.label} must be one of: ${v.accept.join(', ')}`
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validates an entire form. Skips fields that are not visible.
|
|
106
|
+
* If `fieldIds` is supplied, only those fields are validated
|
|
107
|
+
* (used for per-step validation in multi-step forms).
|
|
108
|
+
*/
|
|
109
|
+
export function validateForm(
|
|
110
|
+
def: PortalFormDefinition,
|
|
111
|
+
values: FormValues,
|
|
112
|
+
fieldIds?: string[],
|
|
113
|
+
): FormErrors {
|
|
114
|
+
const errors: FormErrors = {}
|
|
115
|
+
const ids = fieldIds ? new Set(fieldIds) : null
|
|
116
|
+
for (const field of def.fields) {
|
|
117
|
+
if (ids && !ids.has(field.id)) continue
|
|
118
|
+
if (!isFieldVisible(field, values)) continue
|
|
119
|
+
const err = validateField(field, values[field.id])
|
|
120
|
+
if (err) errors[field.id] = err
|
|
121
|
+
}
|
|
122
|
+
return errors
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function hasErrors(errors: FormErrors): boolean {
|
|
126
|
+
return Object.values(errors).some((e) => !!e)
|
|
127
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { PortalFormField } from '../types'
|
|
4
|
+
import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
field: PortalFormField
|
|
8
|
+
modelValue: boolean | undefined
|
|
9
|
+
error?: string
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
'update:modelValue': [value: boolean]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const inputId = computed(() => `field-${props.field.id}`)
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
|
|
21
|
+
<template #label><span class="sr-only">{{ field.label }}</span></template>
|
|
22
|
+
<label class="dcs-form-checkbox-single" :for="inputId">
|
|
23
|
+
<input
|
|
24
|
+
:id="inputId"
|
|
25
|
+
type="checkbox"
|
|
26
|
+
:name="field.id"
|
|
27
|
+
:checked="!!modelValue"
|
|
28
|
+
:required="field.required"
|
|
29
|
+
:aria-invalid="!!error"
|
|
30
|
+
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
|
31
|
+
/>
|
|
32
|
+
<span>{{ field.label }}<span v-if="field.required" aria-hidden="true"> *</span></span>
|
|
33
|
+
</label>
|
|
34
|
+
</DcsFormFieldWrapper>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { PortalFormField } from '../types'
|
|
4
|
+
import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
field: PortalFormField
|
|
8
|
+
modelValue: string[] | undefined
|
|
9
|
+
error?: string
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
'update:modelValue': [value: string[]]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const inputId = computed(() => `field-${props.field.id}`)
|
|
17
|
+
const options = computed(() => props.field.options ?? [])
|
|
18
|
+
const current = computed(() => props.modelValue ?? [])
|
|
19
|
+
|
|
20
|
+
function toggle(value: string, checked: boolean): void {
|
|
21
|
+
const set = new Set(current.value)
|
|
22
|
+
if (checked) set.add(value)
|
|
23
|
+
else set.delete(value)
|
|
24
|
+
emit('update:modelValue', Array.from(set))
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
|
|
30
|
+
<fieldset
|
|
31
|
+
class="dcs-form-checkbox-group"
|
|
32
|
+
:aria-required="field.required"
|
|
33
|
+
:aria-invalid="!!error"
|
|
34
|
+
>
|
|
35
|
+
<legend class="sr-only">{{ field.label }}</legend>
|
|
36
|
+
<label
|
|
37
|
+
v-for="opt in options"
|
|
38
|
+
:key="opt.value"
|
|
39
|
+
class="dcs-form-checkbox"
|
|
40
|
+
>
|
|
41
|
+
<input
|
|
42
|
+
type="checkbox"
|
|
43
|
+
:name="`${field.id}[]`"
|
|
44
|
+
:value="opt.value"
|
|
45
|
+
:checked="current.includes(opt.value)"
|
|
46
|
+
@change="toggle(opt.value, ($event.target as HTMLInputElement).checked)"
|
|
47
|
+
/>
|
|
48
|
+
<span>{{ opt.label }}</span>
|
|
49
|
+
</label>
|
|
50
|
+
</fieldset>
|
|
51
|
+
</DcsFormFieldWrapper>
|
|
52
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { PortalFormField } from '../types'
|
|
4
|
+
import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
field: PortalFormField
|
|
8
|
+
modelValue: string | undefined
|
|
9
|
+
error?: string
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
'update:modelValue': [value: string]
|
|
14
|
+
blur: []
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const inputId = computed(() => `field-${props.field.id}`)
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
|
|
22
|
+
<input
|
|
23
|
+
:id="inputId"
|
|
24
|
+
class="dcs-form-input dcs-form-date"
|
|
25
|
+
type="date"
|
|
26
|
+
:name="field.id"
|
|
27
|
+
:required="field.required"
|
|
28
|
+
:aria-invalid="!!error"
|
|
29
|
+
:value="modelValue ?? ''"
|
|
30
|
+
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
|
31
|
+
@blur="emit('blur')"
|
|
32
|
+
/>
|
|
33
|
+
</DcsFormFieldWrapper>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { PortalFormField } from '../types'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
field: PortalFormField
|
|
6
|
+
error?: string
|
|
7
|
+
/** Optional id to use for the input — wrappers use it for label `for=`. */
|
|
8
|
+
inputId?: string
|
|
9
|
+
}>()
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div
|
|
14
|
+
class="dcs-form-field"
|
|
15
|
+
:class="[`dcs-form-field--${field.type}`, `dcs-form-field--width-${field.width ?? 'full'}`]"
|
|
16
|
+
:data-form-field-key="field.id"
|
|
17
|
+
>
|
|
18
|
+
<slot name="label">
|
|
19
|
+
<label
|
|
20
|
+
v-if="field.type !== 'checkbox' && field.type !== 'hidden' && field.type !== 'section-heading' && field.type !== 'html-block'"
|
|
21
|
+
:for="inputId ?? `field-${field.id}`"
|
|
22
|
+
class="dcs-form-field__label"
|
|
23
|
+
>
|
|
24
|
+
{{ field.label }}
|
|
25
|
+
<span v-if="field.required" aria-hidden="true" class="dcs-form-field__required">*</span>
|
|
26
|
+
</label>
|
|
27
|
+
</slot>
|
|
28
|
+
|
|
29
|
+
<slot />
|
|
30
|
+
|
|
31
|
+
<slot name="help">
|
|
32
|
+
<p v-if="field.helpText" class="dcs-form-field__help">{{ field.helpText }}</p>
|
|
33
|
+
</slot>
|
|
34
|
+
|
|
35
|
+
<slot name="error">
|
|
36
|
+
<p v-if="error" class="dcs-form-field__error" role="alert">{{ error }}</p>
|
|
37
|
+
</slot>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { PortalFormField } from '../types'
|
|
4
|
+
import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
field: PortalFormField
|
|
8
|
+
modelValue: File | undefined
|
|
9
|
+
error?: string
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
'update:modelValue': [value: File | undefined]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const inputId = computed(() => `field-${props.field.id}`)
|
|
17
|
+
const accept = computed(() => (props.field.validation?.accept ?? []).join(','))
|
|
18
|
+
|
|
19
|
+
function onChange(e: Event): void {
|
|
20
|
+
const target = e.target as HTMLInputElement
|
|
21
|
+
emit('update:modelValue', target.files?.[0])
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
|
|
27
|
+
<input
|
|
28
|
+
:id="inputId"
|
|
29
|
+
class="dcs-form-input dcs-form-file"
|
|
30
|
+
type="file"
|
|
31
|
+
:name="field.id"
|
|
32
|
+
:required="field.required"
|
|
33
|
+
:aria-invalid="!!error"
|
|
34
|
+
:accept="accept || undefined"
|
|
35
|
+
@change="onChange"
|
|
36
|
+
/>
|
|
37
|
+
</DcsFormFieldWrapper>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { PortalFormField } from '../types'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
field: PortalFormField
|
|
6
|
+
modelValue: string | number | undefined
|
|
7
|
+
}>()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<input
|
|
12
|
+
type="hidden"
|
|
13
|
+
:name="field.id"
|
|
14
|
+
:value="modelValue ?? (field.defaultValue as string | number | undefined) ?? ''"
|
|
15
|
+
:data-form-field-key="field.id"
|
|
16
|
+
/>
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { PortalFormField } from '../types'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
field: PortalFormField
|
|
6
|
+
}>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<!--
|
|
11
|
+
`html` is sanitized server-side per the schema; rendering with v-html
|
|
12
|
+
is intentional. Site authors must keep portal-side sanitization on.
|
|
13
|
+
-->
|
|
14
|
+
<div
|
|
15
|
+
class="dcs-form-html-block"
|
|
16
|
+
:data-form-field-key="field.id"
|
|
17
|
+
v-html="field.html ?? ''"
|
|
18
|
+
/>
|
|
19
|
+
</template>
|