@asd20/ui-next 2.29.1 → 2.30.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
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
# [2.30.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.29.1...ui-next-v2.30.0) (2026-10-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* refactor student records request workflow; reduce to a single form ([1dda060](https://github.com/academydistrict20/asd20-ui-next/commit/1dda06043264a5e9ca0dd7f5b012a51b4cae52ec))
|
|
9
|
+
|
|
3
10
|
## [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
11
|
|
|
5
12
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
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
|
+
<input
|
|
9
|
+
:id="inputId"
|
|
10
|
+
type="file"
|
|
11
|
+
accept=".pdf,application/pdf"
|
|
12
|
+
multiple
|
|
13
|
+
:required="field.required"
|
|
14
|
+
:aria-describedby="describedBy"
|
|
15
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
16
|
+
@change="
|
|
17
|
+
$emit('update:modelValue', Array.from($event.target.files || []))
|
|
18
|
+
"
|
|
19
|
+
/>
|
|
20
|
+
<ul v-if="modelValue.length">
|
|
21
|
+
<li
|
|
22
|
+
v-for="(file, index) in modelValue"
|
|
23
|
+
:key="index"
|
|
24
|
+
>
|
|
25
|
+
{{ file.name }}
|
|
26
|
+
</li>
|
|
27
|
+
</ul>
|
|
28
|
+
</div>
|
|
3
29
|
<asd20-text-area-input
|
|
4
|
-
v-if="field.type === 'textarea'"
|
|
30
|
+
v-else-if="field.type === 'textarea'"
|
|
5
31
|
:id="inputId"
|
|
6
32
|
:model-value="modelValue"
|
|
7
33
|
:label="displayLabel"
|
|
@@ -81,7 +107,13 @@
|
|
|
81
107
|
:required="field.required"
|
|
82
108
|
@change="$emit('update:modelValue', option.value)"
|
|
83
109
|
/>
|
|
84
|
-
<span>
|
|
110
|
+
<span>
|
|
111
|
+
{{ option.label }}
|
|
112
|
+
<span
|
|
113
|
+
v-if="option.description"
|
|
114
|
+
class="asd20-public-form-field__option-description"
|
|
115
|
+
>{{ option.description }}</span>
|
|
116
|
+
</span>
|
|
85
117
|
</label>
|
|
86
118
|
</fieldset>
|
|
87
119
|
|
|
@@ -151,7 +183,7 @@ export default {
|
|
|
151
183
|
components: { Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
|
|
152
184
|
props: {
|
|
153
185
|
field: { type: Object, required: true },
|
|
154
|
-
modelValue: { type: [String, Boolean], default: '' },
|
|
186
|
+
modelValue: { type: [String, Boolean, Array], default: '' },
|
|
155
187
|
errors: { type: Array, default: () => [] },
|
|
156
188
|
inputId: { type: String, required: true },
|
|
157
189
|
showCharacterCount: { type: Boolean, default: true },
|
|
@@ -214,6 +246,11 @@ export default {
|
|
|
214
246
|
@use '../../../design/component-stack' as *;
|
|
215
247
|
|
|
216
248
|
.asd20-public-form-field {
|
|
249
|
+
&__option-description {
|
|
250
|
+
display: block;
|
|
251
|
+
font-weight: normal;
|
|
252
|
+
margin-top: 0.25rem;
|
|
253
|
+
}
|
|
217
254
|
// Public intake controls must stay legible on every school theme. Limit the
|
|
218
255
|
// neutral overrides to this wrapper so other site components keep their colors.
|
|
219
256
|
--color__accent: #3d3d3d;
|
|
@@ -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':
|
|
@@ -199,6 +207,9 @@ import Asd20PublicFormField from '../../molecules/Asd20PublicFormField'
|
|
|
199
207
|
import Recaptcha from '../../utils/Recaptcha'
|
|
200
208
|
import {
|
|
201
209
|
createPublicFormValues,
|
|
210
|
+
isPublicFormFieldVisible,
|
|
211
|
+
isPublicFormFieldRequired,
|
|
212
|
+
serializePublicFormValues,
|
|
202
213
|
normalizePublicFormDefinition,
|
|
203
214
|
validatePublicFormValues,
|
|
204
215
|
} from '../../../helpers/publicFormDefinition'
|
|
@@ -255,6 +266,14 @@ export default {
|
|
|
255
266
|
}
|
|
256
267
|
},
|
|
257
268
|
computed: {
|
|
269
|
+
visibleFields() {
|
|
270
|
+
return (this.formDefinition?.fields || [])
|
|
271
|
+
.filter(field => isPublicFormFieldVisible(field, this.values))
|
|
272
|
+
.map(field => ({
|
|
273
|
+
...field,
|
|
274
|
+
required: isPublicFormFieldRequired(field, this.values),
|
|
275
|
+
}))
|
|
276
|
+
},
|
|
258
277
|
modalTitle() {
|
|
259
278
|
return this.formDefinition?.title || 'Public Form'
|
|
260
279
|
},
|
|
@@ -288,7 +307,9 @@ export default {
|
|
|
288
307
|
},
|
|
289
308
|
hasFallbackContact() {
|
|
290
309
|
const fallback = this.formDefinition?.fallbackContact
|
|
291
|
-
return Boolean(
|
|
310
|
+
return Boolean(
|
|
311
|
+
fallback?.instructions || fallback?.email || fallback?.phone
|
|
312
|
+
)
|
|
292
313
|
},
|
|
293
314
|
showFallbackContact() {
|
|
294
315
|
return this.submissionFailed && this.hasFallbackContact
|
|
@@ -377,6 +398,12 @@ export default {
|
|
|
377
398
|
},
|
|
378
399
|
updateValue(name, value) {
|
|
379
400
|
this.values[name] = value
|
|
401
|
+
for (const field of this.formDefinition.fields) {
|
|
402
|
+
if (!isPublicFormFieldVisible(field, this.values)) {
|
|
403
|
+
this.values[field.name] =
|
|
404
|
+
field.type === 'checkbox' ? false : field.type === 'file' ? [] : ''
|
|
405
|
+
}
|
|
406
|
+
}
|
|
380
407
|
if (this.validationAttempted) this.validate(false)
|
|
381
408
|
},
|
|
382
409
|
validate(focusSummary = true) {
|
|
@@ -424,6 +451,12 @@ export default {
|
|
|
424
451
|
if (status === 429) {
|
|
425
452
|
return 'Too many submissions were received recently. Please wait and try again, or use the contact options listed.'
|
|
426
453
|
}
|
|
454
|
+
if (
|
|
455
|
+
status === 502 &&
|
|
456
|
+
typeof serverMessage === 'string' &&
|
|
457
|
+
serverMessage.trim()
|
|
458
|
+
)
|
|
459
|
+
return serverMessage
|
|
427
460
|
if (status === 502) {
|
|
428
461
|
return 'The form could not be delivered right now. Please try again later or use the contact options listed.'
|
|
429
462
|
}
|
|
@@ -440,7 +473,9 @@ export default {
|
|
|
440
473
|
async submitForm(captchaToken = '') {
|
|
441
474
|
if (this.sending || !this.validate()) return
|
|
442
475
|
if (this.captchaEnabled && !captchaToken) {
|
|
443
|
-
this.showFeedback(
|
|
476
|
+
this.showFeedback(
|
|
477
|
+
'Verification is required before submitting this form.'
|
|
478
|
+
)
|
|
444
479
|
return
|
|
445
480
|
}
|
|
446
481
|
|
|
@@ -449,7 +484,10 @@ export default {
|
|
|
449
484
|
this.submissionFailed = false
|
|
450
485
|
try {
|
|
451
486
|
const payload = {
|
|
452
|
-
...
|
|
487
|
+
...(await serializePublicFormValues(
|
|
488
|
+
this.formDefinition,
|
|
489
|
+
this.values
|
|
490
|
+
)),
|
|
453
491
|
website: this.honeypot,
|
|
454
492
|
captchaToken,
|
|
455
493
|
}
|
|
@@ -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,11 @@ function normalizeField(field) {
|
|
|
100
106
|
'Character-count visibility'
|
|
101
107
|
),
|
|
102
108
|
options: undefined,
|
|
109
|
+
visibleWhen: normalizeCondition(field.visibleWhen),
|
|
110
|
+
requiredWhen:
|
|
111
|
+
field.requiredWhen == null
|
|
112
|
+
? undefined
|
|
113
|
+
: normalizeConditions(field.requiredWhen),
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
if (
|
|
@@ -122,9 +133,103 @@ function normalizeField(field) {
|
|
|
122
133
|
type,
|
|
123
134
|
label: normalized.label,
|
|
124
135
|
})
|
|
136
|
+
if (type === 'file') {
|
|
137
|
+
if (
|
|
138
|
+
field.accept !== 'application/pdf' ||
|
|
139
|
+
field.maxFiles !== 5 ||
|
|
140
|
+
field.maxFileBytes !== 5 * 1024 * 1024 ||
|
|
141
|
+
field.maxTotalFileBytes !== 10 * 1024 * 1024
|
|
142
|
+
) {
|
|
143
|
+
throw new Error('The public form has invalid upload limits.')
|
|
144
|
+
}
|
|
145
|
+
Object.assign(normalized, {
|
|
146
|
+
accept: field.accept,
|
|
147
|
+
maxFiles: field.maxFiles,
|
|
148
|
+
maxFileBytes: field.maxFileBytes,
|
|
149
|
+
maxTotalFileBytes: field.maxTotalFileBytes,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
125
152
|
return normalized
|
|
126
153
|
}
|
|
127
154
|
|
|
155
|
+
function normalizeCondition(condition) {
|
|
156
|
+
if (condition == null) return undefined
|
|
157
|
+
if (
|
|
158
|
+
!condition ||
|
|
159
|
+
typeof condition !== 'object' ||
|
|
160
|
+
Array.isArray(condition) ||
|
|
161
|
+
!FIELD_NAME_PATTERN.test(condition.fieldName) ||
|
|
162
|
+
!Array.isArray(condition.values) ||
|
|
163
|
+
!condition.values.length ||
|
|
164
|
+
condition.values.length > MAX_OPTIONS ||
|
|
165
|
+
condition.values.some(
|
|
166
|
+
value => typeof value !== 'string' || !OPTION_VALUE_PATTERN.test(value)
|
|
167
|
+
)
|
|
168
|
+
) {
|
|
169
|
+
throw new Error('The public form has an invalid field condition.')
|
|
170
|
+
}
|
|
171
|
+
return { fieldName: condition.fieldName, values: [...condition.values] }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeConditions(conditions) {
|
|
175
|
+
if (
|
|
176
|
+
!Array.isArray(conditions) ||
|
|
177
|
+
!conditions.length ||
|
|
178
|
+
conditions.length > MAX_FIELDS ||
|
|
179
|
+
conditions.some(condition => condition == null)
|
|
180
|
+
) {
|
|
181
|
+
throw new Error('The public form has invalid required conditions.')
|
|
182
|
+
}
|
|
183
|
+
return conditions.map(normalizeCondition)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function isPublicFormFieldVisible(field, values) {
|
|
187
|
+
return (
|
|
188
|
+
!field.visibleWhen ||
|
|
189
|
+
field.visibleWhen.values.includes(String(values[field.visibleWhen.fieldName] || '').trim())
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function isPublicFormFieldRequired(field, values) {
|
|
194
|
+
return (
|
|
195
|
+
field.required ||
|
|
196
|
+
field.requiredWhen?.some(condition =>
|
|
197
|
+
condition.values.includes(String(values[condition.fieldName] || '').trim())
|
|
198
|
+
) ||
|
|
199
|
+
false
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function serializePublicFormValues(definition, values) {
|
|
204
|
+
const result = { ...values }
|
|
205
|
+
for (const field of definition.fields.filter(
|
|
206
|
+
field => field.type === 'file'
|
|
207
|
+
)) {
|
|
208
|
+
result[field.name] = await Promise.all(
|
|
209
|
+
(values[field.name] || []).map(
|
|
210
|
+
file =>
|
|
211
|
+
new Promise((resolve, reject) => {
|
|
212
|
+
const reader = new FileReader()
|
|
213
|
+
reader.onload = () =>
|
|
214
|
+
resolve({
|
|
215
|
+
name: file.name,
|
|
216
|
+
contentType: 'application/pdf',
|
|
217
|
+
contentBase64: String(reader.result).split(',')[1],
|
|
218
|
+
})
|
|
219
|
+
reader.onerror = () =>
|
|
220
|
+
reject(
|
|
221
|
+
new Error(
|
|
222
|
+
'A supporting document could not be read. Select the document again.'
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
reader.readAsDataURL(file)
|
|
226
|
+
})
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
return result
|
|
231
|
+
}
|
|
232
|
+
|
|
128
233
|
export function normalizePublicFormDefinition(definition) {
|
|
129
234
|
if (
|
|
130
235
|
!definition ||
|
|
@@ -146,6 +251,26 @@ export function normalizePublicFormDefinition(definition) {
|
|
|
146
251
|
if (names.size !== fields.length)
|
|
147
252
|
throw new Error('The public form contains duplicate field names.')
|
|
148
253
|
|
|
254
|
+
for (const field of fields) {
|
|
255
|
+
for (const condition of [
|
|
256
|
+
field.visibleWhen,
|
|
257
|
+
...(field.requiredWhen || []),
|
|
258
|
+
].filter(Boolean)) {
|
|
259
|
+
const dependency = fields.find(
|
|
260
|
+
candidate => candidate.name === condition.fieldName
|
|
261
|
+
)
|
|
262
|
+
if (
|
|
263
|
+
!dependency ||
|
|
264
|
+
dependency === field ||
|
|
265
|
+
!['radio', 'select'].includes(dependency.type) ||
|
|
266
|
+
condition.values.some(
|
|
267
|
+
value => !dependency.options.some(option => option.value === value)
|
|
268
|
+
)
|
|
269
|
+
) {
|
|
270
|
+
throw new Error('The public form has an invalid condition reference.')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
149
274
|
const version = Number(definition.version)
|
|
150
275
|
if (!Number.isInteger(version) || version < 1)
|
|
151
276
|
throw new Error('The public form version is invalid.')
|
|
@@ -169,7 +294,8 @@ export function normalizePublicFormDefinition(definition) {
|
|
|
169
294
|
}
|
|
170
295
|
|
|
171
296
|
const id = boundedText(definition.id, 'Form ID', 100, true)
|
|
172
|
-
if (!FORM_ID_PATTERN.test(id))
|
|
297
|
+
if (!FORM_ID_PATTERN.test(id))
|
|
298
|
+
throw new Error('The public form ID is invalid.')
|
|
173
299
|
|
|
174
300
|
const introduction = boundedText(
|
|
175
301
|
definition.introduction,
|
|
@@ -202,7 +328,9 @@ export function normalizePublicFormDefinition(definition) {
|
|
|
202
328
|
(!introduction.includes(label) ||
|
|
203
329
|
introduction.indexOf(label) !== introduction.lastIndexOf(label))
|
|
204
330
|
) {
|
|
205
|
-
throw new Error(
|
|
331
|
+
throw new Error(
|
|
332
|
+
'The inline introduction link label must appear once in the introduction.'
|
|
333
|
+
)
|
|
206
334
|
}
|
|
207
335
|
introductionLink = inline ? { label, href, inline: true } : { label, href }
|
|
208
336
|
}
|
|
@@ -234,7 +362,7 @@ export function createPublicFormValues(definition) {
|
|
|
234
362
|
return Object.fromEntries(
|
|
235
363
|
definition.fields.map(field => [
|
|
236
364
|
field.name,
|
|
237
|
-
field.type === 'checkbox' ? false : '',
|
|
365
|
+
field.type === 'file' ? [] : field.type === 'checkbox' ? false : '',
|
|
238
366
|
])
|
|
239
367
|
)
|
|
240
368
|
}
|
|
@@ -268,21 +396,47 @@ export function validatePublicFormValues(definition, sourceValues = {}) {
|
|
|
268
396
|
const values = {}
|
|
269
397
|
|
|
270
398
|
for (const field of definition.fields) {
|
|
399
|
+
if (!isPublicFormFieldVisible(field, sourceValues)) {
|
|
400
|
+
values[field.name] =
|
|
401
|
+
field.type === 'file' ? [] : field.type === 'checkbox' ? false : ''
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
const required = isPublicFormFieldRequired(field, sourceValues)
|
|
271
405
|
const rawValue = sourceValues[field.name]
|
|
272
406
|
const fieldErrors = []
|
|
273
407
|
|
|
274
|
-
if (field.type === '
|
|
408
|
+
if (field.type === 'file') {
|
|
409
|
+
const files = rawValue || []
|
|
410
|
+
values[field.name] = files
|
|
411
|
+
if (
|
|
412
|
+
!Array.isArray(files) ||
|
|
413
|
+
files.length > field.maxFiles ||
|
|
414
|
+
files.some(
|
|
415
|
+
file =>
|
|
416
|
+
!(file instanceof File) ||
|
|
417
|
+
!/\.pdf$/i.test(file.name) ||
|
|
418
|
+
(file.type && file.type !== 'application/pdf') ||
|
|
419
|
+
file.size > field.maxFileBytes ||
|
|
420
|
+
file.size === 0
|
|
421
|
+
) ||
|
|
422
|
+
files.reduce((size, file) => size + file.size, 0) >
|
|
423
|
+
field.maxTotalFileBytes
|
|
424
|
+
) {
|
|
425
|
+
fieldErrors.push(
|
|
426
|
+
'Upload up to 5 PDF documents, 5 MB each and 10 MB total.'
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
} else if (field.type === 'checkbox') {
|
|
275
430
|
const value = rawValue === true
|
|
276
431
|
values[field.name] = value
|
|
277
|
-
if (
|
|
278
|
-
fieldErrors.push(`${field.label} is required.`)
|
|
432
|
+
if (required && !value) fieldErrors.push(`${field.label} is required.`)
|
|
279
433
|
} else {
|
|
280
434
|
const hasInvalidType = rawValue != null && typeof rawValue !== 'string'
|
|
281
435
|
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
282
436
|
values[field.name] = field.type === 'email' ? value.toLowerCase() : value
|
|
283
437
|
|
|
284
438
|
if (hasInvalidType) fieldErrors.push(`${field.label} must be text.`)
|
|
285
|
-
else if (
|
|
439
|
+
else if (required && !value)
|
|
286
440
|
fieldErrors.push(`${field.label} is required.`)
|
|
287
441
|
if (value && field.minLength != null && value.length < field.minLength) {
|
|
288
442
|
fieldErrors.push(
|