@asd20/ui-next 2.29.0 → 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 +14 -0
- package/package.json +1 -1
- package/src/components/molecules/Asd20PublicFormField/index.vue +40 -3
- package/src/components/organisms/Asd20PublicFormModal/index.vue +44 -6
- package/src/components/organisms/Asd20SiteSearch/index.vue +20 -2
- package/src/helpers/publicFormDefinition.js +162 -8
- package/src/helpers/queryPages.js +24 -8
- package/src/helpers/supportingPages.js +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
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)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* carry ai search verified supporting pages into related pages and pages tab ([46253e0](https://github.com/academydistrict20/asd20-ui-next/commit/46253e05ef064c99dbe3151cd093f6c938e2e9f6))
|
|
16
|
+
|
|
3
17
|
# [2.29.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.28.1...ui-next-v2.29.0) (2026-10-02)
|
|
4
18
|
|
|
5
19
|
|
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
|
}
|
|
@@ -2701,6 +2701,12 @@ export default {
|
|
|
2701
2701
|
question: this.searchQuestion || this.inputText,
|
|
2702
2702
|
}
|
|
2703
2703
|
if (options.purpose) searchInput.purpose = options.purpose
|
|
2704
|
+
if (
|
|
2705
|
+
options.purpose === 'ai-related-pages' &&
|
|
2706
|
+
options.sourcePages?.length
|
|
2707
|
+
) {
|
|
2708
|
+
searchInput.sourcePages = options.sourcePages
|
|
2709
|
+
}
|
|
2704
2710
|
|
|
2705
2711
|
this.searchingPages = true
|
|
2706
2712
|
try {
|
|
@@ -3105,6 +3111,16 @@ export default {
|
|
|
3105
3111
|
? `[Keywords: ${keywordsToUse.replace(/<[^>]*>/g, '')}]`
|
|
3106
3112
|
: ''
|
|
3107
3113
|
|
|
3114
|
+
const sourcePages = Array.isArray(sources)
|
|
3115
|
+
? sources
|
|
3116
|
+
.filter(
|
|
3117
|
+
source =>
|
|
3118
|
+
typeof source?.url === 'string' &&
|
|
3119
|
+
typeof source?.chunkId === 'string'
|
|
3120
|
+
)
|
|
3121
|
+
.slice(0, 8)
|
|
3122
|
+
.map(({ url, chunkId }) => ({ url, chunkId }))
|
|
3123
|
+
: []
|
|
3108
3124
|
const assistantTurnKeywords = safeKeywords || keywordsToUse || ''
|
|
3109
3125
|
const assistantTurn = {
|
|
3110
3126
|
id: `a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
@@ -3115,7 +3131,8 @@ export default {
|
|
|
3115
3131
|
feedbackValue: null,
|
|
3116
3132
|
feedbackSubmitting: false,
|
|
3117
3133
|
relatedPages: [],
|
|
3118
|
-
relatedPagesStatus:
|
|
3134
|
+
relatedPagesStatus:
|
|
3135
|
+
keywordsToUse || sourcePages.length ? 'pending' : 'complete',
|
|
3119
3136
|
}
|
|
3120
3137
|
assistantTurnId = assistantTurn.id
|
|
3121
3138
|
this.aiTranscript = [...this.aiTranscript, assistantTurn]
|
|
@@ -3124,13 +3141,14 @@ export default {
|
|
|
3124
3141
|
this.scrollTurnToTop(this.anchoredUserTurnId, { defer: true })
|
|
3125
3142
|
}
|
|
3126
3143
|
|
|
3127
|
-
if (keywordsToUse) {
|
|
3144
|
+
if (keywordsToUse || sourcePages.length) {
|
|
3128
3145
|
this.keywordsFromAi = true
|
|
3129
3146
|
this.skipKeywordWatcher = true
|
|
3130
3147
|
this.keywords = keywordsToUse
|
|
3131
3148
|
await this.searchPages({
|
|
3132
3149
|
keepAnswersTab: true,
|
|
3133
3150
|
purpose: 'ai-related-pages',
|
|
3151
|
+
sourcePages,
|
|
3134
3152
|
runId,
|
|
3135
3153
|
})
|
|
3136
3154
|
if (!this.isCurrentSearchRun(runId)) return
|
|
@@ -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(
|
|
@@ -3,6 +3,7 @@ import axios from 'axios'
|
|
|
3
3
|
import { hasSearchProxyRuntime, resolveSearchProxyUrl } from './searchProxyUrl'
|
|
4
4
|
import { resolveRuntimeConfigValue } from './runtimeConfig'
|
|
5
5
|
import { buildTypoTolerantSearchQuery } from './searchQuery'
|
|
6
|
+
import { mergeSupportingPages } from './supportingPages'
|
|
6
7
|
|
|
7
8
|
export const NO_PAGE_RESULTS_FALLBACKS = [
|
|
8
9
|
{
|
|
@@ -100,6 +101,8 @@ function searchPayload(state) {
|
|
|
100
101
|
*/
|
|
101
102
|
export default async function queryPages({
|
|
102
103
|
keywords = '',
|
|
104
|
+
purpose = '',
|
|
105
|
+
sourcePages = [],
|
|
103
106
|
top = 25,
|
|
104
107
|
requireKeywords = false,
|
|
105
108
|
organizations = [],
|
|
@@ -111,7 +114,11 @@ export default async function queryPages({
|
|
|
111
114
|
apiVersion = '',
|
|
112
115
|
searchVersion = '',
|
|
113
116
|
}) {
|
|
114
|
-
|
|
117
|
+
const supportingSources =
|
|
118
|
+
purpose === 'ai-related-pages' && Array.isArray(sourcePages)
|
|
119
|
+
? sourcePages.slice(0, 8)
|
|
120
|
+
: []
|
|
121
|
+
if (!keywords && requireKeywords && !supportingSources.length)
|
|
115
122
|
return {
|
|
116
123
|
pages: [],
|
|
117
124
|
facets: [],
|
|
@@ -150,16 +157,17 @@ export default async function queryPages({
|
|
|
150
157
|
const resolvedRequestUrl =
|
|
151
158
|
typeof requestUrl === 'string' && requestUrl.trim()
|
|
152
159
|
? requestUrl.trim()
|
|
153
|
-
: !resolvedSearchEndpoint &&
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
: ''
|
|
160
|
+
: !resolvedSearchEndpoint && hasSearchProxyRuntime(runtimeConfig)
|
|
161
|
+
? resolveSearchProxyUrl('pages', runtimeConfig)
|
|
162
|
+
: ''
|
|
157
163
|
|
|
158
164
|
if (
|
|
159
165
|
!resolvedRequestUrl &&
|
|
160
166
|
(!resolvedSearchEndpoint || !resolvedSearchKey || !resolvedApiVersion)
|
|
161
167
|
) {
|
|
162
|
-
throw new Error(
|
|
168
|
+
throw new Error(
|
|
169
|
+
'Azure Search or proxy configuration is required for queryPages.'
|
|
170
|
+
)
|
|
163
171
|
}
|
|
164
172
|
|
|
165
173
|
const { data } = resolvedRequestUrl
|
|
@@ -170,6 +178,9 @@ export default async function queryPages({
|
|
|
170
178
|
keywords,
|
|
171
179
|
organizations,
|
|
172
180
|
top,
|
|
181
|
+
...(supportingSources.length
|
|
182
|
+
? { sourcePages: supportingSources }
|
|
183
|
+
: {}),
|
|
173
184
|
},
|
|
174
185
|
})
|
|
175
186
|
: await axios({
|
|
@@ -194,10 +205,15 @@ export default async function queryPages({
|
|
|
194
205
|
),
|
|
195
206
|
})
|
|
196
207
|
|
|
197
|
-
const pages =
|
|
208
|
+
const pages = mergeSupportingPages(
|
|
209
|
+
Array.isArray(data.value) ? data.value : [],
|
|
210
|
+
Array.isArray(data.supportingPages) ? data.supportingPages : [],
|
|
211
|
+
supportingSources,
|
|
212
|
+
organizations
|
|
213
|
+
)
|
|
198
214
|
const resolvedPages = pages.length
|
|
199
215
|
? pages
|
|
200
|
-
: NO_PAGE_RESULTS_FALLBACKS.map(
|
|
216
|
+
: NO_PAGE_RESULTS_FALLBACKS.map(page => ({
|
|
201
217
|
...page,
|
|
202
218
|
categories: Array.isArray(page.categories) ? [...page.categories] : [],
|
|
203
219
|
}))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function normalizeSupportingPageUrl(value) {
|
|
2
|
+
if (typeof value !== 'string' || value.length > 2048) return ''
|
|
3
|
+
try {
|
|
4
|
+
const url = new URL(value.trim())
|
|
5
|
+
if (
|
|
6
|
+
!['http:', 'https:'].includes(url.protocol) ||
|
|
7
|
+
url.username ||
|
|
8
|
+
url.password
|
|
9
|
+
)
|
|
10
|
+
return ''
|
|
11
|
+
url.hash = ''
|
|
12
|
+
url.pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
13
|
+
return url.toString().replace(/\/$/, '')
|
|
14
|
+
} catch (_error) {
|
|
15
|
+
return ''
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function mergeSupportingPages(
|
|
20
|
+
pages = [],
|
|
21
|
+
supportingPages = [],
|
|
22
|
+
sourcePages = [],
|
|
23
|
+
organizations = []
|
|
24
|
+
) {
|
|
25
|
+
const sourceUrls = sourcePages
|
|
26
|
+
.map(source => normalizeSupportingPageUrl(source?.url))
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
const supported = supportingPages
|
|
29
|
+
.filter(
|
|
30
|
+
page =>
|
|
31
|
+
page.type === 'page' &&
|
|
32
|
+
sourceUrls.includes(normalizeSupportingPageUrl(page.url)) &&
|
|
33
|
+
(!organizations.length ||
|
|
34
|
+
organizations.includes(page.ownerOrganizationId))
|
|
35
|
+
)
|
|
36
|
+
.sort(
|
|
37
|
+
(a, b) =>
|
|
38
|
+
sourceUrls.indexOf(normalizeSupportingPageUrl(a.url)) -
|
|
39
|
+
sourceUrls.indexOf(normalizeSupportingPageUrl(b.url))
|
|
40
|
+
)
|
|
41
|
+
if (!supported.length) return pages
|
|
42
|
+
const seen = new Set()
|
|
43
|
+
return [
|
|
44
|
+
...supported.map(page => ({ ...page, isAnswerSupportingPage: true })),
|
|
45
|
+
...pages,
|
|
46
|
+
].filter(page => {
|
|
47
|
+
const key = normalizeSupportingPageUrl(page.url) || page.id
|
|
48
|
+
if (!key) return true
|
|
49
|
+
if (seen.has(key)) return false
|
|
50
|
+
seen.add(key)
|
|
51
|
+
return true
|
|
52
|
+
})
|
|
53
|
+
}
|