You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

UnifiedSearchModal.vue 23KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  1. <template>
  2. <NcModal id="unified-search"
  3. ref="unifiedSearchModal"
  4. :show.sync="internalIsVisible"
  5. :clear-view-delay="0"
  6. @close="closeModal">
  7. <CustomDateRangeModal :is-open="showDateRangeModal"
  8. class="unified-search__date-range"
  9. @set:custom-date-range="setCustomDateRange"
  10. @update:is-open="showDateRangeModal = $event" />
  11. <!-- Unified search form -->
  12. <div ref="unifiedSearch" class="unified-search-modal">
  13. <div class="unified-search-modal__header">
  14. <h2>{{ t('core', 'Unified search') }}</h2>
  15. <NcInputField ref="searchInput"
  16. data-cy-unified-search-input
  17. :value.sync="searchQuery"
  18. type="text"
  19. :label="t('core', 'Search apps, files, tags, messages') + '...'"
  20. @update:value="debouncedFind" />
  21. <div class="unified-search-modal__filters" data-cy-unified-search-filters>
  22. <NcActions :menu-name="t('core', 'Places')" :open.sync="providerActionMenuIsOpen" data-cy-unified-search-filter="places">
  23. <template #icon>
  24. <ListBox :size="20" />
  25. </template>
  26. <!-- Provider id's may be duplicated since, plugin filters could depend on a provider that is already in the defaults.
  27. provider.id concatenated to provider.name is used to create the item id, if same then, there should be an issue. -->
  28. <NcActionButton v-for="provider in providers"
  29. :key="`${provider.id}-${provider.name.replace(/\s/g, '')}`"
  30. @click="addProviderFilter(provider)">
  31. <template #icon>
  32. <img :src="provider.icon" class="filter-button__icon" alt="">
  33. </template>
  34. {{ provider.name }}
  35. </NcActionButton>
  36. </NcActions>
  37. <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen" data-cy-unified-search-filter="date">
  38. <template #icon>
  39. <CalendarRangeIcon :size="20" />
  40. </template>
  41. <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')">
  42. {{ t('core', 'Today') }}
  43. </NcActionButton>
  44. <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')">
  45. {{ t('core', 'Last 7 days') }}
  46. </NcActionButton>
  47. <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')">
  48. {{ t('core', 'Last 30 days') }}
  49. </NcActionButton>
  50. <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')">
  51. {{ t('core', 'This year') }}
  52. </NcActionButton>
  53. <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')">
  54. {{ t('core', 'Last year') }}
  55. </NcActionButton>
  56. <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')">
  57. {{ t('core', 'Custom date range') }}
  58. </NcActionButton>
  59. </NcActions>
  60. <SearchableList :label-text="t('core', 'Search people')"
  61. :search-list="userContacts"
  62. :empty-content-text="t('core', 'Not found')"
  63. data-cy-unified-search-filter="people"
  64. @search-term-change="debouncedFilterContacts"
  65. @item-selected="applyPersonFilter">
  66. <template #trigger>
  67. <NcButton>
  68. <template #icon>
  69. <AccountGroup :size="20" />
  70. </template>
  71. {{ t('core', 'People') }}
  72. </NcButton>
  73. </template>
  74. </SearchableList>
  75. <NcButton v-if="supportFiltering" data-cy-unified-search-filter="current-view" @click="closeModal">
  76. {{ t('core', 'Filter in current view') }}
  77. <template #icon>
  78. <FilterIcon :size="20" />
  79. </template>
  80. </NcButton>
  81. </div>
  82. <div class="unified-search-modal__filters-applied">
  83. <FilterChip v-for="filter in filters"
  84. :key="filter.id"
  85. :text="filter.name ?? filter.text"
  86. :pretext="''"
  87. @delete="removeFilter(filter)">
  88. <template #icon>
  89. <NcAvatar v-if="filter.type === 'person'"
  90. :user="filter.user"
  91. :size="24"
  92. :disable-menu="true"
  93. :show-user-status="false"
  94. :hide-favorite="false" />
  95. <CalendarRangeIcon v-else-if="filter.type === 'date'" />
  96. <img v-else :src="filter.icon" alt="">
  97. </template>
  98. </FilterChip>
  99. </div>
  100. </div>
  101. <div v-if="noContentInfo.show" class="unified-search-modal__no-content">
  102. <NcEmptyContent :name="noContentInfo.text">
  103. <template #icon>
  104. <component :is="noContentInfo.icon" />
  105. </template>
  106. </NcEmptyContent>
  107. </div>
  108. <div v-else class="unified-search-modal__results">
  109. <div v-for="providerResult in results" :key="providerResult.id" class="result">
  110. <div class="result-title">
  111. <span>{{ providerResult.provider }}</span>
  112. </div>
  113. <ul class="result-items">
  114. <SearchResult v-for="(result, index) in providerResult.results" :key="index" v-bind="result" />
  115. </ul>
  116. <div class="result-footer">
  117. <NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)">
  118. {{ t('core', 'Load more results') }}
  119. <template #icon>
  120. <DotsHorizontalIcon :size="20" />
  121. </template>
  122. </NcButton>
  123. <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background">
  124. {{ t('core', 'Search in') }} {{ providerResult.provider }}
  125. <template #icon>
  126. <ArrowRight :size="20" />
  127. </template>
  128. </NcButton>
  129. </div>
  130. </div>
  131. </div>
  132. </div>
  133. </NcModal>
  134. </template>
  135. <script>
  136. import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
  137. import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
  138. import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
  139. import CustomDateRangeModal from '../components/UnifiedSearch/CustomDateRangeModal.vue'
  140. import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
  141. import FilterIcon from 'vue-material-design-icons/Filter.vue'
  142. import FilterChip from '../components/UnifiedSearch/SearchFilterChip.vue'
  143. import ListBox from 'vue-material-design-icons/ListBox.vue'
  144. import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
  145. import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
  146. import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
  147. import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
  148. import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
  149. import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
  150. import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
  151. import MagnifyIcon from 'vue-material-design-icons/Magnify.vue'
  152. import SearchableList from '../components/UnifiedSearch/SearchableList.vue'
  153. import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
  154. import debounce from 'debounce'
  155. import { emit, subscribe } from '@nextcloud/event-bus'
  156. import { useBrowserLocation } from '@vueuse/core'
  157. import { getProviders, search as unifiedSearch, getContacts } from '../services/UnifiedSearchService.js'
  158. import { useSearchStore } from '../store/unified-search-external-filters.js'
  159. export default {
  160. name: 'UnifiedSearchModal',
  161. components: {
  162. ArrowRight,
  163. AccountGroup,
  164. CalendarRangeIcon,
  165. CustomDateRangeModal,
  166. DotsHorizontalIcon,
  167. FilterIcon,
  168. FilterChip,
  169. ListBox,
  170. NcActions,
  171. NcActionButton,
  172. NcAvatar,
  173. NcButton,
  174. NcEmptyContent,
  175. NcModal,
  176. NcInputField,
  177. MagnifyIcon,
  178. SearchableList,
  179. SearchResult,
  180. },
  181. props: {
  182. isVisible: {
  183. type: Boolean,
  184. required: true,
  185. },
  186. },
  187. setup() {
  188. /**
  189. * Reactive version of window.location
  190. */
  191. const currentLocation = useBrowserLocation()
  192. const searchStore = useSearchStore()
  193. return {
  194. currentLocation,
  195. externalFilters: searchStore.externalFilters,
  196. }
  197. },
  198. data() {
  199. return {
  200. providers: [],
  201. providerActionMenuIsOpen: false,
  202. dateActionMenuIsOpen: false,
  203. providerResultLimit: 5,
  204. dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
  205. personFilter: { id: 'person', type: 'person', name: '' },
  206. dateFilterIsApplied: false,
  207. personFilterIsApplied: false,
  208. filteredProviders: [],
  209. searching: false,
  210. searchQuery: '',
  211. placessearchTerm: '',
  212. dateTimeFilter: null,
  213. filters: [],
  214. results: [],
  215. contacts: [],
  216. debouncedFind: debounce(this.find, 300),
  217. debouncedFilterContacts: debounce(this.filterContacts, 300),
  218. showDateRangeModal: false,
  219. internalIsVisible: false,
  220. }
  221. },
  222. computed: {
  223. userContacts() {
  224. return this.contacts
  225. },
  226. noContentInfo() {
  227. const isEmptySearch = this.searchQuery.length === 0
  228. const hasNoResults = this.searchQuery.length > 0 && this.results.length === 0
  229. return {
  230. show: isEmptySearch || hasNoResults,
  231. text: this.searching && hasNoResults ? t('core', 'Searching …') : (isEmptySearch ? t('core', 'Start typing to search') : t('core', 'No matching results')),
  232. icon: MagnifyIcon,
  233. }
  234. },
  235. supportFiltering() {
  236. /* Hard coded apps for the moment this would be improved in coming updates. */
  237. const providerPaths = ['/settings/users', '/apps/files', '/apps/deck']
  238. return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
  239. },
  240. },
  241. watch: {
  242. isVisible(value) {
  243. if (value) {
  244. /*
  245. * Before setting the search UI to visible, reset previous search event emissions.
  246. * This allows apps to restore defaults after "Filter in current view" if the user opens the search interface once more.
  247. * Additionally, it's a new search, so it's better to reset all previous events emitted.
  248. */
  249. emit('nextcloud:unified-search.reset', { query: '' })
  250. }
  251. this.internalIsVisible = value
  252. },
  253. internalIsVisible(value) {
  254. this.$emit('update:isVisible', value)
  255. this.$nextTick(() => {
  256. if (value) {
  257. this.focusInput()
  258. }
  259. })
  260. },
  261. },
  262. mounted() {
  263. subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter)
  264. getProviders().then((providers) => {
  265. this.providers = providers
  266. this.externalFilters.forEach(filter => {
  267. this.providers.push(filter)
  268. })
  269. this.providers = this.groupProvidersByApp(this.providers)
  270. console.debug('Search providers', this.providers)
  271. })
  272. getContacts({ searchTerm: '' }).then((contacts) => {
  273. this.contacts = this.mapContacts(contacts)
  274. console.debug('Contacts', this.contacts)
  275. })
  276. },
  277. methods: {
  278. find(query) {
  279. this.searching = true
  280. if (query.length === 0) {
  281. this.results = []
  282. this.searching = false
  283. emit('nextcloud:unified-search.reset', { query })
  284. return
  285. }
  286. emit('nextcloud:unified-search.search', { query })
  287. const newResults = []
  288. const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers
  289. const searchProvider = (provider, filters) => {
  290. const params = {
  291. type: provider.id,
  292. query,
  293. cursor: null,
  294. extraQueries: provider.extraParams,
  295. }
  296. if (filters.dateFilterIsApplied) {
  297. if (provider.filters.since && provider.filters.until) {
  298. params.since = this.dateFilter.startFrom
  299. params.until = this.dateFilter.endAt
  300. } else {
  301. // Date filter is applied but provider does not support it, no need to search provider
  302. return
  303. }
  304. }
  305. if (filters.personFilterIsApplied) {
  306. if (provider.filters.person) {
  307. params.person = this.personFilter.user
  308. } else {
  309. // Person filter is applied but provider does not support it, no need to search provider
  310. return
  311. }
  312. }
  313. if (this.providerResultLimit > 5) {
  314. params.limit = this.providerResultLimit
  315. }
  316. const request = unifiedSearch(params).request
  317. request().then((response) => {
  318. newResults.push({
  319. id: provider.id,
  320. provider: provider.name,
  321. inAppSearch: provider.inAppSearch,
  322. results: response.data.ocs.data.entries,
  323. })
  324. console.debug('New results', newResults)
  325. console.debug('Unified search results:', this.results)
  326. this.updateResults(newResults)
  327. this.searching = false
  328. })
  329. }
  330. providersToSearch.forEach(provider => {
  331. const dateFilterIsApplied = this.dateFilterIsApplied
  332. const personFilterIsApplied = this.personFilterIsApplied
  333. searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied })
  334. })
  335. },
  336. updateResults(newResults) {
  337. let updatedResults = [...this.results]
  338. // If filters are applied, remove any previous results for providers that are not in current filters
  339. if (this.filters.length > 0) {
  340. updatedResults = updatedResults.filter(result => {
  341. return this.filters.some(filter => filter.id === result.id)
  342. })
  343. }
  344. // Process the new results
  345. newResults.forEach(newResult => {
  346. const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id)
  347. if (existingResultIndex !== -1) {
  348. if (newResult.results.length === 0) {
  349. // If the new results data has no matches for and existing result, remove the existing result
  350. updatedResults.splice(existingResultIndex, 1)
  351. } else {
  352. // If input triggered a change in existing results, update existing result
  353. updatedResults.splice(existingResultIndex, 1, newResult)
  354. }
  355. } else if (newResult.results.length > 0) {
  356. // Push the new result to the array only if its results array is not empty
  357. updatedResults.push(newResult)
  358. }
  359. })
  360. const sortedResults = updatedResults.slice(0)
  361. // Order results according to provider preference
  362. sortedResults.sort((a, b) => {
  363. const aProvider = this.providers.find(provider => provider.id === a.id)
  364. const bProvider = this.providers.find(provider => provider.id === b.id)
  365. const aOrder = aProvider ? aProvider.order : 0
  366. const bOrder = bProvider ? bProvider.order : 0
  367. return aOrder - bOrder
  368. })
  369. this.results = sortedResults
  370. },
  371. mapContacts(contacts) {
  372. return contacts.map(contact => {
  373. return {
  374. // id: contact.id,
  375. // name: '',
  376. displayName: contact.fullName,
  377. isNoUser: false,
  378. subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '',
  379. icon: '',
  380. user: contact.id,
  381. }
  382. })
  383. },
  384. filterContacts(query) {
  385. getContacts({ searchTerm: query }).then((contacts) => {
  386. this.contacts = this.mapContacts(contacts)
  387. console.debug(`Contacts filtered by ${query}`, this.contacts)
  388. })
  389. },
  390. applyPersonFilter(person) {
  391. this.personFilterIsApplied = true
  392. const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id)
  393. if (existingPersonFilter === -1) {
  394. this.personFilter.id = person.id
  395. this.personFilter.user = person.user
  396. this.personFilter.name = person.displayName
  397. this.filters.push(this.personFilter)
  398. } else {
  399. this.filters[existingPersonFilter].id = person.id
  400. this.filters[existingPersonFilter].user = person.user
  401. this.filters[existingPersonFilter].name = person.displayName
  402. }
  403. this.debouncedFind(this.searchQuery)
  404. console.debug('Person filter applied', person)
  405. },
  406. loadMoreResultsForProvider(providerId) {
  407. this.providerResultLimit += 5
  408. this.filters = this.filters.filter(filter => filter.type !== 'provider')
  409. const provider = this.providers.find(provider => provider.id === providerId)
  410. this.addProviderFilter(provider, true)
  411. },
  412. addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
  413. if (!providerFilter.id) return
  414. if (providerFilter.isPluginFilter) {
  415. providerFilter.callback()
  416. }
  417. this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
  418. this.providerActionMenuIsOpen = false
  419. // With the possibility for other apps to add new filters
  420. // Resulting in a possible id/provider collision
  421. // If a user tries to apply a filter that seems to already exist, we remove the current one and add the new one.
  422. const existingFilterIndex = this.filteredProviders.findIndex(existing => existing.id === providerFilter.id)
  423. if (existingFilterIndex > -1) {
  424. this.filteredProviders.splice(existingFilterIndex, 1)
  425. this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
  426. }
  427. this.filteredProviders.push({
  428. id: providerFilter.id,
  429. name: providerFilter.name,
  430. icon: providerFilter.icon,
  431. type: providerFilter.type || 'provider',
  432. filters: providerFilter.filters,
  433. isPluginFilter: providerFilter.isPluginFilter || false,
  434. })
  435. this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
  436. console.debug('Search filters (newly added)', this.filters)
  437. this.debouncedFind(this.searchQuery)
  438. },
  439. removeFilter(filter) {
  440. if (filter.type === 'provider') {
  441. for (let i = 0; i < this.filteredProviders.length; i++) {
  442. if (this.filteredProviders[i].id === filter.id) {
  443. this.filteredProviders.splice(i, 1)
  444. break
  445. }
  446. }
  447. this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
  448. console.debug('Search filters (recently removed)', this.filters)
  449. } else {
  450. for (let i = 0; i < this.filters.length; i++) {
  451. // Remove date and person filter
  452. if (this.filters[i].id === 'date' || this.filters[i].id === filter.id) {
  453. this.dateFilterIsApplied = false
  454. this.filters.splice(i, 1)
  455. if (filter.type === 'person') {
  456. this.personFilterIsApplied = false
  457. }
  458. break
  459. }
  460. }
  461. }
  462. this.debouncedFind(this.searchQuery)
  463. },
  464. syncProviderFilters(firstArray, secondArray) {
  465. // Create a copy of the first array to avoid modifying it directly.
  466. const synchronizedArray = firstArray.slice()
  467. // Remove items from the synchronizedArray that are not in the secondArray.
  468. synchronizedArray.forEach((item, index) => {
  469. const itemId = item.id
  470. if (item.type === 'provider') {
  471. if (!secondArray.some(secondItem => secondItem.id === itemId)) {
  472. synchronizedArray.splice(index, 1)
  473. }
  474. }
  475. })
  476. // Add items to the synchronizedArray that are in the secondArray but not in the firstArray.
  477. secondArray.forEach(secondItem => {
  478. const itemId = secondItem.id
  479. if (secondItem.type === 'provider') {
  480. if (!synchronizedArray.some(item => item.id === itemId)) {
  481. synchronizedArray.push(secondItem)
  482. }
  483. }
  484. })
  485. return synchronizedArray
  486. },
  487. updateDateFilter() {
  488. const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date')
  489. if (currFilterIndex !== -1) {
  490. this.filters[currFilterIndex] = this.dateFilter
  491. } else {
  492. this.filters.push(this.dateFilter)
  493. }
  494. this.dateFilterIsApplied = true
  495. this.debouncedFind(this.searchQuery)
  496. },
  497. applyQuickDateRange(range) {
  498. this.dateActionMenuIsOpen = false
  499. const today = new Date()
  500. let startDate
  501. let endDate
  502. switch (range) {
  503. case 'today':
  504. // For 'Today', both start and end are set to today
  505. startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0)
  506. endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999)
  507. this.dateFilter.text = t('core', 'Today')
  508. break
  509. case '7days':
  510. // For 'Last 7 days', start date is 7 days ago, end is today
  511. startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0)
  512. this.dateFilter.text = t('core', 'Last 7 days')
  513. break
  514. case '30days':
  515. // For 'Last 30 days', start date is 30 days ago, end is today
  516. startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0)
  517. this.dateFilter.text = t('core', 'Last 30 days')
  518. break
  519. case 'thisyear':
  520. // For 'This year', start date is the first day of the year, end is the last day of the year
  521. startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0)
  522. endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999)
  523. this.dateFilter.text = t('core', 'This year')
  524. break
  525. case 'lastyear':
  526. // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year
  527. startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0)
  528. endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999)
  529. this.dateFilter.text = t('core', 'Last year')
  530. break
  531. case 'custom':
  532. this.showDateRangeModal = true
  533. return
  534. default:
  535. return
  536. }
  537. this.dateFilter.startFrom = startDate
  538. this.dateFilter.endAt = endDate
  539. this.updateDateFilter()
  540. },
  541. setCustomDateRange(event) {
  542. console.debug('Custom date range', event)
  543. this.dateFilter.startFrom = event.startFrom
  544. this.dateFilter.endAt = event.endAt
  545. this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
  546. this.updateDateFilter()
  547. },
  548. handlePluginFilter(addFilterEvent) {
  549. for (let i = 0; i < this.filteredProviders.length; i++) {
  550. const provider = this.filteredProviders[i]
  551. if (provider.id === addFilterEvent.id) {
  552. provider.name = addFilterEvent.filterUpdateText
  553. // Filters attached may only make sense with certain providers,
  554. // So, find the provider attached, add apply the extra parameters to those providers only
  555. const compatibleProviderIndex = this.providers.findIndex(provider => provider.id === addFilterEvent.id)
  556. if (compatibleProviderIndex > -1) {
  557. provider.extraParams = addFilterEvent.filterParams
  558. this.filteredProviders[i] = provider
  559. }
  560. break
  561. }
  562. }
  563. this.debouncedFind(this.searchQuery)
  564. },
  565. groupProvidersByApp(filters) {
  566. const groupedByProviderApp = {}
  567. filters.forEach(filter => {
  568. const provider = filter.appId ? filter.appId : 'general'
  569. if (!groupedByProviderApp[provider]) {
  570. groupedByProviderApp[provider] = []
  571. }
  572. groupedByProviderApp[provider].push(filter)
  573. })
  574. const flattenedArray = []
  575. Object.values(groupedByProviderApp).forEach(group => {
  576. flattenedArray.push(...group)
  577. })
  578. return flattenedArray
  579. },
  580. focusInput() {
  581. this.$refs.searchInput.$el.children[0].children[0].focus()
  582. },
  583. closeModal() {
  584. this.internalIsVisible = false
  585. this.searchQuery = ''
  586. },
  587. },
  588. }
  589. </script>
  590. <style lang="scss" scoped>
  591. .unified-search-modal {
  592. box-sizing: border-box;
  593. height: 100%;
  594. min-height: 80vh;
  595. display: flex;
  596. flex-direction: column;
  597. padding-block: 10px 0;
  598. // inline padding on direct children to make sure the scrollbar is on the modal container
  599. >* {
  600. padding-inline: 20px;
  601. }
  602. &__header {
  603. padding-block-end: 8px;
  604. }
  605. &__heading {
  606. font-size: 16px;
  607. font-weight: bolder;
  608. line-height: 2em;
  609. margin-bottom: 0;
  610. }
  611. &__filters {
  612. display: flex;
  613. flex-wrap: wrap;
  614. gap: 4px;
  615. justify-content: start;
  616. padding-top: 4px;
  617. }
  618. &__filters-applied {
  619. padding-top: 4px;
  620. display: flex;
  621. flex-wrap: wrap;
  622. }
  623. &__no-content {
  624. display: flex;
  625. align-items: center;
  626. height: 100%;
  627. }
  628. &__results {
  629. overflow: hidden scroll;
  630. padding-block: 0 10px;
  631. .result {
  632. &-title {
  633. span {
  634. color: var(--color-primary-element);
  635. font-weight: bolder;
  636. font-size: 16px;
  637. }
  638. }
  639. &-footer {
  640. justify-content: space-between;
  641. align-items: center;
  642. display: flex;
  643. }
  644. }
  645. }
  646. }
  647. .filter-button__icon {
  648. height: 20px;
  649. width: 20px;
  650. object-fit: contain;
  651. filter: var(--background-invert-if-bright);
  652. padding: 11px; // align with text to fit at least 44px
  653. }
  654. // Ensure modal is accessible on small devices
  655. @media only screen and (max-height: 400px) {
  656. .unified-search-modal__results {
  657. overflow: unset;
  658. }
  659. }
  660. </style>