@asd20/ui-next 2.25.2 → 2.26.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.26.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.25.3...ui-next-v2.26.0) (2026-09-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * add school transfer record request form with email workflow ([d92d458](https://github.com/academydistrict20/asd20-ui-next/commit/d92d458838303e7ae5ae600d26d6a6ce88fd748d))
9
+
10
+ ## [2.25.3](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.25.2...ui-next-v2.25.3) (2026-09-25)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * add typo-tolerant serches and common aliases to search ([49f86a1](https://github.com/academydistrict20/asd20-ui-next/commit/49f86a104c8f33b3c2e53ebee696b95dbef43014))
16
+
3
17
  ## [2.25.2](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.25.1...ui-next-v2.25.2) (2026-09-23)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.25.2",
3
+ "version": "2.26.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -43,6 +43,9 @@
43
43
 
44
44
  <form
45
45
  v-else-if="formDefinition"
46
+ :class="{
47
+ 'asd20-public-form-modal__records-form': usesRecordsFormSpacing,
48
+ }"
46
49
  novalidate
47
50
  @submit.prevent="validateBeforeCaptcha"
48
51
  >
@@ -52,6 +55,11 @@
52
55
  >
53
56
  {{ formDefinition.introduction }}
54
57
  </p>
58
+ <p v-if="formDefinition.introductionLink">
59
+ <a :href="formDefinition.introductionLink.href">
60
+ {{ formDefinition.introductionLink.label }}
61
+ </a>
62
+ </p>
55
63
 
56
64
  <div
57
65
  v-if="errorEntries.length"
@@ -77,6 +85,11 @@
77
85
  <asd20-public-form-field
78
86
  v-for="field in formDefinition.fields"
79
87
  :key="field.name"
88
+ :class="{
89
+ 'asd20-public-form-modal__requester-choice':
90
+ formDefinition.id === 'student-records-request' &&
91
+ field.name === 'requesterType',
92
+ }"
80
93
  :field="field"
81
94
  :input-id="fieldId(field.name)"
82
95
  :model-value="values[field.name]"
@@ -239,6 +252,12 @@ export default {
239
252
  modalTitle() {
240
253
  return this.formDefinition?.title || 'Public Form'
241
254
  },
255
+ usesRecordsFormSpacing() {
256
+ return [
257
+ 'student-records-request',
258
+ 'school-transfer-transcript-request',
259
+ ].includes(this.formDefinition?.id)
260
+ },
242
261
  showPristineValidationErrors() {
243
262
  return false
244
263
  },
@@ -453,6 +472,20 @@ export default {
453
472
  font-size: 1rem;
454
473
  }
455
474
 
475
+ &__records-form :deep(.asd20-text-input),
476
+ &__records-form :deep(.asd20-text-area-input) {
477
+ margin-bottom: 0.25rem;
478
+ }
479
+
480
+ &__records-form :deep(.asd20-public-form-field) {
481
+ margin-bottom: 1rem;
482
+ }
483
+
484
+ &__requester-choice.asd20-public-form-field
485
+ :deep(fieldset.asd20-public-form-field__choices label) {
486
+ align-items: flex-start;
487
+ }
488
+
456
489
  &__state,
457
490
  &__submit {
458
491
  align-items: center;
@@ -2917,11 +2917,9 @@ export default {
2917
2917
  this.skipKeywordWatcher = true
2918
2918
  this.keywords = this.limitWords(text, 4)
2919
2919
  await this.searchPages({ keepAnswersTab: false, runId })
2920
- this.searchFilesWithGroupOwners({ runId })
2921
- .then(() =>
2922
- this.selectBestKeywordTabAfterContactResults({ runId, text })
2923
- )
2924
- .catch(() => {})
2920
+ const supportingSearchPromise = this.searchFilesWithGroupOwners({
2921
+ runId,
2922
+ }).catch(() => [])
2925
2923
  if (!this.isCurrentSearchRun(runId)) return
2926
2924
  this.currentTab = 'Pages'
2927
2925
  this.syncSearchState({ text, mode, tab: 'Pages' })
@@ -2929,6 +2927,10 @@ export default {
2929
2927
 
2930
2928
  // Log keyword-only search analytics
2931
2929
  const keywordAnalyticsPages = this.getAnalyticsPages()
2930
+ await supportingSearchPromise
2931
+ if (!this.isCurrentSearchRun(runId)) return
2932
+ this.selectBestKeywordTabAfterContactResults({ runId, text })
2933
+ const resultCounts = this.getKeywordAnalyticsResultCounts()
2932
2934
  const keywordLogPages = keywordAnalyticsPages
2933
2935
  .slice(0, 10)
2934
2936
  .map(p => ({ title: p.title || '', url: p.url || '' }))
@@ -2936,7 +2938,7 @@ export default {
2936
2938
  question: text,
2937
2939
  searchType: 'keyword_only',
2938
2940
  keywords: this.keywords || text,
2939
- hadResults: keywordAnalyticsPages.length > 0,
2941
+ hadResults: resultCounts.total > 0,
2940
2942
  relatedPages: keywordLogPages,
2941
2943
  organizationId:
2942
2944
  this.organization && this.organization.id
@@ -3572,6 +3574,21 @@ export default {
3572
3574
  )
3573
3575
  },
3574
3576
 
3577
+ getKeywordAnalyticsResultCounts() {
3578
+ const pages = this.getAnalyticsPages().length
3579
+ const files = Array.isArray(this.currentFiles)
3580
+ ? this.currentFiles.length
3581
+ : 0
3582
+ const contacts = Number(this.contactResultCount) || 0
3583
+
3584
+ return {
3585
+ pages,
3586
+ files,
3587
+ contacts,
3588
+ total: pages + files + contacts,
3589
+ }
3590
+ },
3591
+
3575
3592
  limitWords(text, maxWords = 4) {
3576
3593
  return (text || '')
3577
3594
  .trim()
@@ -171,6 +171,25 @@ export function normalizePublicFormDefinition(definition) {
171
171
  const id = boundedText(definition.id, 'Form ID', 100, true)
172
172
  if (!FORM_ID_PATTERN.test(id)) throw new Error('The public form ID is invalid.')
173
173
 
174
+ let introductionLink
175
+ if (definition.introductionLink != null) {
176
+ const label = boundedText(
177
+ definition.introductionLink.label,
178
+ 'Introduction link label',
179
+ 200,
180
+ true
181
+ )
182
+ const href = boundedText(
183
+ definition.introductionLink.href,
184
+ 'Introduction link URL',
185
+ 500,
186
+ true
187
+ )
188
+ if (!/^\/[a-z0-9/-]+\/?$/.test(href) || href.startsWith('//'))
189
+ throw new Error('The introduction link must be a local page path.')
190
+ introductionLink = { label, href }
191
+ }
192
+
174
193
  return {
175
194
  id,
176
195
  version,
@@ -180,6 +199,7 @@ export function normalizePublicFormDefinition(definition) {
180
199
  'Form introduction',
181
200
  2000
182
201
  ),
202
+ introductionLink,
183
203
  submitLabel: boundedText(definition.submitLabel, 'Submit label', 100, true),
184
204
  successMessage: boundedText(
185
205
  definition.successMessage,
@@ -2,6 +2,7 @@ import axios from 'axios'
2
2
 
3
3
  import { hasSearchProxyRuntime, resolveSearchProxyUrl } from './searchProxyUrl'
4
4
  import { normalizeBaseUrl, resolveRuntimeConfigValue } from './runtimeConfig'
5
+ import { buildTypoTolerantSearchQuery } from './searchQuery'
5
6
 
6
7
  /**
7
8
  * Creates a Azure Search payload
@@ -13,13 +14,7 @@ function searchPayload(state) {
13
14
  let query = '*'
14
15
 
15
16
  if (state.enableFuzzySearch) {
16
- // Search both by exact phrase and by fuzzy search of event name
17
- query = state.keywords.trim()
18
- ? `"${state.keywords.trim()}" OR name:${state.keywords
19
- .trim()
20
- .split(' ')
21
- .join('* OR name:')}*`
22
- : '*'
17
+ query = buildTypoTolerantSearchQuery(state.keywords, 'name')
23
18
  } else {
24
19
  query = state.keywords.trim()
25
20
  }
@@ -2,6 +2,7 @@ import axios from 'axios'
2
2
 
3
3
  import { hasSearchProxyRuntime, resolveSearchProxyUrl } from './searchProxyUrl'
4
4
  import { normalizeBaseUrl, resolveRuntimeConfigValue } from './runtimeConfig'
5
+ import { buildTypoTolerantSearchQuery } from './searchQuery'
5
6
 
6
7
  /**
7
8
  * Creates a Azure Search payload
@@ -43,13 +44,7 @@ function searchPayload(state) {
43
44
  }
44
45
 
45
46
  if (state.enableFuzzySearch) {
46
- // Search both by exact phrase and by fuzzy search of event name
47
- query = state.keywords.trim()
48
- ? `"${state.keywords.trim()}" OR title:${state.keywords
49
- .trim()
50
- .split(' ')
51
- .join('* OR title:')}*`
52
- : '*'
47
+ query = buildTypoTolerantSearchQuery(state.keywords, 'title')
53
48
  } else {
54
49
  query = state.keywords.trim()
55
50
  }
@@ -2,6 +2,7 @@ import axios from 'axios'
2
2
 
3
3
  import { hasSearchProxyRuntime, resolveSearchProxyUrl } from './searchProxyUrl'
4
4
  import { resolveRuntimeConfigValue } from './runtimeConfig'
5
+ import { buildTypoTolerantSearchQuery } from './searchQuery'
5
6
 
6
7
  export const NO_PAGE_RESULTS_FALLBACKS = [
7
8
  {
@@ -56,13 +57,7 @@ function searchPayload(state) {
56
57
  let query = '*'
57
58
 
58
59
  if (state.enableFuzzySearch) {
59
- // Search both by exact phrase and by fuzzy search of event name
60
- query = state.keywords.trim()
61
- ? `"${state.keywords.trim()}" OR title:${state.keywords
62
- .trim()
63
- .split(' ')
64
- .join('* OR title:')}*`
65
- : '*'
60
+ query = buildTypoTolerantSearchQuery(state.keywords, 'title')
66
61
  } else {
67
62
  query = state.keywords.trim()
68
63
  }
@@ -0,0 +1,70 @@
1
+ const MIN_FUZZY_TOKEN_LENGTH = 5
2
+
3
+ const SEARCH_ALIASES = Object.freeze({
4
+ childfind: ['child find'],
5
+ dresscode: ['dress code'],
6
+ foodmenu: ['food menu', 'lunch menu'],
7
+ hoco: ['homecoming'],
8
+ landsharks: ['land sharks'],
9
+ merch: ['merchandise', 'spirit wear'],
10
+ powderpuff: ['powder puff'],
11
+ printshop: ['print shop'],
12
+ studenthub: ['student hub'],
13
+ })
14
+
15
+ function escapePhrase(value) {
16
+ return value.replace(/"/g, '\\"')
17
+ }
18
+
19
+ function searchTokens(value) {
20
+ return Array.from(
21
+ new Set(
22
+ String(value || '')
23
+ .split(/\s+/)
24
+ .map(token => token.replace(/[^a-z0-9-]/gi, '').trim())
25
+ .filter(Boolean)
26
+ )
27
+ )
28
+ }
29
+
30
+ function prefixClause(tokens, field = '') {
31
+ if (!tokens.length) return ''
32
+ const prefix = field ? `${field}:` : ''
33
+ return tokens.map(token => `${prefix}${token}*`).join(' AND ')
34
+ }
35
+
36
+ function fuzzyClause(tokens) {
37
+ if (!tokens.some(token => token.length >= MIN_FUZZY_TOKEN_LENGTH)) return ''
38
+ return tokens
39
+ .map(token =>
40
+ token.length >= MIN_FUZZY_TOKEN_LENGTH ? `${token}~1` : token
41
+ )
42
+ .join(' AND ')
43
+ }
44
+
45
+ export function buildTypoTolerantSearchQuery(keywords, field = '') {
46
+ const trimmed = String(keywords || '').trim()
47
+ if (!trimmed) return '*'
48
+
49
+ const tokens = searchTokens(trimmed)
50
+ if (!tokens.length) return `"${escapePhrase(trimmed)}"`
51
+
52
+ const clauses = [
53
+ `"${escapePhrase(trimmed)}"`,
54
+ prefixClause(tokens, field),
55
+ prefixClause(tokens),
56
+ fuzzyClause(tokens),
57
+ ]
58
+ const aliases = SEARCH_ALIASES[trimmed.toLowerCase()] || []
59
+
60
+ aliases.forEach(alias => {
61
+ const aliasTokens = searchTokens(alias)
62
+ clauses.push(`"${escapePhrase(alias)}"`)
63
+ clauses.push(prefixClause(aliasTokens, field))
64
+ clauses.push(prefixClause(aliasTokens))
65
+ })
66
+
67
+ return Array.from(new Set(clauses.filter(Boolean)))
68
+ .map(clause => (clause.includes(' AND ') ? `(${clause})` : clause))
69
+ .join(' OR ')
70
+ }