@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.29.0",
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
  }
@@ -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: keywordsToUse ? 'pending' : 'complete',
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
- 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(
@@ -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
- if (!keywords && requireKeywords)
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
- hasSearchProxyRuntime(runtimeConfig)
155
- ? resolveSearchProxyUrl('pages', runtimeConfig)
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('Azure Search or proxy configuration is required for queryPages.')
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 = Array.isArray(data.value) ? data.value : []
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((page) => ({
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
+ }