@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.29.1",
3
+ "version": "2.30.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -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>{{ option.label }}</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 }}<a :href="formDefinition.introductionLink.href">{{ formDefinition.introductionLink.label }}</a>{{ inlineIntroductionParts.after }}
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 v-if="formDefinition.introductionLink && !formDefinition.introductionLink.inline">
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 formDefinition.fields"
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(fallback?.instructions || fallback?.email || fallback?.phone)
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('Verification is required before submitting this form.')
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
- ...this.values,
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
- return { label, value }
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)) throw new Error('The public form ID is invalid.')
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('The inline introduction link label must appear once in the introduction.')
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 === 'checkbox') {
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 (field.required && !value)
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 (field.required && !value)
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(