@asd20/ui 3.5.6 → 3.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui",
3
- "version": "3.5.6",
3
+ "version": "3.6.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "sideEffects": [
@@ -25,7 +25,7 @@ storiesOf('Organisms - Asd20SiteSearch', module).add(
25
25
  pages: [{title: '1', description: 'adsfasdfasf', url: 'url', tags: ['one'] }],
26
26
  files: [],
27
27
  groups: [],
28
- includeDistrictSearchResults: true,
28
+ includeDistrictResults: true,
29
29
  },
30
30
  actions: {
31
31
  queryPages({state, commit}, keywords) {
@@ -37,7 +37,7 @@ storiesOf('Organisms - Asd20SiteSearch', module).add(
37
37
  queryGroups() {
38
38
  console.log('queried groups')
39
39
  },
40
- setIncludeDistrictSearchResults() {
40
+ setIncludeDistrictResults() {
41
41
  console.log('setting district search toggle')
42
42
  }
43
43
  }
@@ -1,4 +1,4 @@
1
- \<template>
1
+ <template>
2
2
  <div
3
3
  ref="container"
4
4
  class="asd20-site-search"
@@ -8,7 +8,41 @@
8
8
  >
9
9
  <div class="asd20-site-search__viewport">
10
10
  <div class="asd20-site-search__options">
11
- <asd20-search-field idTag="-sitewide" ref="search" v-model="keywords" />
11
+ <!-- Ask a Question -->
12
+ <div class="asd20-site-search__field asd20-site-search__field--question">
13
+ <!-- <label class="asd20-site-search__label">Ask a question</label> -->
14
+ <div class="asd20-site-search__question-row">
15
+ <asd20-search-field
16
+ idTag="-question"
17
+ ref="question"
18
+ v-model="questionText"
19
+ @keyup.enter.stop.prevent="onAskQuestion"
20
+ placeholder="Ask a question."
21
+ />
22
+ <button
23
+ type="button"
24
+ class="asd20-site-search__ask-button"
25
+ @click="onAskQuestion"
26
+ :disabled="!questionText || searchingAi"
27
+ >
28
+ Ask
29
+ </button>
30
+ </div>
31
+ </div>
32
+ <hr/>
33
+
34
+ <!-- Search by Keyword -->
35
+ <div class="asd20-site-search__field asd20-site-search__field--keyword">
36
+ <!-- <label class="asd20-site-search__label">Search by keyword</label> -->
37
+ <asd20-search-field
38
+ idTag="-sitewide"
39
+ ref="search"
40
+ v-model="keywords"
41
+ placeholder="Search by keyword."
42
+ />
43
+ </div>
44
+
45
+ <!-- Only show school results -->
12
46
  <asd20-checkbox
13
47
  v-show="hasParentOrg"
14
48
  v-model="excludeDistrictResults"
@@ -17,29 +51,64 @@
17
51
  label="Only show school results"
18
52
  />
19
53
  </div>
54
+
20
55
  <asd20-tab-bar :tabs="tabs" @tabClick="onTabClick" />
56
+
21
57
  <asd20-viewport class="asd20-site-search__results" scrollable>
22
58
  <asd20-notification
23
- v-if="!keywords && !searchingFiles && !searchingPages"
24
- title="Search by Keyword"
25
- description="Type in keywords to begin searching."
59
+ v-if="!keywords && !questionText && !searchingFiles && !searchingPages && !searchingAi"
60
+ title="Ask a Question, or Search by Keyword"
61
+ description="Use the top box for plain-language questions, or search by keyword in the box below."
26
62
  />
63
+
64
+ <!-- Pages tab -->
27
65
  <div v-show="currentTab === 'Pages'" scrollable>
66
+ <!-- AI Answer Section -->
67
+ <div v-if="aiAnswer" class="asd20-site-search__ai-result">
68
+ <h3>Answer</h3>
69
+ <div class="asd20-site-search__ai-answer" v-html="aiAnswer" />
70
+
71
+ <div v-if="aiUiSources.length" class="asd20-site-search__ai-sources">
72
+ <h4>Sources</h4>
73
+ <ul>
74
+ <li v-for="src in aiUiSources" :key="src.url || src.id">
75
+ <strong>Content from: {{ src.hostLabel }}</strong><br />
76
+ <a :href="src.url" target="_blank" rel="noreferrer">
77
+ {{ src.title }}
78
+ </a>
79
+ <p v-if="src.snippet" class="asd20-site-search__ai-snippet">
80
+ {{ src.snippet }}
81
+ </p>
82
+ </li>
83
+ </ul>
84
+ </div>
85
+
86
+ <asd20-loader v-if="searchingAi" size="lg" />
87
+ </div>
88
+
89
+ <!-- No results -->
28
90
  <asd20-notification
29
- v-if="keywords && _pages.length === 0 && !searchingPages"
91
+ v-if="keywords && _pages.length === 0 && !searchingPages && !aiAnswer"
30
92
  title="No Pages Found"
31
93
  description="Try using different keywords."
32
94
  />
95
+
96
+ <!-- Results list -->
33
97
  <asd20-list @click.native="dismiss">
34
98
  <asd20-list-item
35
99
  v-for="item in pageListItems"
36
100
  :key="item.page.id"
37
- :attribution="organization.id !== '26eaf390-d8ab-11e9-a3a8-5de5bba4f125' && !excludeDistrictResults"
101
+ :attribution="
102
+ organization.id !== '26eaf390-d8ab-11e9-a3a8-5de5bba4f125' &&
103
+ !excludeDistrictResults
104
+ "
38
105
  v-bind="item"
39
106
  />
40
107
  </asd20-list>
41
108
  <asd20-loader v-if="searchingPages" size="lg" />
42
109
  </div>
110
+
111
+ <!-- Files tab -->
43
112
  <div v-show="currentTab === 'Files'" scrollable>
44
113
  <asd20-notification
45
114
  v-if="keywords && _files.length === 0 && !searchingFiles"
@@ -50,15 +119,16 @@
50
119
  <asd20-loader v-if="searchingFiles" size="lg" />
51
120
  </div>
52
121
  </asd20-viewport>
122
+
53
123
  <div class="asd20-site-search__suggested" v-if="_groups.length > 0">
54
- <h3>{{ _groups.length > 1 ? 'Suggested Contacts:' : 'Suggested Contact:' }}</h3>
124
+ <h3>
125
+ {{ _groups.length > 1 ? 'Suggested Contacts:' : 'Suggested Contact:' }}
126
+ </h3>
55
127
  <asd20-list-item
56
128
  v-for="g in _groups"
57
129
  :text-avatar="
58
130
  g.abbreviation ||
59
- ((g.shortName || g.title || '')
60
- .match(/\b([A-Z])/g) || [])
61
- .join('')
131
+ ((g.shortName || g.title || '').match(/\b([A-Z])/g) || []).join('')
62
132
  "
63
133
  :key="g.id"
64
134
  :label="`${g.title}`"
@@ -67,6 +137,7 @@
67
137
  />
68
138
  </div>
69
139
  </div>
140
+
70
141
  <asd20-modal
71
142
  :open="!!selectedGroup"
72
143
  title="Details"
@@ -130,8 +201,9 @@ export default {
130
201
  },
131
202
  'search'
132
203
  ),
204
+ // Make sure your search module state uses "includeDistrictResults"
133
205
  globalPropMixinFactory(
134
- 'includeDistrictSearchResults',
206
+ 'includeDistrictResults',
135
207
  {
136
208
  type: Boolean,
137
209
  default: true,
@@ -156,17 +228,19 @@ export default {
156
228
 
157
229
  props: {
158
230
  active: { type: Boolean, default: false },
159
- organization: {type: Object, default: {title: "Academy District 20"} },
231
+ organization: { type: Object, default: () => ({ title: 'Academy District 20' }) },
160
232
  organizationOptions: { type: Array, default: () => [] },
161
233
  },
162
234
 
163
235
  data: () => ({
164
236
  currentTab: 'Pages',
237
+ // keyword search (classic)
165
238
  keywords: '',
166
- // suggestions: [],
167
- // pages: [],
168
- // files: [],
169
- // groups: [],
239
+ // question input (AI)
240
+ questionText: '',
241
+ aiAnswer: null,
242
+ searchingAi: false,
243
+ aiSources: [],
170
244
  selectedGroup: null,
171
245
  searchingPages: false,
172
246
  searchingFiles: false,
@@ -195,7 +269,7 @@ export default {
195
269
  return {
196
270
  ...item,
197
271
  sourceTitle,
198
- logoUrl
272
+ logoUrl,
199
273
  }
200
274
  })
201
275
  : []
@@ -222,37 +296,59 @@ export default {
222
296
  return this._organization.parentOrganization !== null
223
297
  }
224
298
  return false
225
- // return true
226
299
  },
227
300
  excludeDistrictResults: {
228
301
  get() {
229
- return !this._includeDistrictSearchResults
302
+ return !this._includeDistrictResults
230
303
  },
231
304
  set(val) {
232
- this._includeDistrictSearchResults = !val
233
- }
305
+ this._includeDistrictResults = !val
306
+ },
307
+ },
308
+ // Enriched AI sources with host label
309
+ aiUiSources() {
310
+ return (this.aiSources || []).map(src => ({
311
+ ...src,
312
+ hostLabel: this.labelFromUrl(src.url),
313
+ }))
234
314
  },
235
315
  },
236
316
 
237
317
  watch: {
238
- active: function(newVal) {
318
+ active(newVal) {
239
319
  if (newVal) {
240
- this.$refs.search.$el.querySelector('input').focus()
241
- } else {
242
- this.$refs.search.$el.querySelector('input').blur()
320
+ // Focus keyword search by default for power users
321
+ if (this.$refs.search && this.$refs.search.$el) {
322
+ const input = this.$refs.search.$el.querySelector('input')
323
+ if (input) input.focus()
324
+ }
325
+ } else if (this.$refs.search && this.$refs.search.$el) {
326
+ const input = this.$refs.search.$el.querySelector('input')
327
+ if (input) input.blur()
243
328
  }
244
329
  },
245
- keywords: debounce(function(newVal, oldVal) {
330
+
331
+ // classic keyword search – unchanged behavior
332
+ keywords: debounce(function (newVal, oldVal) {
246
333
  if (newVal !== oldVal) {
247
334
  this.searchPages()
248
335
  this.searchFiles()
249
336
  this.searchGroups()
250
337
  }
251
338
  }, 500),
252
- _includeDistrictSearchResults: function() {
339
+
340
+ _includeDistrictResults() {
253
341
  this.searchPages()
254
342
  this.searchFiles()
255
343
  },
344
+
345
+ // Clear AI answer if user clears the question
346
+ questionText(newVal) {
347
+ if (!newVal) {
348
+ this.aiAnswer = null
349
+ this.aiSources = []
350
+ }
351
+ },
256
352
  },
257
353
 
258
354
  methods: {
@@ -260,7 +356,6 @@ export default {
260
356
  if (this.$store && this.$store.state.search) {
261
357
  this.searchingPages = true
262
358
  await this.$store.dispatch('search/queryPages', this.keywords)
263
- // await this.$store.dispatch('search/setPages', this.keywords)
264
359
  this.searchingPages = false
265
360
  }
266
361
  },
@@ -297,6 +392,81 @@ export default {
297
392
  this.$emit('update:active', false)
298
393
  }
299
394
  },
395
+
396
+ // Small helper to derive a host label from URL
397
+ labelFromUrl(url) {
398
+ if (!url) return 'Academy District 20'
399
+ try {
400
+ const host = new URL(url).hostname
401
+
402
+ if (host === 'www.asd20.org') return 'Academy District 20'
403
+
404
+ if (host.endsWith('.asd20.org')) {
405
+ const subdomain = host.replace('.asd20.org', '')
406
+ switch (subdomain) {
407
+ case 'rampart':
408
+ return 'Rampart High School'
409
+ case 'pinecreek':
410
+ return 'Pine Creek High School'
411
+ // add more mappings as needed
412
+ default:
413
+ return subdomain.charAt(0).toUpperCase() + subdomain.slice(1)
414
+ }
415
+ }
416
+
417
+ return host
418
+ } catch {
419
+ return 'Academy District 20'
420
+ }
421
+ },
422
+
423
+ // Called when user clicks Ask or presses Enter in question field
424
+ onAskQuestion() {
425
+ const q = (this.questionText || '').trim()
426
+ if (!q) {
427
+ this.aiAnswer = null
428
+ this.aiSources = []
429
+ return
430
+ }
431
+ this.searchAi(q)
432
+ },
433
+
434
+ async searchAi(question) {
435
+ if (!question) return
436
+ if (this.searchingAi) return
437
+
438
+ this.searchingAi = true
439
+ try {
440
+ const organizationId =
441
+ this.organization && this.organization.id
442
+ ? this.organization.id
443
+ : null
444
+ const organizationWebsite =
445
+ this.organization && this.organization.website
446
+ ? this.organization.website
447
+ : null
448
+
449
+ const { answer, sources } = await this.$store.dispatch(
450
+ 'search/queryAiSite',
451
+ {
452
+ question,
453
+ organizationId,
454
+ organizationWebsite,
455
+ includeDistrictResults: this._includeDistrictResults,
456
+ }
457
+ )
458
+
459
+ this.aiAnswer = answer
460
+ this.aiSources = sources || []
461
+ this.currentTab = 'Pages'
462
+ } catch (e) {
463
+ console.error('AI search failed', e)
464
+ this.aiAnswer = null
465
+ this.aiSources = []
466
+ } finally {
467
+ this.searchingAi = false
468
+ }
469
+ },
300
470
  },
301
471
  }
302
472
  </script>
@@ -329,16 +499,99 @@ export default {
329
499
  display: flex;
330
500
  flex-direction: column;
331
501
  flex-grow: 1;
502
+ }
503
+
504
+ &__options {
505
+ padding: space(0.5);
506
+ display: flex;
507
+ flex-direction: column;
508
+ }
509
+
510
+ &__field {
511
+ display: flex;
512
+ flex-direction: column;
513
+ flex: 1 1 auto;
514
+ }
515
+
516
+ &__label {
517
+ font-size: 0.85rem;
518
+ font-weight: 600;
519
+ margin-bottom: space(0.25);
520
+ }
521
+
522
+ &__question-row {
523
+ display: flex;
524
+ align-items: stretch;
525
+ gap: space(0.5);
526
+
332
527
  .asd20-search-field {
333
- border-bottom: 1px solid var(--color__tertiary);
528
+ flex: 1 1 auto;
529
+ border-bottom: none;
530
+ }
531
+ }
532
+
533
+ &__ask-button {
534
+ flex: 0 0 auto;
535
+ padding: 0 space(0.75);
536
+ border-radius: 999px;
537
+ border: 1px solid var(--color__primary, #22506a);
538
+ background: var(--color__primary, #22506a);
539
+ color: #fff;
540
+ font-weight: 600;
541
+ font-size: 0.9rem;
542
+ cursor: pointer;
543
+ white-space: nowrap;
544
+
545
+ &:disabled {
546
+ opacity: 0.5;
547
+ cursor: default;
334
548
  }
335
549
  }
550
+
336
551
  &__results {
337
552
  flex-grow: 1;
338
553
  }
554
+
555
+ &__ai-result {
556
+ border-bottom: 1px solid var(--color__tertiary);
557
+ padding: space(0.75) space(0.75) space(0.5);
558
+ background: var(--website-page__alternate-background-t25);
559
+ }
560
+
561
+ &__ai-answer {
562
+ margin-bottom: space(0.5);
563
+ }
564
+
565
+ &__ai-sources {
566
+ margin-top: space(0.25);
567
+
568
+ h4 {
569
+ font-size: 0.9rem;
570
+ font-weight: 600;
571
+ margin-bottom: space(0.25);
572
+ }
573
+
574
+ ul {
575
+ list-style: none;
576
+ padding-left: 0;
577
+ margin: 0;
578
+ }
579
+
580
+ li {
581
+ font-size: 0.85rem;
582
+ margin-bottom: space(0.25);
583
+ }
584
+ }
585
+
586
+ &__ai-snippet {
587
+ margin: 0;
588
+ opacity: 0.85;
589
+ }
590
+
339
591
  &__suggested {
340
592
  border-top: 2px solid var(--color__tertiary);
341
593
  background: var(--website-page__alternate-background-t50);
594
+
342
595
  h3 {
343
596
  font-size: 1rem;
344
597
  font-weight: bold;
@@ -346,14 +599,17 @@ export default {
346
599
  margin: space(0.25) 0 0 space(0.5);
347
600
  }
348
601
  }
602
+
349
603
  ::v-deep .asd20-checkbox {
350
604
  justify-content: flex-start;
605
+
351
606
  label {
352
607
  padding: space(0.5) space(0.5) space(0.5) space(1.5);
353
608
  flex-grow: 0;
354
609
  }
355
610
  }
356
611
  }
612
+
357
613
  @media (min-width: 768px) {
358
614
  .asd20-site-search {
359
615
  .asd20-notificaiton {
@@ -388,6 +644,7 @@ export default {
388
644
  margin-left: space(0.5);
389
645
  margin-right: space(-2.5);
390
646
  }
647
+
391
648
  input {
392
649
  padding: 0 space(1) 0 space(2.75);
393
650
  font-size: space(1);
@@ -397,7 +654,7 @@ export default {
397
654
 
398
655
  .asd20-notification {
399
656
  margin: space(1);
400
- flex-direction: column;
657
+ flex-direction: row;
401
658
  margin: space(0.5);
402
659
  }
403
660
  }
@@ -413,4 +670,4 @@ export default {
413
670
  border-left: 1px solid var(--color__tertiary);
414
671
  }
415
672
  }
416
- </style>
673
+ </style>
@@ -0,0 +1,45 @@
1
+ // helpers/search/queryAiSite.js
2
+ import axios from 'axios'
3
+
4
+ /**
5
+ * Calls Azure Function / API for AI site search.
6
+ * Expected response shape:
7
+ * {
8
+ * answer: string | null,
9
+ * sources: Array<{ url, title, snippet }>
10
+ * }
11
+ */
12
+ export default async function queryAiSite({
13
+ question,
14
+ organizationId = null,
15
+ organizationWebsite = null,
16
+ includeDistrictResults = true,
17
+ }) {
18
+ const q = (question || '').trim()
19
+ if (!q) {
20
+ return { answer: null, sources: [] }
21
+ }
22
+
23
+ const { data } = await axios.post(
24
+ process.env.AI_SITE_SEARCH_ENDPOINT, // e.g. https://...azurewebsites.net/api/httpAiSiteSearch
25
+ {
26
+ question: q,
27
+ organizationId,
28
+ organizationWebsite,
29
+ includeDistrictResults,
30
+ }
31
+ )
32
+
33
+ const rawSources = Array.isArray(data && data.sources) ? data.sources : []
34
+
35
+ const sources = rawSources.map((s) => ({
36
+ url: s.url,
37
+ title: s.title || s.url,
38
+ snippet: s.snippet || '',
39
+ }))
40
+
41
+ return {
42
+ answer: data && data.answer ? data.answer : null,
43
+ sources,
44
+ }
45
+ }
package/src/store.js CHANGED
@@ -36,10 +36,10 @@ const store = new Vuex.Store({
36
36
  search: {
37
37
  namespaced: true,
38
38
  state: {
39
- pages: [{one: '1'}],
40
- files: [],
39
+ pages: [{ one: '1' }],
40
+ files: [],
41
41
  groups: [],
42
- includeDistrictSearchResults: true,
42
+ includeDistrictResults: true,
43
43
  languageCode: 'es',
44
44
  isInLeadershipGroup: false,
45
45
  },
@@ -52,10 +52,14 @@ const store = new Vuex.Store({
52
52
  },
53
53
  queryGroups() {
54
54
  console.log('queried groups')
55
- }
56
- }
57
- }
58
- }
55
+ },
56
+ async queryAiSite(ctx, payload) {
57
+ // payload is { question, organizationId?, organizationWebsite?, includeDistrictResults? }
58
+ return await queryAiSiteHelper(payload)
59
+ },
60
+ },
61
+ },
62
+ },
59
63
  })
60
64
 
61
65
  export default store