@asd20/ui-next 2.23.6 → 2.24.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/CHANGELOG.md +7 -0
- package/components.js +2 -0
- package/helpers.js +7 -0
- package/package.json +1 -1
- package/src/components/molecules/Asd20PublicFormField/index.vue +215 -0
- package/src/components/organisms/Asd20PublicFormModal/index.vue +486 -0
- package/src/helpers/publicFormDefinition.js +236 -0
- package/src/helpers/publicForms.js +66 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
# [2.24.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.23.6...ui-next-v2.24.0) (2026-09-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add generic public form components ([12e7f88](https://github.com/academydistrict20/asd20-ui-next/commit/12e7f8812980744c46786b9effefe4cc15b5420a))
|
|
9
|
+
|
|
3
10
|
## [2.23.6](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.23.5...ui-next-v2.23.6) (2026-09-16)
|
|
4
11
|
|
|
5
12
|
|
package/components.js
CHANGED
|
@@ -50,6 +50,7 @@ export { default as Asd20Modal } from './src/components/molecules/Asd20Modal/ind
|
|
|
50
50
|
export { default as Asd20Notification } from './src/components/molecules/Asd20Notification/index.vue'
|
|
51
51
|
export { default as Asd20Picker } from './src/components/molecules/Asd20Picker/index.vue'
|
|
52
52
|
export { default as Asd20Progress } from './src/components/molecules/Asd20Progress/index.vue'
|
|
53
|
+
export { default as Asd20PublicFormField } from './src/components/molecules/Asd20PublicFormField/index.vue'
|
|
53
54
|
export { default as Asd20SearchField } from './src/components/molecules/Asd20SearchField/index.vue'
|
|
54
55
|
export { default as Asd20Share } from './src/components/molecules/Asd20Share/index.vue'
|
|
55
56
|
export { default as Asd20SliderInput } from './src/components/molecules/Asd20SliderInput/index.vue'
|
|
@@ -93,6 +94,7 @@ export { default as Asd20PageHeader } from './src/components/organisms/Asd20Page
|
|
|
93
94
|
export { default as Asd20PeopleList } from './src/components/organisms/Asd20PeopleList/index.vue'
|
|
94
95
|
export { default as Asd20PersonModal } from './src/components/organisms/Asd20PersonModal/index.vue'
|
|
95
96
|
export { default as Asd20PrimaryHeader } from './src/components/organisms/Asd20PrimaryHeader/index.vue'
|
|
97
|
+
export { default as Asd20PublicFormModal } from './src/components/organisms/Asd20PublicFormModal/index.vue'
|
|
96
98
|
export { default as Asd20QuicklinksMenu } from './src/components/organisms/Asd20QuicklinksMenu/index.vue'
|
|
97
99
|
export { default as Asd20SchoolHomepageHeader } from './src/components/organisms/Asd20SchoolHomepageHeader/index.vue'
|
|
98
100
|
export { default as Asd20SchoolHomepageVideoHeader } from './src/components/organisms/Asd20SchoolHomepageVideoHeader/index.vue'
|
package/helpers.js
CHANGED
|
@@ -36,6 +36,13 @@ export { default as organizationItemsByCategory } from './src/helpers/organizati
|
|
|
36
36
|
export { default as organizationSubtitle } from './src/helpers/organizationSubtitle.js'
|
|
37
37
|
export { default as organizationTitle } from './src/helpers/organizationTitle.js'
|
|
38
38
|
export { default as preventWidows } from './src/helpers/prevent-widows.js'
|
|
39
|
+
export {
|
|
40
|
+
fetchPublicFormDefinition,
|
|
41
|
+
normalizeFormId,
|
|
42
|
+
resolvePublicFormEndpoints,
|
|
43
|
+
resolvePublicFormsEndpoint,
|
|
44
|
+
submitPublicForm,
|
|
45
|
+
} from './src/helpers/publicForms.js'
|
|
39
46
|
export { default as queryAiSite } from './src/helpers/queryAiSite.js'
|
|
40
47
|
export { default as queryDepartments } from './src/helpers/queryDepartments.js'
|
|
41
48
|
export { default as queryEvents } from './src/helpers/queryEvents.js'
|
package/package.json
CHANGED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="asd20-public-form-field">
|
|
3
|
+
<asd20-text-area-input
|
|
4
|
+
v-if="field.type === 'textarea'"
|
|
5
|
+
:id="inputId"
|
|
6
|
+
:model-value="modelValue"
|
|
7
|
+
:label="displayLabel"
|
|
8
|
+
:required="field.required"
|
|
9
|
+
:minlength="field.minLength"
|
|
10
|
+
:maxlength="field.maxLength"
|
|
11
|
+
:autocomplete="field.autocomplete || undefined"
|
|
12
|
+
:aria-describedby="describedBy"
|
|
13
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
14
|
+
@update:modelValue="$emit('update:modelValue', $event)"
|
|
15
|
+
/>
|
|
16
|
+
|
|
17
|
+
<asd20-text-input
|
|
18
|
+
v-else-if="textInputTypes.includes(field.type)"
|
|
19
|
+
:id="inputId"
|
|
20
|
+
:model-value="modelValue"
|
|
21
|
+
:label="displayLabel"
|
|
22
|
+
:type="field.type"
|
|
23
|
+
:required="field.required"
|
|
24
|
+
:minlength="field.minLength"
|
|
25
|
+
:maxlength="field.maxLength"
|
|
26
|
+
:autocomplete="field.autocomplete || undefined"
|
|
27
|
+
:aria-describedby="describedBy"
|
|
28
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
29
|
+
@update:modelValue="$emit('update:modelValue', $event)"
|
|
30
|
+
/>
|
|
31
|
+
|
|
32
|
+
<div
|
|
33
|
+
v-else-if="field.type === 'select'"
|
|
34
|
+
class="asd20-public-form-field__select"
|
|
35
|
+
>
|
|
36
|
+
<label :for="inputId">{{ displayLabel }}</label>
|
|
37
|
+
<select
|
|
38
|
+
:id="inputId"
|
|
39
|
+
:value="modelValue"
|
|
40
|
+
:required="field.required"
|
|
41
|
+
:aria-describedby="describedBy"
|
|
42
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
43
|
+
@change="$emit('update:modelValue', $event.target.value)"
|
|
44
|
+
>
|
|
45
|
+
<option value="">
|
|
46
|
+
Please select
|
|
47
|
+
</option>
|
|
48
|
+
<option
|
|
49
|
+
v-for="option in field.options"
|
|
50
|
+
:key="option.value"
|
|
51
|
+
:value="option.value"
|
|
52
|
+
>
|
|
53
|
+
{{ option.label }}
|
|
54
|
+
</option>
|
|
55
|
+
</select>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<fieldset
|
|
59
|
+
v-else-if="field.type === 'radio'"
|
|
60
|
+
class="asd20-public-form-field__choices"
|
|
61
|
+
:aria-describedby="describedBy"
|
|
62
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
63
|
+
>
|
|
64
|
+
<legend>{{ displayLabel }}</legend>
|
|
65
|
+
<label
|
|
66
|
+
v-for="option in field.options"
|
|
67
|
+
:key="option.value"
|
|
68
|
+
:for="`${inputId}-${option.value}`"
|
|
69
|
+
>
|
|
70
|
+
<input
|
|
71
|
+
:id="`${inputId}-${option.value}`"
|
|
72
|
+
:name="inputId"
|
|
73
|
+
type="radio"
|
|
74
|
+
:value="option.value"
|
|
75
|
+
:checked="modelValue === option.value"
|
|
76
|
+
:required="field.required"
|
|
77
|
+
@change="$emit('update:modelValue', option.value)"
|
|
78
|
+
/>
|
|
79
|
+
<span>{{ option.label }}</span>
|
|
80
|
+
</label>
|
|
81
|
+
</fieldset>
|
|
82
|
+
|
|
83
|
+
<asd20-checkbox
|
|
84
|
+
v-else-if="field.type === 'checkbox'"
|
|
85
|
+
:id="inputId"
|
|
86
|
+
:model-value="modelValue === true"
|
|
87
|
+
:label="displayLabel"
|
|
88
|
+
checkbox
|
|
89
|
+
:required="field.required || undefined"
|
|
90
|
+
:aria-describedby="describedBy"
|
|
91
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
92
|
+
@update:modelValue="$emit('update:modelValue', $event)"
|
|
93
|
+
/>
|
|
94
|
+
|
|
95
|
+
<p
|
|
96
|
+
v-if="field.helpText"
|
|
97
|
+
:id="helpId"
|
|
98
|
+
class="asd20-public-form-field__help"
|
|
99
|
+
>
|
|
100
|
+
{{ field.helpText }}
|
|
101
|
+
</p>
|
|
102
|
+
<ul
|
|
103
|
+
v-if="hasErrors"
|
|
104
|
+
:id="errorId"
|
|
105
|
+
class="asd20-public-form-field__errors"
|
|
106
|
+
>
|
|
107
|
+
<li
|
|
108
|
+
v-for="error in errors"
|
|
109
|
+
:key="error"
|
|
110
|
+
>
|
|
111
|
+
{{ error }}
|
|
112
|
+
</li>
|
|
113
|
+
</ul>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<script>
|
|
118
|
+
import Asd20Checkbox from '../../atoms/Asd20Checkbox'
|
|
119
|
+
import Asd20TextAreaInput from '../Asd20TextAreaInput'
|
|
120
|
+
import Asd20TextInput from '../Asd20TextInput'
|
|
121
|
+
|
|
122
|
+
export default {
|
|
123
|
+
name: 'Asd20PublicFormField',
|
|
124
|
+
components: { Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
|
|
125
|
+
props: {
|
|
126
|
+
field: { type: Object, required: true },
|
|
127
|
+
modelValue: { type: [String, Boolean], default: '' },
|
|
128
|
+
errors: { type: Array, default: () => [] },
|
|
129
|
+
inputId: { type: String, required: true },
|
|
130
|
+
},
|
|
131
|
+
emits: ['update:modelValue'],
|
|
132
|
+
data() {
|
|
133
|
+
return {
|
|
134
|
+
textInputTypes: ['text', 'email', 'tel', 'date'],
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
computed: {
|
|
138
|
+
displayLabel() {
|
|
139
|
+
return this.field.required
|
|
140
|
+
? `${this.field.label} (required)`
|
|
141
|
+
: this.field.label
|
|
142
|
+
},
|
|
143
|
+
helpId() {
|
|
144
|
+
return `${this.inputId}-help`
|
|
145
|
+
},
|
|
146
|
+
errorId() {
|
|
147
|
+
return `${this.inputId}-error`
|
|
148
|
+
},
|
|
149
|
+
hasErrors() {
|
|
150
|
+
return this.errors.length > 0
|
|
151
|
+
},
|
|
152
|
+
describedBy() {
|
|
153
|
+
return (
|
|
154
|
+
[
|
|
155
|
+
this.field.helpText ? this.helpId : '',
|
|
156
|
+
this.hasErrors ? this.errorId : '',
|
|
157
|
+
]
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.join(' ') || undefined
|
|
160
|
+
)
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<style lang="scss" scoped>
|
|
167
|
+
@use '../../../design/component-stack' as *;
|
|
168
|
+
|
|
169
|
+
.asd20-public-form-field {
|
|
170
|
+
margin-bottom: space(0.5);
|
|
171
|
+
|
|
172
|
+
&__select label,
|
|
173
|
+
legend {
|
|
174
|
+
color: var(--website-card__reverse-background-color);
|
|
175
|
+
display: block;
|
|
176
|
+
margin-bottom: space(0.125);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
select {
|
|
180
|
+
background: white;
|
|
181
|
+
border: 2px solid var(--color__accent);
|
|
182
|
+
border-radius: var(--website-shape__radius-s);
|
|
183
|
+
box-sizing: border-box;
|
|
184
|
+
color: var(--website-card__reverse-background-color);
|
|
185
|
+
font: inherit;
|
|
186
|
+
padding: space(0.25);
|
|
187
|
+
width: 100%;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
fieldset {
|
|
191
|
+
border: 0;
|
|
192
|
+
margin: 0;
|
|
193
|
+
padding: 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
&__choices label {
|
|
197
|
+
align-items: center;
|
|
198
|
+
display: flex;
|
|
199
|
+
gap: space(0.25);
|
|
200
|
+
margin: space(0.125) 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
&__help,
|
|
204
|
+
&__errors {
|
|
205
|
+
color: var(--website-card__reverse-background-color);
|
|
206
|
+
font-size: 0.75rem;
|
|
207
|
+
margin: space(0.125) 0 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
&__errors {
|
|
211
|
+
color: asd20-swatch('emergency', -1);
|
|
212
|
+
padding-left: space(1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
</style>
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<asd20-modal
|
|
3
|
+
:open="open"
|
|
4
|
+
:title="modalTitle"
|
|
5
|
+
icon="contact"
|
|
6
|
+
dismissable
|
|
7
|
+
class="asd20-public-form-modal"
|
|
8
|
+
@dismiss="dismiss"
|
|
9
|
+
>
|
|
10
|
+
<asd20-viewport scrollable>
|
|
11
|
+
<div
|
|
12
|
+
v-if="loadingDefinition"
|
|
13
|
+
class="asd20-public-form-modal__state"
|
|
14
|
+
role="status"
|
|
15
|
+
aria-live="polite"
|
|
16
|
+
>
|
|
17
|
+
<asd20-spinner size="sm" />
|
|
18
|
+
<span>Loading form…</span>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div
|
|
22
|
+
v-else-if="definitionError"
|
|
23
|
+
ref="definitionError"
|
|
24
|
+
class="asd20-public-form-modal__feedback"
|
|
25
|
+
role="alert"
|
|
26
|
+
tabindex="-1"
|
|
27
|
+
>
|
|
28
|
+
{{ definitionError }}
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div
|
|
32
|
+
v-else-if="submissionSuccess"
|
|
33
|
+
class="asd20-public-form-modal__success"
|
|
34
|
+
role="status"
|
|
35
|
+
aria-live="polite"
|
|
36
|
+
>
|
|
37
|
+
{{ formDefinition.successMessage }}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<form
|
|
41
|
+
v-else-if="formDefinition"
|
|
42
|
+
novalidate
|
|
43
|
+
@submit.prevent="validateBeforeCaptcha"
|
|
44
|
+
>
|
|
45
|
+
<p
|
|
46
|
+
v-if="formDefinition.introduction"
|
|
47
|
+
class="asd20-public-form-modal__introduction"
|
|
48
|
+
>
|
|
49
|
+
{{ formDefinition.introduction }}
|
|
50
|
+
</p>
|
|
51
|
+
|
|
52
|
+
<div
|
|
53
|
+
v-if="errorEntries.length"
|
|
54
|
+
ref="errorSummary"
|
|
55
|
+
class="asd20-public-form-modal__error-summary"
|
|
56
|
+
role="alert"
|
|
57
|
+
tabindex="-1"
|
|
58
|
+
>
|
|
59
|
+
<p>Please correct the following fields:</p>
|
|
60
|
+
<ul>
|
|
61
|
+
<li
|
|
62
|
+
v-for="entry in errorEntries"
|
|
63
|
+
:key="entry.name"
|
|
64
|
+
>
|
|
65
|
+
<a
|
|
66
|
+
:href="`#${fieldId(entry.name)}`"
|
|
67
|
+
@click.prevent="focusField(entry.name)"
|
|
68
|
+
>{{ entry.messages[0] }}</a>
|
|
69
|
+
</li>
|
|
70
|
+
</ul>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<asd20-public-form-field
|
|
74
|
+
v-for="field in formDefinition.fields"
|
|
75
|
+
:key="field.name"
|
|
76
|
+
:field="field"
|
|
77
|
+
:input-id="fieldId(field.name)"
|
|
78
|
+
:model-value="values[field.name]"
|
|
79
|
+
:errors="validationErrors[field.name] || []"
|
|
80
|
+
@update:modelValue="updateValue(field.name, $event)"
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<div
|
|
84
|
+
class="asd20-public-form-modal__honeypot"
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
>
|
|
87
|
+
<label :for="honeypotId">Website</label>
|
|
88
|
+
<input
|
|
89
|
+
:id="honeypotId"
|
|
90
|
+
v-model="honeypot"
|
|
91
|
+
type="text"
|
|
92
|
+
name="website"
|
|
93
|
+
tabindex="-1"
|
|
94
|
+
autocomplete="off"
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div
|
|
99
|
+
v-if="hasFallbackContact"
|
|
100
|
+
class="asd20-public-form-modal__fallback"
|
|
101
|
+
>
|
|
102
|
+
<p>
|
|
103
|
+
If you cannot submit this form, use one of these contact options:
|
|
104
|
+
</p>
|
|
105
|
+
<a
|
|
106
|
+
v-if="formDefinition.fallbackContact.email"
|
|
107
|
+
:href="`mailto:${formDefinition.fallbackContact.email}`"
|
|
108
|
+
>{{ formDefinition.fallbackContact.email }}</a>
|
|
109
|
+
<a
|
|
110
|
+
v-if="formDefinition.fallbackContact.phone"
|
|
111
|
+
:href="fallbackPhoneHref"
|
|
112
|
+
>{{ formDefinition.fallbackContact.phone }}</a>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div
|
|
116
|
+
class="asd20-public-form-modal__submit"
|
|
117
|
+
aria-live="polite"
|
|
118
|
+
>
|
|
119
|
+
<template v-if="!sending">
|
|
120
|
+
<recaptcha
|
|
121
|
+
v-if="captchaEnabled"
|
|
122
|
+
:sitekey="captchaSitekey"
|
|
123
|
+
:load-recaptcha-script="loadRecaptchaScript"
|
|
124
|
+
size="invisible"
|
|
125
|
+
@verify="captchaVerified"
|
|
126
|
+
@error="captchaFailed"
|
|
127
|
+
@expired="captchaExpired"
|
|
128
|
+
>
|
|
129
|
+
<asd20-button
|
|
130
|
+
:label="formDefinition.submitLabel"
|
|
131
|
+
horizontal
|
|
132
|
+
centered
|
|
133
|
+
bordered
|
|
134
|
+
@click="validateBeforeCaptcha"
|
|
135
|
+
/>
|
|
136
|
+
</recaptcha>
|
|
137
|
+
<asd20-button
|
|
138
|
+
v-else
|
|
139
|
+
:label="formDefinition.submitLabel"
|
|
140
|
+
horizontal
|
|
141
|
+
centered
|
|
142
|
+
bordered
|
|
143
|
+
@click="submitForm('')"
|
|
144
|
+
/>
|
|
145
|
+
</template>
|
|
146
|
+
<asd20-spinner
|
|
147
|
+
v-else
|
|
148
|
+
size="sm"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div
|
|
153
|
+
v-if="feedbackMessage"
|
|
154
|
+
ref="feedback"
|
|
155
|
+
class="asd20-public-form-modal__feedback"
|
|
156
|
+
:role="feedbackRole"
|
|
157
|
+
tabindex="-1"
|
|
158
|
+
>
|
|
159
|
+
{{ feedbackMessage }}
|
|
160
|
+
</div>
|
|
161
|
+
</form>
|
|
162
|
+
</asd20-viewport>
|
|
163
|
+
</asd20-modal>
|
|
164
|
+
</template>
|
|
165
|
+
|
|
166
|
+
<script>
|
|
167
|
+
import Asd20Button from '../../atoms/Asd20Button'
|
|
168
|
+
import Asd20Spinner from '../../atoms/Asd20Spinner'
|
|
169
|
+
import Asd20Viewport from '../../atoms/Asd20Viewport'
|
|
170
|
+
import Asd20Modal from '../../molecules/Asd20Modal'
|
|
171
|
+
import Asd20PublicFormField from '../../molecules/Asd20PublicFormField'
|
|
172
|
+
import Recaptcha from '../../utils/Recaptcha'
|
|
173
|
+
import {
|
|
174
|
+
createPublicFormValues,
|
|
175
|
+
normalizePublicFormDefinition,
|
|
176
|
+
validatePublicFormValues,
|
|
177
|
+
} from '../../../helpers/publicFormDefinition'
|
|
178
|
+
import {
|
|
179
|
+
fetchPublicFormDefinition,
|
|
180
|
+
normalizeFormId,
|
|
181
|
+
submitPublicForm,
|
|
182
|
+
} from '../../../helpers/publicForms'
|
|
183
|
+
import createComponentInstanceId from '../../../utils/createComponentInstanceId'
|
|
184
|
+
|
|
185
|
+
const DEFAULT_CAPTCHA_SITEKEY = '6LfidKoUAAAAAFqr3QEbia3jIkecsZyxBYlMvWrX'
|
|
186
|
+
|
|
187
|
+
export default {
|
|
188
|
+
name: 'Asd20PublicFormModal',
|
|
189
|
+
components: {
|
|
190
|
+
Asd20Button,
|
|
191
|
+
Asd20Modal,
|
|
192
|
+
Asd20PublicFormField,
|
|
193
|
+
Asd20Spinner,
|
|
194
|
+
Asd20Viewport,
|
|
195
|
+
Recaptcha,
|
|
196
|
+
},
|
|
197
|
+
props: {
|
|
198
|
+
open: { type: Boolean, default: false },
|
|
199
|
+
formId: { type: String, required: true },
|
|
200
|
+
definition: { type: Object, default: null },
|
|
201
|
+
endpoint: { type: String, default: '' },
|
|
202
|
+
functionsEndpoint: { type: String, default: '' },
|
|
203
|
+
captchaEnabled: { type: Boolean, default: true },
|
|
204
|
+
captchaSitekey: { type: String, default: DEFAULT_CAPTCHA_SITEKEY },
|
|
205
|
+
loadRecaptchaScript: { type: Boolean, default: true },
|
|
206
|
+
schemaClient: { type: Function, default: null },
|
|
207
|
+
submissionClient: { type: Function, default: null },
|
|
208
|
+
},
|
|
209
|
+
emits: ['dismiss', 'submitted', 'update:open'],
|
|
210
|
+
data() {
|
|
211
|
+
const instanceId = createComponentInstanceId('asd20-public-form')
|
|
212
|
+
return {
|
|
213
|
+
instanceId,
|
|
214
|
+
honeypotId: `${instanceId}-website`,
|
|
215
|
+
formDefinition: null,
|
|
216
|
+
values: {},
|
|
217
|
+
honeypot: '',
|
|
218
|
+
validationErrors: {},
|
|
219
|
+
validationAttempted: false,
|
|
220
|
+
loadingDefinition: false,
|
|
221
|
+
definitionError: '',
|
|
222
|
+
feedbackMessage: '',
|
|
223
|
+
feedbackRole: 'alert',
|
|
224
|
+
sending: false,
|
|
225
|
+
submissionSuccess: false,
|
|
226
|
+
loadSequence: 0,
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
computed: {
|
|
230
|
+
modalTitle() {
|
|
231
|
+
return this.formDefinition?.title || 'Public Form'
|
|
232
|
+
},
|
|
233
|
+
errorEntries() {
|
|
234
|
+
if (!this.formDefinition) return []
|
|
235
|
+
return this.formDefinition.fields
|
|
236
|
+
.filter(field => this.validationErrors[field.name]?.length)
|
|
237
|
+
.map(field => ({
|
|
238
|
+
name: field.name,
|
|
239
|
+
messages: this.validationErrors[field.name],
|
|
240
|
+
}))
|
|
241
|
+
},
|
|
242
|
+
hasFallbackContact() {
|
|
243
|
+
const fallback = this.formDefinition?.fallbackContact
|
|
244
|
+
return Boolean(fallback?.email || fallback?.phone)
|
|
245
|
+
},
|
|
246
|
+
fallbackPhoneHref() {
|
|
247
|
+
const phone = this.formDefinition?.fallbackContact?.phone || ''
|
|
248
|
+
return `tel:${phone.replace(/[^+\d]/g, '')}`
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
watch: {
|
|
252
|
+
open: {
|
|
253
|
+
immediate: true,
|
|
254
|
+
handler(value) {
|
|
255
|
+
if (value) this.prepareForm()
|
|
256
|
+
else this.loadSequence += 1
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
definition: {
|
|
260
|
+
deep: true,
|
|
261
|
+
handler() {
|
|
262
|
+
if (this.open) this.prepareForm()
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
formId() {
|
|
266
|
+
if (this.open) this.prepareForm()
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
methods: {
|
|
270
|
+
resolveRuntimeConfig() {
|
|
271
|
+
return (
|
|
272
|
+
this.$config ||
|
|
273
|
+
this.$?.appContext?.config?.globalProperties?.$config ||
|
|
274
|
+
{}
|
|
275
|
+
)
|
|
276
|
+
},
|
|
277
|
+
clientOptions() {
|
|
278
|
+
return {
|
|
279
|
+
endpoint: this.endpoint,
|
|
280
|
+
functionsEndpoint: this.functionsEndpoint,
|
|
281
|
+
runtimeConfig: this.resolveRuntimeConfig(),
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
async prepareForm() {
|
|
285
|
+
const sequence = ++this.loadSequence
|
|
286
|
+
this.loadingDefinition = true
|
|
287
|
+
this.definitionError = ''
|
|
288
|
+
this.feedbackMessage = ''
|
|
289
|
+
this.submissionSuccess = false
|
|
290
|
+
this.validationErrors = {}
|
|
291
|
+
this.validationAttempted = false
|
|
292
|
+
this.honeypot = ''
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const expectedId = normalizeFormId(this.formId)
|
|
296
|
+
const rawDefinition = this.definition
|
|
297
|
+
? this.definition
|
|
298
|
+
: await (this.schemaClient || fetchPublicFormDefinition)(
|
|
299
|
+
expectedId,
|
|
300
|
+
this.clientOptions()
|
|
301
|
+
)
|
|
302
|
+
const normalized = normalizePublicFormDefinition(rawDefinition)
|
|
303
|
+
if (normalized.id !== expectedId)
|
|
304
|
+
throw new Error(
|
|
305
|
+
'The public form definition does not match the request.'
|
|
306
|
+
)
|
|
307
|
+
if (sequence !== this.loadSequence) return
|
|
308
|
+
|
|
309
|
+
this.formDefinition = normalized
|
|
310
|
+
this.values = createPublicFormValues(normalized)
|
|
311
|
+
} catch (_error) {
|
|
312
|
+
if (sequence !== this.loadSequence) return
|
|
313
|
+
this.formDefinition = null
|
|
314
|
+
this.definitionError =
|
|
315
|
+
'This form could not be loaded. Please try again later or use another contact option on this page.'
|
|
316
|
+
this.$nextTick(() => this.$refs.definitionError?.focus?.())
|
|
317
|
+
} finally {
|
|
318
|
+
if (sequence === this.loadSequence) this.loadingDefinition = false
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
fieldId(name) {
|
|
322
|
+
return `${this.instanceId}-${name}`
|
|
323
|
+
},
|
|
324
|
+
updateValue(name, value) {
|
|
325
|
+
this.values[name] = value
|
|
326
|
+
if (this.validationAttempted) this.validate(false)
|
|
327
|
+
},
|
|
328
|
+
validate(focusSummary = true) {
|
|
329
|
+
if (!this.formDefinition) return false
|
|
330
|
+
const result = validatePublicFormValues(this.formDefinition, this.values)
|
|
331
|
+
this.values = result.values
|
|
332
|
+
this.validationErrors = result.errors
|
|
333
|
+
this.validationAttempted = true
|
|
334
|
+
|
|
335
|
+
if (!result.valid && focusSummary) {
|
|
336
|
+
this.$nextTick(() => this.$refs.errorSummary?.focus?.())
|
|
337
|
+
}
|
|
338
|
+
return result.valid
|
|
339
|
+
},
|
|
340
|
+
validateBeforeCaptcha(event) {
|
|
341
|
+
if (this.sending || !this.validate()) {
|
|
342
|
+
event?.preventDefault?.()
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
return true
|
|
346
|
+
},
|
|
347
|
+
focusField(name) {
|
|
348
|
+
document.getElementById(this.fieldId(name))?.focus?.()
|
|
349
|
+
},
|
|
350
|
+
captchaVerified(token) {
|
|
351
|
+
if (token) this.submitForm(token)
|
|
352
|
+
},
|
|
353
|
+
captchaFailed() {
|
|
354
|
+
this.showFeedback(
|
|
355
|
+
'The verification service could not start. Please try again or use the contact options below.'
|
|
356
|
+
)
|
|
357
|
+
},
|
|
358
|
+
captchaExpired() {
|
|
359
|
+
this.showFeedback(
|
|
360
|
+
'The verification expired. Please submit the form again.'
|
|
361
|
+
)
|
|
362
|
+
},
|
|
363
|
+
failureMessage(error) {
|
|
364
|
+
const status = Number(error?.response?.status || 0)
|
|
365
|
+
const serverMessage = error?.response?.data?.error
|
|
366
|
+
if (status === 400 && typeof serverMessage === 'string')
|
|
367
|
+
return serverMessage
|
|
368
|
+
if (status === 403)
|
|
369
|
+
return 'The verification was not accepted. Please try again.'
|
|
370
|
+
if (status === 429) {
|
|
371
|
+
return 'Too many submissions were received recently. Please wait and try again, or use the contact options below.'
|
|
372
|
+
}
|
|
373
|
+
if (status === 502) {
|
|
374
|
+
return 'The form could not be delivered right now. Please try again later or use the contact options below.'
|
|
375
|
+
}
|
|
376
|
+
if (status === 503) {
|
|
377
|
+
return 'This form is temporarily unavailable. Please use the contact options below.'
|
|
378
|
+
}
|
|
379
|
+
return 'Something went wrong while submitting the form. Please try again later or use the contact options below.'
|
|
380
|
+
},
|
|
381
|
+
showFeedback(message, role = 'alert') {
|
|
382
|
+
this.feedbackMessage = message
|
|
383
|
+
this.feedbackRole = role
|
|
384
|
+
this.$nextTick(() => this.$refs.feedback?.focus?.())
|
|
385
|
+
},
|
|
386
|
+
async submitForm(captchaToken = '') {
|
|
387
|
+
if (this.sending || !this.validate()) return
|
|
388
|
+
if (this.captchaEnabled && !captchaToken) {
|
|
389
|
+
this.showFeedback('Verification is required before submitting this form.')
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
this.sending = true
|
|
394
|
+
this.feedbackMessage = ''
|
|
395
|
+
try {
|
|
396
|
+
const payload = {
|
|
397
|
+
...this.values,
|
|
398
|
+
website: this.honeypot,
|
|
399
|
+
captchaToken,
|
|
400
|
+
}
|
|
401
|
+
await (this.submissionClient || submitPublicForm)(
|
|
402
|
+
this.formDefinition.id,
|
|
403
|
+
payload,
|
|
404
|
+
this.clientOptions()
|
|
405
|
+
)
|
|
406
|
+
this.submissionSuccess = true
|
|
407
|
+
this.$emit('submitted', {
|
|
408
|
+
formId: this.formDefinition.id,
|
|
409
|
+
definitionVersion: this.formDefinition.version,
|
|
410
|
+
})
|
|
411
|
+
} catch (error) {
|
|
412
|
+
this.showFeedback(this.failureMessage(error))
|
|
413
|
+
} finally {
|
|
414
|
+
this.sending = false
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
dismiss() {
|
|
418
|
+
this.$emit('dismiss')
|
|
419
|
+
this.$emit('update:open', false)
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
</script>
|
|
424
|
+
|
|
425
|
+
<style lang="scss" scoped>
|
|
426
|
+
@use '../../../design/component-stack' as *;
|
|
427
|
+
|
|
428
|
+
.asd20-public-form-modal {
|
|
429
|
+
& :deep(.asd20-modal__content .asd20-viewport) {
|
|
430
|
+
padding: space(1);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
&__state,
|
|
434
|
+
&__submit {
|
|
435
|
+
align-items: center;
|
|
436
|
+
display: flex;
|
|
437
|
+
gap: space(0.25);
|
|
438
|
+
justify-content: center;
|
|
439
|
+
min-height: space(2);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
&__error-summary,
|
|
443
|
+
&__feedback,
|
|
444
|
+
&__success {
|
|
445
|
+
font-weight: 600;
|
|
446
|
+
margin: space(0.5) 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
&__error-summary {
|
|
450
|
+
border: 2px solid asd20-swatch('emergency', -1);
|
|
451
|
+
padding: space(0.5);
|
|
452
|
+
|
|
453
|
+
p {
|
|
454
|
+
margin-top: 0;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
&__success {
|
|
459
|
+
font-size: 1.1rem;
|
|
460
|
+
text-align: center;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
&__fallback {
|
|
464
|
+
display: flex;
|
|
465
|
+
flex-direction: column;
|
|
466
|
+
margin: space(0.5) 0;
|
|
467
|
+
|
|
468
|
+
p {
|
|
469
|
+
margin-bottom: space(0.125);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
&__feedback {
|
|
474
|
+
text-align: center;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
&__honeypot {
|
|
478
|
+
clip: rect(0, 0, 0, 0);
|
|
479
|
+
height: 1px;
|
|
480
|
+
margin: -1px;
|
|
481
|
+
overflow: hidden;
|
|
482
|
+
position: absolute;
|
|
483
|
+
width: 1px;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
</style>
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
export const SUPPORTED_PUBLIC_FORM_FIELD_TYPES = Object.freeze([
|
|
2
|
+
'text',
|
|
3
|
+
'email',
|
|
4
|
+
'tel',
|
|
5
|
+
'date',
|
|
6
|
+
'textarea',
|
|
7
|
+
'select',
|
|
8
|
+
'radio',
|
|
9
|
+
'checkbox',
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
const FIELD_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
13
|
+
const FORM_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
14
|
+
const OPTION_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
|
|
15
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
16
|
+
const MAX_FIELDS = 50
|
|
17
|
+
const MAX_OPTIONS = 100
|
|
18
|
+
const MAX_TEXT_LENGTH = 10000
|
|
19
|
+
|
|
20
|
+
function boundedText(value, label, maximum, required = false) {
|
|
21
|
+
if (value == null && !required) return ''
|
|
22
|
+
if (typeof value !== 'string') throw new Error(`${label} must be text.`)
|
|
23
|
+
const normalized = value.trim()
|
|
24
|
+
if (required && !normalized) throw new Error(`${label} is required.`)
|
|
25
|
+
if (normalized.length > maximum) throw new Error(`${label} is too long.`)
|
|
26
|
+
return normalized
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function optionalLength(value, label) {
|
|
30
|
+
if (value == null) return undefined
|
|
31
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_TEXT_LENGTH) {
|
|
32
|
+
throw new Error(`${label} is invalid.`)
|
|
33
|
+
}
|
|
34
|
+
return value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeOptions(field) {
|
|
38
|
+
if (!['select', 'radio'].includes(field.type)) return undefined
|
|
39
|
+
if (
|
|
40
|
+
!Array.isArray(field.options) ||
|
|
41
|
+
!field.options.length ||
|
|
42
|
+
field.options.length > MAX_OPTIONS
|
|
43
|
+
) {
|
|
44
|
+
throw new Error(`${field.label} must have approved choices.`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const seen = new Set()
|
|
48
|
+
return field.options.map(option => {
|
|
49
|
+
const label = boundedText(option?.label, 'Option label', 200, true)
|
|
50
|
+
const value = boundedText(option?.value, 'Option value', 100, true)
|
|
51
|
+
if (!OPTION_VALUE_PATTERN.test(value))
|
|
52
|
+
throw new Error(`${field.label} has an invalid choice ID.`)
|
|
53
|
+
if (seen.has(value))
|
|
54
|
+
throw new Error(`${field.label} has duplicate choices.`)
|
|
55
|
+
seen.add(value)
|
|
56
|
+
return { label, value }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeField(field) {
|
|
61
|
+
if (!field || typeof field !== 'object' || Array.isArray(field)) {
|
|
62
|
+
throw new Error('The public form contains an invalid field.')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const name = boundedText(field.name, 'Field name', 64, true)
|
|
66
|
+
if (!FIELD_NAME_PATTERN.test(name))
|
|
67
|
+
throw new Error('The public form contains an invalid field name.')
|
|
68
|
+
|
|
69
|
+
const type = boundedText(field.type, 'Field type', 30, true)
|
|
70
|
+
if (!SUPPORTED_PUBLIC_FORM_FIELD_TYPES.includes(type)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`The public form contains an unsupported field type: ${type}.`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const normalized = {
|
|
77
|
+
name,
|
|
78
|
+
type,
|
|
79
|
+
label: boundedText(field.label, 'Field label', 200, true),
|
|
80
|
+
helpText: boundedText(field.helpText, 'Field help text', 2000),
|
|
81
|
+
required: field.required === true,
|
|
82
|
+
minLength: optionalLength(field.minLength, 'Minimum length'),
|
|
83
|
+
maxLength: optionalLength(field.maxLength, 'Maximum length'),
|
|
84
|
+
autocomplete: boundedText(field.autocomplete, 'Autocomplete value', 100),
|
|
85
|
+
options: undefined,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
normalized.minLength != null &&
|
|
90
|
+
normalized.maxLength != null &&
|
|
91
|
+
normalized.minLength > normalized.maxLength
|
|
92
|
+
) {
|
|
93
|
+
throw new Error(`${normalized.label} has invalid length limits.`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
normalized.options = normalizeOptions({
|
|
97
|
+
...field,
|
|
98
|
+
type,
|
|
99
|
+
label: normalized.label,
|
|
100
|
+
})
|
|
101
|
+
return normalized
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function normalizePublicFormDefinition(definition) {
|
|
105
|
+
if (
|
|
106
|
+
!definition ||
|
|
107
|
+
typeof definition !== 'object' ||
|
|
108
|
+
Array.isArray(definition)
|
|
109
|
+
) {
|
|
110
|
+
throw new Error('The public form definition is invalid.')
|
|
111
|
+
}
|
|
112
|
+
if (
|
|
113
|
+
!Array.isArray(definition.fields) ||
|
|
114
|
+
!definition.fields.length ||
|
|
115
|
+
definition.fields.length > MAX_FIELDS
|
|
116
|
+
) {
|
|
117
|
+
throw new Error('The public form must contain approved fields.')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const fields = definition.fields.map(normalizeField)
|
|
121
|
+
const names = new Set(fields.map(field => field.name))
|
|
122
|
+
if (names.size !== fields.length)
|
|
123
|
+
throw new Error('The public form contains duplicate field names.')
|
|
124
|
+
|
|
125
|
+
const version = Number(definition.version)
|
|
126
|
+
if (!Number.isInteger(version) || version < 1)
|
|
127
|
+
throw new Error('The public form version is invalid.')
|
|
128
|
+
|
|
129
|
+
const fallbackContact = definition.fallbackContact
|
|
130
|
+
let normalizedFallback
|
|
131
|
+
if (fallbackContact != null) {
|
|
132
|
+
if (typeof fallbackContact !== 'object' || Array.isArray(fallbackContact)) {
|
|
133
|
+
throw new Error('The public form fallback contact is invalid.')
|
|
134
|
+
}
|
|
135
|
+
const email = boundedText(fallbackContact.email, 'Fallback email', 320)
|
|
136
|
+
const phone = boundedText(fallbackContact.phone, 'Fallback phone', 50)
|
|
137
|
+
if (email && !EMAIL_PATTERN.test(email))
|
|
138
|
+
throw new Error('The public form fallback email is invalid.')
|
|
139
|
+
normalizedFallback = { email, phone }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const id = boundedText(definition.id, 'Form ID', 100, true)
|
|
143
|
+
if (!FORM_ID_PATTERN.test(id)) throw new Error('The public form ID is invalid.')
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
version,
|
|
148
|
+
title: boundedText(definition.title, 'Form title', 200, true),
|
|
149
|
+
introduction: boundedText(
|
|
150
|
+
definition.introduction,
|
|
151
|
+
'Form introduction',
|
|
152
|
+
2000
|
|
153
|
+
),
|
|
154
|
+
submitLabel: boundedText(definition.submitLabel, 'Submit label', 100, true),
|
|
155
|
+
successMessage: boundedText(
|
|
156
|
+
definition.successMessage,
|
|
157
|
+
'Success message',
|
|
158
|
+
1000,
|
|
159
|
+
true
|
|
160
|
+
),
|
|
161
|
+
fallbackContact: normalizedFallback,
|
|
162
|
+
fields,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function createPublicFormValues(definition) {
|
|
167
|
+
return Object.fromEntries(
|
|
168
|
+
definition.fields.map(field => [
|
|
169
|
+
field.name,
|
|
170
|
+
field.type === 'checkbox' ? false : '',
|
|
171
|
+
])
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validDate(value) {
|
|
176
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
177
|
+
const [year, month, day] = value.split('-').map(Number)
|
|
178
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
179
|
+
return (
|
|
180
|
+
date.getUTCFullYear() === year &&
|
|
181
|
+
date.getUTCMonth() === month - 1 &&
|
|
182
|
+
date.getUTCDate() === day
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function validatePublicFormValues(definition, sourceValues = {}) {
|
|
187
|
+
const errors = {}
|
|
188
|
+
const values = {}
|
|
189
|
+
|
|
190
|
+
for (const field of definition.fields) {
|
|
191
|
+
const rawValue = sourceValues[field.name]
|
|
192
|
+
const fieldErrors = []
|
|
193
|
+
|
|
194
|
+
if (field.type === 'checkbox') {
|
|
195
|
+
const value = rawValue === true
|
|
196
|
+
values[field.name] = value
|
|
197
|
+
if (field.required && !value)
|
|
198
|
+
fieldErrors.push(`${field.label} is required.`)
|
|
199
|
+
} else {
|
|
200
|
+
const hasInvalidType = rawValue != null && typeof rawValue !== 'string'
|
|
201
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
202
|
+
values[field.name] = field.type === 'email' ? value.toLowerCase() : value
|
|
203
|
+
|
|
204
|
+
if (hasInvalidType) fieldErrors.push(`${field.label} must be text.`)
|
|
205
|
+
else if (field.required && !value)
|
|
206
|
+
fieldErrors.push(`${field.label} is required.`)
|
|
207
|
+
if (value && field.minLength != null && value.length < field.minLength) {
|
|
208
|
+
fieldErrors.push(
|
|
209
|
+
`${field.label} must be at least ${field.minLength} characters.`
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
if (value && field.maxLength != null && value.length > field.maxLength) {
|
|
213
|
+
fieldErrors.push(
|
|
214
|
+
`${field.label} must be ${field.maxLength} characters or fewer.`
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
if (value && field.type === 'email' && !EMAIL_PATTERN.test(value)) {
|
|
218
|
+
fieldErrors.push('Enter a valid email address.')
|
|
219
|
+
}
|
|
220
|
+
if (value && field.type === 'date' && !validDate(value)) {
|
|
221
|
+
fieldErrors.push(`Enter a valid ${field.label.toLowerCase()}.`)
|
|
222
|
+
}
|
|
223
|
+
if (value && ['select', 'radio'].includes(field.type)) {
|
|
224
|
+
const allowedValues = new Set(
|
|
225
|
+
(field.options || []).map(option => option.value)
|
|
226
|
+
)
|
|
227
|
+
if (!allowedValues.has(value))
|
|
228
|
+
fieldErrors.push(`Select a valid ${field.label.toLowerCase()}.`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (fieldErrors.length) errors[field.name] = fieldErrors
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { values, errors, valid: Object.keys(errors).length === 0 }
|
|
236
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
|
|
3
|
+
import { normalizeBaseUrl, resolveRuntimeConfigValue } from './runtimeConfig'
|
|
4
|
+
|
|
5
|
+
const PUBLIC_FORMS_ROUTE = '/api/public-forms'
|
|
6
|
+
const PUBLIC_FORM_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
7
|
+
|
|
8
|
+
function normalizeFormId(formId) {
|
|
9
|
+
const normalized =
|
|
10
|
+
typeof formId === 'string' ? formId.trim().toLowerCase() : ''
|
|
11
|
+
if (!PUBLIC_FORM_ID_PATTERN.test(normalized)) {
|
|
12
|
+
throw new Error('The public form ID is invalid.')
|
|
13
|
+
}
|
|
14
|
+
return normalized
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolvePublicFormsEndpoint({
|
|
18
|
+
endpoint = null,
|
|
19
|
+
functionsEndpoint = null,
|
|
20
|
+
runtimeConfig = {},
|
|
21
|
+
} = {}) {
|
|
22
|
+
const explicitEndpoint = resolveRuntimeConfigValue({
|
|
23
|
+
explicitValue: endpoint,
|
|
24
|
+
runtimeConfig,
|
|
25
|
+
runtimeConfigKeys: ['publicFormsEndpoint'],
|
|
26
|
+
envKeys: ['PUBLIC_FORMS_ENDPOINT'],
|
|
27
|
+
})
|
|
28
|
+
if (explicitEndpoint) return normalizeBaseUrl(explicitEndpoint)
|
|
29
|
+
|
|
30
|
+
const resolvedFunctionsEndpoint = resolveRuntimeConfigValue({
|
|
31
|
+
explicitValue: functionsEndpoint,
|
|
32
|
+
runtimeConfig,
|
|
33
|
+
runtimeConfigKeys: ['functionsEndpoint'],
|
|
34
|
+
envKeys: ['FUNCTIONS_ENDPOINT'],
|
|
35
|
+
})
|
|
36
|
+
const baseUrl = normalizeBaseUrl(resolvedFunctionsEndpoint)
|
|
37
|
+
return baseUrl ? `${baseUrl}${PUBLIC_FORMS_ROUTE}` : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolvePublicFormEndpoints(formId, options = {}) {
|
|
41
|
+
const normalizedFormId = normalizeFormId(formId)
|
|
42
|
+
const baseEndpoint = resolvePublicFormsEndpoint(options)
|
|
43
|
+
if (!baseEndpoint) return { schema: null, submissions: null }
|
|
44
|
+
|
|
45
|
+
const schema = `${baseEndpoint}/${normalizedFormId}`
|
|
46
|
+
return { schema, submissions: `${schema}/submissions` }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function fetchPublicFormDefinition(formId, options = {}) {
|
|
50
|
+
const { schema } = resolvePublicFormEndpoints(formId, options)
|
|
51
|
+
if (!schema) throw new Error('The public forms endpoint is not configured.')
|
|
52
|
+
|
|
53
|
+
const response = await axios.get(schema)
|
|
54
|
+
return response.data
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function submitPublicForm(formId, submission, options = {}) {
|
|
58
|
+
const { submissions } = resolvePublicFormEndpoints(formId, options)
|
|
59
|
+
if (!submissions)
|
|
60
|
+
throw new Error('The public forms endpoint is not configured.')
|
|
61
|
+
|
|
62
|
+
const response = await axios.post(submissions, submission)
|
|
63
|
+
return response.data
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { normalizeFormId }
|