@asd20/ui-next 2.29.1 → 2.30.1
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
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.30.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.30.0...ui-next-v2.30.1) (2026-10-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* update styling on student records form ([3c9e73b](https://github.com/academydistrict20/asd20-ui-next/commit/3c9e73b669569aead47346fe52c5df432d1ecbf7))
|
|
9
|
+
|
|
10
|
+
# [2.30.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.29.1...ui-next-v2.30.0) (2026-10-06)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* refactor student records request workflow; reduce to a single form ([1dda060](https://github.com/academydistrict20/asd20-ui-next/commit/1dda06043264a5e9ca0dd7f5b012a51b4cae52ec))
|
|
16
|
+
|
|
3
17
|
## [2.29.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.29.0...ui-next-v2.29.1) (2026-10-05)
|
|
4
18
|
|
|
5
19
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,59 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="asd20-public-form-field">
|
|
3
|
+
<div
|
|
4
|
+
v-if="field.type === 'file'"
|
|
5
|
+
class="asd20-public-form-field__upload"
|
|
6
|
+
>
|
|
7
|
+
<label :for="inputId">{{ displayLabel }}</label>
|
|
8
|
+
<asd20-button
|
|
9
|
+
:id="inputId"
|
|
10
|
+
label="Choose files"
|
|
11
|
+
horizontal
|
|
12
|
+
bordered
|
|
13
|
+
:aria-label="`Choose files for ${field.label.toLowerCase()}`"
|
|
14
|
+
:aria-describedby="describedBy"
|
|
15
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
16
|
+
@click="$refs.fileInput.click()"
|
|
17
|
+
/>
|
|
18
|
+
<input
|
|
19
|
+
:id="`${inputId}-input`"
|
|
20
|
+
ref="fileInput"
|
|
21
|
+
type="file"
|
|
22
|
+
accept=".pdf,application/pdf"
|
|
23
|
+
multiple
|
|
24
|
+
hidden
|
|
25
|
+
:required="field.required"
|
|
26
|
+
:aria-label="field.label"
|
|
27
|
+
@change="addFiles"
|
|
28
|
+
/>
|
|
29
|
+
<div
|
|
30
|
+
class="asd20-public-form-field__file-selection"
|
|
31
|
+
aria-live="polite"
|
|
32
|
+
>
|
|
33
|
+
<ul
|
|
34
|
+
v-if="selectedFiles.length"
|
|
35
|
+
class="asd20-public-form-field__file-list"
|
|
36
|
+
>
|
|
37
|
+
<li
|
|
38
|
+
v-for="(file, index) in selectedFiles"
|
|
39
|
+
:key="index"
|
|
40
|
+
>
|
|
41
|
+
<span>{{ file.name }}</span>
|
|
42
|
+
<asd20-button
|
|
43
|
+
label="Remove"
|
|
44
|
+
text-only
|
|
45
|
+
:aria-label="`Remove ${file.name}`"
|
|
46
|
+
@click="removeFile(index)"
|
|
47
|
+
/>
|
|
48
|
+
</li>
|
|
49
|
+
</ul>
|
|
50
|
+
<p v-else>
|
|
51
|
+
No file chosen
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
3
55
|
<asd20-text-area-input
|
|
4
|
-
v-if="field.type === 'textarea'"
|
|
56
|
+
v-else-if="field.type === 'textarea'"
|
|
5
57
|
:id="inputId"
|
|
6
58
|
:model-value="modelValue"
|
|
7
59
|
:label="displayLabel"
|
|
@@ -39,30 +91,36 @@
|
|
|
39
91
|
class="asd20-public-form-field__select"
|
|
40
92
|
>
|
|
41
93
|
<label :for="inputId">{{ displayLabel }}</label>
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<option value="">
|
|
51
|
-
Please select
|
|
52
|
-
</option>
|
|
53
|
-
<option
|
|
54
|
-
v-for="option in field.options"
|
|
55
|
-
:key="option.value"
|
|
56
|
-
:value="option.value"
|
|
94
|
+
<div class="asd20-public-form-field__select-control">
|
|
95
|
+
<select
|
|
96
|
+
:id="inputId"
|
|
97
|
+
:value="modelValue"
|
|
98
|
+
:required="field.required"
|
|
99
|
+
:aria-describedby="describedBy"
|
|
100
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
101
|
+
@change="$emit('update:modelValue', $event.target.value)"
|
|
57
102
|
>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
103
|
+
<option value="">
|
|
104
|
+
Please select
|
|
105
|
+
</option>
|
|
106
|
+
<option
|
|
107
|
+
v-for="option in field.options"
|
|
108
|
+
:key="option.value"
|
|
109
|
+
:value="option.value"
|
|
110
|
+
>
|
|
111
|
+
{{ option.label }}
|
|
112
|
+
</option>
|
|
113
|
+
</select>
|
|
114
|
+
</div>
|
|
61
115
|
</div>
|
|
62
116
|
|
|
63
117
|
<fieldset
|
|
64
118
|
v-else-if="field.type === 'radio'"
|
|
65
119
|
class="asd20-public-form-field__choices"
|
|
120
|
+
:class="{
|
|
121
|
+
'asd20-public-form-field__choices--described':
|
|
122
|
+
field.options.some(option => option.description),
|
|
123
|
+
}"
|
|
66
124
|
:aria-describedby="describedBy"
|
|
67
125
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
68
126
|
>
|
|
@@ -81,7 +139,13 @@
|
|
|
81
139
|
:required="field.required"
|
|
82
140
|
@change="$emit('update:modelValue', option.value)"
|
|
83
141
|
/>
|
|
84
|
-
<span>
|
|
142
|
+
<span>
|
|
143
|
+
<strong class="asd20-public-form-field__option-title">{{ option.label }}</strong>
|
|
144
|
+
<span
|
|
145
|
+
v-if="option.description"
|
|
146
|
+
class="asd20-public-form-field__option-description"
|
|
147
|
+
>{{ option.description }}</span>
|
|
148
|
+
</span>
|
|
85
149
|
</label>
|
|
86
150
|
</fieldset>
|
|
87
151
|
|
|
@@ -129,6 +193,7 @@
|
|
|
129
193
|
</template>
|
|
130
194
|
|
|
131
195
|
<script>
|
|
196
|
+
import Asd20Button from '../../atoms/Asd20Button'
|
|
132
197
|
import Asd20Checkbox from '../../atoms/Asd20Checkbox'
|
|
133
198
|
import Asd20TextAreaInput from '../Asd20TextAreaInput'
|
|
134
199
|
import Asd20TextInput from '../Asd20TextInput'
|
|
@@ -148,10 +213,10 @@ function districtToday() {
|
|
|
148
213
|
|
|
149
214
|
export default {
|
|
150
215
|
name: 'Asd20PublicFormField',
|
|
151
|
-
components: { Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
|
|
216
|
+
components: { Asd20Button, Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
|
|
152
217
|
props: {
|
|
153
218
|
field: { type: Object, required: true },
|
|
154
|
-
modelValue: { type: [String, Boolean], default: '' },
|
|
219
|
+
modelValue: { type: [String, Boolean, Array], default: '' },
|
|
155
220
|
errors: { type: Array, default: () => [] },
|
|
156
221
|
inputId: { type: String, required: true },
|
|
157
222
|
showCharacterCount: { type: Boolean, default: true },
|
|
@@ -164,6 +229,9 @@ export default {
|
|
|
164
229
|
}
|
|
165
230
|
},
|
|
166
231
|
computed: {
|
|
232
|
+
selectedFiles() {
|
|
233
|
+
return Array.isArray(this.modelValue) ? this.modelValue : []
|
|
234
|
+
},
|
|
167
235
|
dateMaximum() {
|
|
168
236
|
return this.field.type === 'date' && this.field.maxDate === 'today'
|
|
169
237
|
? districtToday()
|
|
@@ -207,6 +275,21 @@ export default {
|
|
|
207
275
|
)
|
|
208
276
|
},
|
|
209
277
|
},
|
|
278
|
+
methods: {
|
|
279
|
+
addFiles(event) {
|
|
280
|
+
const addedFiles = Array.from(event.target.files || [])
|
|
281
|
+
if (addedFiles.length) {
|
|
282
|
+
this.$emit('update:modelValue', [...this.selectedFiles, ...addedFiles])
|
|
283
|
+
}
|
|
284
|
+
// The model owns the selection; reset the picker so later choices append
|
|
285
|
+
// and a removed document can be selected again.
|
|
286
|
+
event.target.value = ''
|
|
287
|
+
},
|
|
288
|
+
removeFile(index) {
|
|
289
|
+
this.$emit('update:modelValue', this.selectedFiles.filter((_, fileIndex) => fileIndex !== index))
|
|
290
|
+
this.$nextTick(() => document.getElementById(this.inputId)?.focus())
|
|
291
|
+
},
|
|
292
|
+
},
|
|
210
293
|
}
|
|
211
294
|
</script>
|
|
212
295
|
|
|
@@ -214,6 +297,49 @@ export default {
|
|
|
214
297
|
@use '../../../design/component-stack' as *;
|
|
215
298
|
|
|
216
299
|
.asd20-public-form-field {
|
|
300
|
+
&__option-title {
|
|
301
|
+
font-weight: 700;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
&__upload {
|
|
305
|
+
align-items: flex-start;
|
|
306
|
+
display: flex;
|
|
307
|
+
flex-direction: column;
|
|
308
|
+
gap: 0.5rem;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
&__file-selection {
|
|
312
|
+
width: 100%;
|
|
313
|
+
|
|
314
|
+
p {
|
|
315
|
+
margin: 0;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
&__file-list {
|
|
320
|
+
display: flex;
|
|
321
|
+
flex-direction: column;
|
|
322
|
+
gap: 0.5rem;
|
|
323
|
+
list-style: none;
|
|
324
|
+
margin: 0;
|
|
325
|
+
padding: 0;
|
|
326
|
+
|
|
327
|
+
li {
|
|
328
|
+
align-items: baseline;
|
|
329
|
+
display: flex;
|
|
330
|
+
gap: 0.5rem;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
span {
|
|
334
|
+
overflow-wrap: anywhere;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
&__option-description {
|
|
339
|
+
display: block;
|
|
340
|
+
font-weight: normal;
|
|
341
|
+
margin-top: 0.25rem;
|
|
342
|
+
}
|
|
217
343
|
// Public intake controls must stay legible on every school theme. Limit the
|
|
218
344
|
// neutral overrides to this wrapper so other site components keep their colors.
|
|
219
345
|
--color__accent: #3d3d3d;
|
|
@@ -235,7 +361,24 @@ export default {
|
|
|
235
361
|
margin-bottom: space(0.125);
|
|
236
362
|
}
|
|
237
363
|
|
|
364
|
+
&__select-control {
|
|
365
|
+
position: relative;
|
|
366
|
+
|
|
367
|
+
&::after {
|
|
368
|
+
border-left: 0.5rem solid transparent;
|
|
369
|
+
border-right: 0.5rem solid transparent;
|
|
370
|
+
border-top: 0.5rem solid currentColor;
|
|
371
|
+
content: '';
|
|
372
|
+
pointer-events: none;
|
|
373
|
+
position: absolute;
|
|
374
|
+
right: 1rem;
|
|
375
|
+
top: 50%;
|
|
376
|
+
transform: translateY(-50%);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
238
380
|
select {
|
|
381
|
+
appearance: none;
|
|
239
382
|
background: white;
|
|
240
383
|
border: 2px solid var(--color__accent);
|
|
241
384
|
border-radius: var(--website-shape__radius-s);
|
|
@@ -243,6 +386,7 @@ export default {
|
|
|
243
386
|
color: var(--website-card__reverse-background-color);
|
|
244
387
|
font: inherit;
|
|
245
388
|
padding: space(0.25);
|
|
389
|
+
padding-right: 3rem;
|
|
246
390
|
width: 100%;
|
|
247
391
|
}
|
|
248
392
|
|
|
@@ -259,6 +403,15 @@ export default {
|
|
|
259
403
|
margin: space(0.125) 0;
|
|
260
404
|
}
|
|
261
405
|
|
|
406
|
+
&__choices--described label {
|
|
407
|
+
align-items: flex-start;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
&__choices input[type='radio'] {
|
|
411
|
+
flex: 0 0 auto;
|
|
412
|
+
margin: 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
262
415
|
&__help,
|
|
263
416
|
&__character-count,
|
|
264
417
|
&__errors {
|
|
@@ -54,13 +54,21 @@
|
|
|
54
54
|
class="asd20-public-form-modal__introduction"
|
|
55
55
|
>
|
|
56
56
|
<template v-if="inlineIntroductionParts">
|
|
57
|
-
{{ inlineIntroductionParts.before
|
|
57
|
+
{{ inlineIntroductionParts.before
|
|
58
|
+
}}<a :href="formDefinition.introductionLink.href">{{
|
|
59
|
+
formDefinition.introductionLink.label
|
|
60
|
+
}}</a>{{ inlineIntroductionParts.after }}
|
|
58
61
|
</template>
|
|
59
62
|
<template v-else>
|
|
60
63
|
{{ formDefinition.introduction }}
|
|
61
64
|
</template>
|
|
62
65
|
</p>
|
|
63
|
-
<p
|
|
66
|
+
<p
|
|
67
|
+
v-if="
|
|
68
|
+
formDefinition.introductionLink &&
|
|
69
|
+
!formDefinition.introductionLink.inline
|
|
70
|
+
"
|
|
71
|
+
>
|
|
64
72
|
<a :href="formDefinition.introductionLink.href">
|
|
65
73
|
{{ formDefinition.introductionLink.label }}
|
|
66
74
|
</a>
|
|
@@ -88,7 +96,7 @@
|
|
|
88
96
|
</div>
|
|
89
97
|
|
|
90
98
|
<asd20-public-form-field
|
|
91
|
-
v-for="field in
|
|
99
|
+
v-for="field in visibleFields"
|
|
92
100
|
:key="field.name"
|
|
93
101
|
:class="{
|
|
94
102
|
'asd20-public-form-modal__requester-choice':
|
|
@@ -135,6 +143,7 @@
|
|
|
135
143
|
>
|
|
136
144
|
<asd20-button
|
|
137
145
|
:label="formDefinition.submitLabel"
|
|
146
|
+
:disabled="!canSubmit"
|
|
138
147
|
horizontal
|
|
139
148
|
centered
|
|
140
149
|
bordered
|
|
@@ -144,6 +153,7 @@
|
|
|
144
153
|
<asd20-button
|
|
145
154
|
v-else
|
|
146
155
|
:label="formDefinition.submitLabel"
|
|
156
|
+
:disabled="!canSubmit"
|
|
147
157
|
horizontal
|
|
148
158
|
centered
|
|
149
159
|
bordered
|
|
@@ -199,6 +209,9 @@ import Asd20PublicFormField from '../../molecules/Asd20PublicFormField'
|
|
|
199
209
|
import Recaptcha from '../../utils/Recaptcha'
|
|
200
210
|
import {
|
|
201
211
|
createPublicFormValues,
|
|
212
|
+
isPublicFormFieldVisible,
|
|
213
|
+
isPublicFormFieldRequired,
|
|
214
|
+
serializePublicFormValues,
|
|
202
215
|
normalizePublicFormDefinition,
|
|
203
216
|
validatePublicFormValues,
|
|
204
217
|
} from '../../../helpers/publicFormDefinition'
|
|
@@ -255,6 +268,21 @@ export default {
|
|
|
255
268
|
}
|
|
256
269
|
},
|
|
257
270
|
computed: {
|
|
271
|
+
canSubmit() {
|
|
272
|
+
return Boolean(
|
|
273
|
+
this.formDefinition &&
|
|
274
|
+
!this.sending &&
|
|
275
|
+
validatePublicFormValues(this.formDefinition, this.values).valid
|
|
276
|
+
)
|
|
277
|
+
},
|
|
278
|
+
visibleFields() {
|
|
279
|
+
return (this.formDefinition?.fields || [])
|
|
280
|
+
.filter(field => isPublicFormFieldVisible(field, this.values))
|
|
281
|
+
.map(field => ({
|
|
282
|
+
...field,
|
|
283
|
+
required: isPublicFormFieldRequired(field, this.values),
|
|
284
|
+
}))
|
|
285
|
+
},
|
|
258
286
|
modalTitle() {
|
|
259
287
|
return this.formDefinition?.title || 'Public Form'
|
|
260
288
|
},
|
|
@@ -288,7 +316,9 @@ export default {
|
|
|
288
316
|
},
|
|
289
317
|
hasFallbackContact() {
|
|
290
318
|
const fallback = this.formDefinition?.fallbackContact
|
|
291
|
-
return Boolean(
|
|
319
|
+
return Boolean(
|
|
320
|
+
fallback?.instructions || fallback?.email || fallback?.phone
|
|
321
|
+
)
|
|
292
322
|
},
|
|
293
323
|
showFallbackContact() {
|
|
294
324
|
return this.submissionFailed && this.hasFallbackContact
|
|
@@ -377,6 +407,12 @@ export default {
|
|
|
377
407
|
},
|
|
378
408
|
updateValue(name, value) {
|
|
379
409
|
this.values[name] = value
|
|
410
|
+
for (const field of this.formDefinition.fields) {
|
|
411
|
+
if (!isPublicFormFieldVisible(field, this.values)) {
|
|
412
|
+
this.values[field.name] =
|
|
413
|
+
field.type === 'checkbox' ? false : field.type === 'file' ? [] : ''
|
|
414
|
+
}
|
|
415
|
+
}
|
|
380
416
|
if (this.validationAttempted) this.validate(false)
|
|
381
417
|
},
|
|
382
418
|
validate(focusSummary = true) {
|
|
@@ -424,6 +460,12 @@ export default {
|
|
|
424
460
|
if (status === 429) {
|
|
425
461
|
return 'Too many submissions were received recently. Please wait and try again, or use the contact options listed.'
|
|
426
462
|
}
|
|
463
|
+
if (
|
|
464
|
+
status === 502 &&
|
|
465
|
+
typeof serverMessage === 'string' &&
|
|
466
|
+
serverMessage.trim()
|
|
467
|
+
)
|
|
468
|
+
return serverMessage
|
|
427
469
|
if (status === 502) {
|
|
428
470
|
return 'The form could not be delivered right now. Please try again later or use the contact options listed.'
|
|
429
471
|
}
|
|
@@ -440,7 +482,9 @@ export default {
|
|
|
440
482
|
async submitForm(captchaToken = '') {
|
|
441
483
|
if (this.sending || !this.validate()) return
|
|
442
484
|
if (this.captchaEnabled && !captchaToken) {
|
|
443
|
-
this.showFeedback(
|
|
485
|
+
this.showFeedback(
|
|
486
|
+
'Verification is required before submitting this form.'
|
|
487
|
+
)
|
|
444
488
|
return
|
|
445
489
|
}
|
|
446
490
|
|
|
@@ -449,7 +493,10 @@ export default {
|
|
|
449
493
|
this.submissionFailed = false
|
|
450
494
|
try {
|
|
451
495
|
const payload = {
|
|
452
|
-
...
|
|
496
|
+
...(await serializePublicFormValues(
|
|
497
|
+
this.formDefinition,
|
|
498
|
+
this.values
|
|
499
|
+
)),
|
|
453
500
|
website: this.honeypot,
|
|
454
501
|
captchaToken,
|
|
455
502
|
}
|
|
@@ -7,6 +7,7 @@ export const SUPPORTED_PUBLIC_FORM_FIELD_TYPES = Object.freeze([
|
|
|
7
7
|
'select',
|
|
8
8
|
'radio',
|
|
9
9
|
'checkbox',
|
|
10
|
+
'file',
|
|
10
11
|
])
|
|
11
12
|
|
|
12
13
|
const FIELD_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
@@ -65,7 +66,12 @@ function normalizeOptions(field) {
|
|
|
65
66
|
if (seen.has(value))
|
|
66
67
|
throw new Error(`${field.label} has duplicate choices.`)
|
|
67
68
|
seen.add(value)
|
|
68
|
-
|
|
69
|
+
const description = boundedText(
|
|
70
|
+
option?.description,
|
|
71
|
+
'Option description',
|
|
72
|
+
1000
|
|
73
|
+
)
|
|
74
|
+
return description ? { label, value, description } : { label, value }
|
|
69
75
|
})
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -100,6 +106,12 @@ function normalizeField(field) {
|
|
|
100
106
|
'Character-count visibility'
|
|
101
107
|
),
|
|
102
108
|
options: undefined,
|
|
109
|
+
visibleWhen: normalizeCondition(field.visibleWhen),
|
|
110
|
+
requiredUnlessUploaded: boundedText(field.requiredUnlessUploaded, 'Upload field name', 64),
|
|
111
|
+
requiredWhen:
|
|
112
|
+
field.requiredWhen == null
|
|
113
|
+
? undefined
|
|
114
|
+
: normalizeConditions(field.requiredWhen),
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
if (
|
|
@@ -122,9 +134,103 @@ function normalizeField(field) {
|
|
|
122
134
|
type,
|
|
123
135
|
label: normalized.label,
|
|
124
136
|
})
|
|
137
|
+
if (type === 'file') {
|
|
138
|
+
if (
|
|
139
|
+
field.accept !== 'application/pdf' ||
|
|
140
|
+
field.maxFiles !== 5 ||
|
|
141
|
+
field.maxFileBytes !== 5 * 1024 * 1024 ||
|
|
142
|
+
field.maxTotalFileBytes !== 10 * 1024 * 1024
|
|
143
|
+
) {
|
|
144
|
+
throw new Error('The public form has invalid upload limits.')
|
|
145
|
+
}
|
|
146
|
+
Object.assign(normalized, {
|
|
147
|
+
accept: field.accept,
|
|
148
|
+
maxFiles: field.maxFiles,
|
|
149
|
+
maxFileBytes: field.maxFileBytes,
|
|
150
|
+
maxTotalFileBytes: field.maxTotalFileBytes,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
125
153
|
return normalized
|
|
126
154
|
}
|
|
127
155
|
|
|
156
|
+
function normalizeCondition(condition) {
|
|
157
|
+
if (condition == null) return undefined
|
|
158
|
+
if (
|
|
159
|
+
!condition ||
|
|
160
|
+
typeof condition !== 'object' ||
|
|
161
|
+
Array.isArray(condition) ||
|
|
162
|
+
!FIELD_NAME_PATTERN.test(condition.fieldName) ||
|
|
163
|
+
!Array.isArray(condition.values) ||
|
|
164
|
+
!condition.values.length ||
|
|
165
|
+
condition.values.length > MAX_OPTIONS ||
|
|
166
|
+
condition.values.some(
|
|
167
|
+
value => typeof value !== 'string' || !OPTION_VALUE_PATTERN.test(value)
|
|
168
|
+
)
|
|
169
|
+
) {
|
|
170
|
+
throw new Error('The public form has an invalid field condition.')
|
|
171
|
+
}
|
|
172
|
+
return { fieldName: condition.fieldName, values: [...condition.values] }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeConditions(conditions) {
|
|
176
|
+
if (
|
|
177
|
+
!Array.isArray(conditions) ||
|
|
178
|
+
!conditions.length ||
|
|
179
|
+
conditions.length > MAX_FIELDS ||
|
|
180
|
+
conditions.some(condition => condition == null)
|
|
181
|
+
) {
|
|
182
|
+
throw new Error('The public form has invalid required conditions.')
|
|
183
|
+
}
|
|
184
|
+
return conditions.map(normalizeCondition)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isPublicFormFieldVisible(field, values) {
|
|
188
|
+
return (
|
|
189
|
+
!field.visibleWhen ||
|
|
190
|
+
field.visibleWhen.values.includes(String(values[field.visibleWhen.fieldName] || '').trim())
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function isPublicFormFieldRequired(field, values) {
|
|
195
|
+
const required = field.required || field.requiredWhen?.some(condition =>
|
|
196
|
+
condition.values.includes(String(values[condition.fieldName] || '').trim())
|
|
197
|
+
)
|
|
198
|
+
const uploaded = field.requiredUnlessUploaded &&
|
|
199
|
+
Array.isArray(values[field.requiredUnlessUploaded]) &&
|
|
200
|
+
values[field.requiredUnlessUploaded].length > 0
|
|
201
|
+
return Boolean(required && !uploaded)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function serializePublicFormValues(definition, values) {
|
|
205
|
+
const result = { ...values }
|
|
206
|
+
for (const field of definition.fields.filter(
|
|
207
|
+
field => field.type === 'file'
|
|
208
|
+
)) {
|
|
209
|
+
result[field.name] = await Promise.all(
|
|
210
|
+
(values[field.name] || []).map(
|
|
211
|
+
file =>
|
|
212
|
+
new Promise((resolve, reject) => {
|
|
213
|
+
const reader = new FileReader()
|
|
214
|
+
reader.onload = () =>
|
|
215
|
+
resolve({
|
|
216
|
+
name: file.name,
|
|
217
|
+
contentType: 'application/pdf',
|
|
218
|
+
contentBase64: String(reader.result).split(',')[1],
|
|
219
|
+
})
|
|
220
|
+
reader.onerror = () =>
|
|
221
|
+
reject(
|
|
222
|
+
new Error(
|
|
223
|
+
'A supporting document could not be read. Select the document again.'
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
reader.readAsDataURL(file)
|
|
227
|
+
})
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
return result
|
|
232
|
+
}
|
|
233
|
+
|
|
128
234
|
export function normalizePublicFormDefinition(definition) {
|
|
129
235
|
if (
|
|
130
236
|
!definition ||
|
|
@@ -146,6 +252,32 @@ export function normalizePublicFormDefinition(definition) {
|
|
|
146
252
|
if (names.size !== fields.length)
|
|
147
253
|
throw new Error('The public form contains duplicate field names.')
|
|
148
254
|
|
|
255
|
+
for (const field of fields) {
|
|
256
|
+
if (field.requiredUnlessUploaded) {
|
|
257
|
+
const upload = fields.find(candidate => candidate.name === field.requiredUnlessUploaded)
|
|
258
|
+
if (!upload || upload.type !== 'file' || upload === field) {
|
|
259
|
+
throw new Error('The public form has an invalid upload requirement reference.')
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const condition of [
|
|
263
|
+
field.visibleWhen,
|
|
264
|
+
...(field.requiredWhen || []),
|
|
265
|
+
].filter(Boolean)) {
|
|
266
|
+
const dependency = fields.find(
|
|
267
|
+
candidate => candidate.name === condition.fieldName
|
|
268
|
+
)
|
|
269
|
+
if (
|
|
270
|
+
!dependency ||
|
|
271
|
+
dependency === field ||
|
|
272
|
+
!['radio', 'select'].includes(dependency.type) ||
|
|
273
|
+
condition.values.some(
|
|
274
|
+
value => !dependency.options.some(option => option.value === value)
|
|
275
|
+
)
|
|
276
|
+
) {
|
|
277
|
+
throw new Error('The public form has an invalid condition reference.')
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
149
281
|
const version = Number(definition.version)
|
|
150
282
|
if (!Number.isInteger(version) || version < 1)
|
|
151
283
|
throw new Error('The public form version is invalid.')
|
|
@@ -169,7 +301,8 @@ export function normalizePublicFormDefinition(definition) {
|
|
|
169
301
|
}
|
|
170
302
|
|
|
171
303
|
const id = boundedText(definition.id, 'Form ID', 100, true)
|
|
172
|
-
if (!FORM_ID_PATTERN.test(id))
|
|
304
|
+
if (!FORM_ID_PATTERN.test(id))
|
|
305
|
+
throw new Error('The public form ID is invalid.')
|
|
173
306
|
|
|
174
307
|
const introduction = boundedText(
|
|
175
308
|
definition.introduction,
|
|
@@ -202,7 +335,9 @@ export function normalizePublicFormDefinition(definition) {
|
|
|
202
335
|
(!introduction.includes(label) ||
|
|
203
336
|
introduction.indexOf(label) !== introduction.lastIndexOf(label))
|
|
204
337
|
) {
|
|
205
|
-
throw new Error(
|
|
338
|
+
throw new Error(
|
|
339
|
+
'The inline introduction link label must appear once in the introduction.'
|
|
340
|
+
)
|
|
206
341
|
}
|
|
207
342
|
introductionLink = inline ? { label, href, inline: true } : { label, href }
|
|
208
343
|
}
|
|
@@ -234,7 +369,7 @@ export function createPublicFormValues(definition) {
|
|
|
234
369
|
return Object.fromEntries(
|
|
235
370
|
definition.fields.map(field => [
|
|
236
371
|
field.name,
|
|
237
|
-
field.type === 'checkbox' ? false : '',
|
|
372
|
+
field.type === 'file' ? [] : field.type === 'checkbox' ? false : '',
|
|
238
373
|
])
|
|
239
374
|
)
|
|
240
375
|
}
|
|
@@ -268,21 +403,47 @@ export function validatePublicFormValues(definition, sourceValues = {}) {
|
|
|
268
403
|
const values = {}
|
|
269
404
|
|
|
270
405
|
for (const field of definition.fields) {
|
|
406
|
+
if (!isPublicFormFieldVisible(field, sourceValues)) {
|
|
407
|
+
values[field.name] =
|
|
408
|
+
field.type === 'file' ? [] : field.type === 'checkbox' ? false : ''
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
const required = isPublicFormFieldRequired(field, sourceValues)
|
|
271
412
|
const rawValue = sourceValues[field.name]
|
|
272
413
|
const fieldErrors = []
|
|
273
414
|
|
|
274
|
-
if (field.type === '
|
|
415
|
+
if (field.type === 'file') {
|
|
416
|
+
const files = rawValue || []
|
|
417
|
+
values[field.name] = files
|
|
418
|
+
if (
|
|
419
|
+
!Array.isArray(files) ||
|
|
420
|
+
files.length > field.maxFiles ||
|
|
421
|
+
files.some(
|
|
422
|
+
file =>
|
|
423
|
+
!(file instanceof File) ||
|
|
424
|
+
!/\.pdf$/i.test(file.name) ||
|
|
425
|
+
(file.type && file.type !== 'application/pdf') ||
|
|
426
|
+
file.size > field.maxFileBytes ||
|
|
427
|
+
file.size === 0
|
|
428
|
+
) ||
|
|
429
|
+
files.reduce((size, file) => size + file.size, 0) >
|
|
430
|
+
field.maxTotalFileBytes
|
|
431
|
+
) {
|
|
432
|
+
fieldErrors.push(
|
|
433
|
+
'Upload up to 5 PDF documents, 5 MB each and 10 MB total.'
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
} else if (field.type === 'checkbox') {
|
|
275
437
|
const value = rawValue === true
|
|
276
438
|
values[field.name] = value
|
|
277
|
-
if (
|
|
278
|
-
fieldErrors.push(`${field.label} is required.`)
|
|
439
|
+
if (required && !value) fieldErrors.push(`${field.label} is required.`)
|
|
279
440
|
} else {
|
|
280
441
|
const hasInvalidType = rawValue != null && typeof rawValue !== 'string'
|
|
281
442
|
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
282
443
|
values[field.name] = field.type === 'email' ? value.toLowerCase() : value
|
|
283
444
|
|
|
284
445
|
if (hasInvalidType) fieldErrors.push(`${field.label} must be text.`)
|
|
285
|
-
else if (
|
|
446
|
+
else if (required && !value)
|
|
286
447
|
fieldErrors.push(`${field.label} is required.`)
|
|
287
448
|
if (value && field.minLength != null && value.length < field.minLength) {
|
|
288
449
|
fieldErrors.push(
|