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.

UnifiedSearch.vue 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. <!--
  2. - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
  3. -
  4. - @author John Molakvoæ <skjnldsv@protonmail.com>
  5. -
  6. - @license GNU AGPL version 3 or any later version
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -
  21. -->
  22. <template>
  23. <HeaderMenu id="unified-search"
  24. class="unified-search"
  25. exclude-click-outside-classes="popover"
  26. :open.sync="open"
  27. @open="onOpen"
  28. @close="onClose">
  29. <!-- Header icon -->
  30. <template #trigger>
  31. <Magnify class="unified-search__trigger" :size="20" fill-color="var(--color-primary-text)" />
  32. </template>
  33. <!-- Search input -->
  34. <div class="unified-search__input-wrapper">
  35. <input ref="input"
  36. v-model="query"
  37. class="unified-search__input"
  38. type="search"
  39. :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ').toLowerCase() })"
  40. @input="onInputDebounced"
  41. @keypress.enter.prevent.stop="onInputEnter">
  42. <!-- Search filters -->
  43. <Actions v-if="availableFilters.length > 1" class="unified-search__filters" placement="bottom">
  44. <ActionButton v-for="type in availableFilters"
  45. :key="type"
  46. icon="icon-filter"
  47. :title="t('core', 'Search for {name} only', { name: typesMap[type] })"
  48. @click="onClickFilter(`in:${type}`)">
  49. {{ `in:${type}` }}
  50. </ActionButton>
  51. </Actions>
  52. </div>
  53. <template v-if="!hasResults">
  54. <!-- Loading placeholders -->
  55. <SearchResultPlaceholders v-if="isLoading" />
  56. <EmptyContent v-else-if="isValidQuery && isDoneSearching" icon="icon-search">
  57. {{ t('core', 'No results for {query}', {query}) }}
  58. </EmptyContent>
  59. <EmptyContent v-else-if="!isLoading || isShortQuery" icon="icon-search">
  60. {{ t('core', 'Start typing to search') }}
  61. <template v-if="isShortQuery" #desc>
  62. {{ n('core',
  63. 'Please enter {minSearchLength} character or more to search',
  64. 'Please enter {minSearchLength} characters or more to search',
  65. minSearchLength,
  66. {minSearchLength}) }}
  67. </template>
  68. </EmptyContent>
  69. </template>
  70. <!-- Grouped search results -->
  71. <template v-else>
  72. <ul v-for="({list, type}, typesIndex) in orderedResults"
  73. :key="type"
  74. class="unified-search__results"
  75. :class="`unified-search__results-${type}`"
  76. :aria-label="typesMap[type]">
  77. <!-- Search results -->
  78. <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
  79. <SearchResult v-bind="result"
  80. :query="query"
  81. :focused="focused === 0 && typesIndex === 0 && index === 0"
  82. @focus="setFocusedIndex" />
  83. </li>
  84. <!-- Load more button -->
  85. <li>
  86. <SearchResult v-if="!reached[type]"
  87. class="unified-search__result-more"
  88. :title="loading[type]
  89. ? t('core', 'Loading more results …')
  90. : t('core', 'Load more results')"
  91. :icon-class="loading[type] ? 'icon-loading-small' : ''"
  92. @click.prevent="loadMore(type)"
  93. @focus="setFocusedIndex" />
  94. </li>
  95. </ul>
  96. </template>
  97. </HeaderMenu>
  98. </template>
  99. <script>
  100. import { emit } from '@nextcloud/event-bus'
  101. import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot } from '../services/UnifiedSearchService'
  102. import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
  103. import Actions from '@nextcloud/vue/dist/Components/Actions'
  104. import debounce from 'debounce'
  105. import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
  106. import Magnify from 'vue-material-design-icons/Magnify'
  107. import HeaderMenu from '../components/HeaderMenu'
  108. import SearchResult from '../components/UnifiedSearch/SearchResult'
  109. import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders'
  110. export default {
  111. name: 'UnifiedSearch',
  112. components: {
  113. ActionButton,
  114. Actions,
  115. EmptyContent,
  116. HeaderMenu,
  117. Magnify,
  118. SearchResult,
  119. SearchResultPlaceholders,
  120. },
  121. data() {
  122. return {
  123. types: [],
  124. cursors: {},
  125. limits: {},
  126. loading: {},
  127. reached: {},
  128. results: {},
  129. query: '',
  130. focused: null,
  131. defaultLimit,
  132. minSearchLength,
  133. open: false,
  134. }
  135. },
  136. computed: {
  137. typesIDs() {
  138. return this.types.map(type => type.id)
  139. },
  140. typesNames() {
  141. return this.types.map(type => type.name)
  142. },
  143. typesMap() {
  144. return this.types.reduce((prev, curr) => {
  145. prev[curr.id] = curr.name
  146. return prev
  147. }, {})
  148. },
  149. /**
  150. * Is there any result to display
  151. * @returns {boolean}
  152. */
  153. hasResults() {
  154. return Object.keys(this.results).length !== 0
  155. },
  156. /**
  157. * Return ordered results
  158. * @returns {Array}
  159. */
  160. orderedResults() {
  161. return this.typesIDs
  162. .filter(type => type in this.results)
  163. .map(type => ({
  164. type,
  165. list: this.results[type],
  166. }))
  167. },
  168. /**
  169. * Available filters
  170. * We only show filters that are available on the results
  171. * @returns {string[]}
  172. */
  173. availableFilters() {
  174. return Object.keys(this.results)
  175. },
  176. /**
  177. * Applied filters
  178. * @returns {string[]}
  179. */
  180. usedFiltersIn() {
  181. let match
  182. const filters = []
  183. while ((match = regexFilterIn.exec(this.query)) !== null) {
  184. filters.push(match[1])
  185. }
  186. return filters
  187. },
  188. /**
  189. * Applied anti filters
  190. * @returns {string[]}
  191. */
  192. usedFiltersNot() {
  193. let match
  194. const filters = []
  195. while ((match = regexFilterNot.exec(this.query)) !== null) {
  196. filters.push(match[1])
  197. }
  198. return filters
  199. },
  200. /**
  201. * Is the current search too short
  202. * @returns {boolean}
  203. */
  204. isShortQuery() {
  205. return this.query && this.query.trim().length < minSearchLength
  206. },
  207. /**
  208. * Is the current search valid
  209. * @returns {boolean}
  210. */
  211. isValidQuery() {
  212. return this.query && this.query.trim() !== '' && !this.isShortQuery
  213. },
  214. /**
  215. * Have we reached the end of all types searches
  216. * @returns {boolean}
  217. */
  218. isDoneSearching() {
  219. return Object.values(this.reached).every(state => state === false)
  220. },
  221. /**
  222. * Is there any search in progress
  223. * @returns {boolean}
  224. */
  225. isLoading() {
  226. return Object.values(this.loading).some(state => state === true)
  227. },
  228. },
  229. async created() {
  230. this.types = await getTypes()
  231. console.debug('Unified Search initialized with the following providers', this.types)
  232. },
  233. mounted() {
  234. document.addEventListener('keydown', (event) => {
  235. // if not already opened, allows us to trigger default browser on second keydown
  236. if (event.ctrlKey && event.key === 'f' && !this.open) {
  237. event.preventDefault()
  238. this.open = true
  239. this.focusInput()
  240. }
  241. // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
  242. if (this.open) {
  243. // If arrow down, focus next result
  244. if (event.key === 'ArrowDown') {
  245. this.focusNext(event)
  246. }
  247. // If arrow up, focus prev result
  248. if (event.key === 'ArrowUp') {
  249. this.focusPrev(event)
  250. }
  251. }
  252. })
  253. },
  254. methods: {
  255. async onOpen() {
  256. this.focusInput()
  257. // Update types list in the background
  258. this.types = await getTypes()
  259. },
  260. onClose() {
  261. this.resetState()
  262. this.query = ''
  263. emit('nextcloud:unified-search:close')
  264. },
  265. resetState() {
  266. this.cursors = {}
  267. this.limits = {}
  268. this.loading = {}
  269. this.reached = {}
  270. this.results = {}
  271. this.focused = null
  272. },
  273. /**
  274. * Focus the search input on next tick
  275. */
  276. focusInput() {
  277. this.$nextTick(() => {
  278. this.$refs.input.focus()
  279. this.$refs.input.select()
  280. })
  281. },
  282. /**
  283. * If we have results already, open first one
  284. * If not, trigger the search again
  285. */
  286. onInputEnter() {
  287. if (this.hasResults) {
  288. const results = this.getResultsList()
  289. results[0].click()
  290. return
  291. }
  292. this.onInput()
  293. },
  294. /**
  295. * Start searching on input
  296. */
  297. async onInput() {
  298. // emit the search query
  299. emit('nextcloud:unified-search:search', { query: this.query })
  300. // Do not search if not long enough
  301. if (this.query.trim() === '' || this.isShortQuery) {
  302. return
  303. }
  304. let types = this.typesIDs
  305. let query = this.query
  306. // Filter out types
  307. if (this.usedFiltersNot.length > 0) {
  308. types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
  309. }
  310. // Only use those filters if any and check if they are valid
  311. if (this.usedFiltersIn.length > 0) {
  312. types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
  313. }
  314. // Remove any filters from the query
  315. query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
  316. console.debug('Searching', query, 'in', types)
  317. // Reset search if the query changed
  318. this.resetState()
  319. types.forEach(async type => {
  320. this.$set(this.loading, type, true)
  321. const request = await search(type, query)
  322. // Process results
  323. if (request.data.ocs.data.entries.length > 0) {
  324. this.$set(this.results, type, request.data.ocs.data.entries)
  325. } else {
  326. this.$delete(this.results, type)
  327. }
  328. // Save cursor if any
  329. if (request.data.ocs.data.cursor) {
  330. this.$set(this.cursors, type, request.data.ocs.data.cursor)
  331. } else if (!request.data.ocs.data.isPaginated) {
  332. // If no cursor and no pagination, we save the default amount
  333. // provided by server's initial state `defaultLimit`
  334. this.$set(this.limits, type, this.defaultLimit)
  335. }
  336. // Check if we reached end of pagination
  337. if (request.data.ocs.data.entries.length < this.defaultLimit) {
  338. this.$set(this.reached, type, true)
  339. }
  340. // If none already focused, focus the first rendered result
  341. if (this.focused === null) {
  342. this.focused = 0
  343. }
  344. this.$set(this.loading, type, false)
  345. })
  346. },
  347. onInputDebounced: debounce(function(e) {
  348. this.onInput(e)
  349. }, 200),
  350. /**
  351. * Load more results for the provided type
  352. * @param {String} type type
  353. */
  354. async loadMore(type) {
  355. // If already loading, ignore
  356. if (this.loading[type]) {
  357. return
  358. }
  359. this.$set(this.loading, type, true)
  360. if (this.cursors[type]) {
  361. const request = await search(type, this.query, this.cursors[type])
  362. // Save cursor if any
  363. if (request.data.ocs.data.cursor) {
  364. this.$set(this.cursors, type, request.data.ocs.data.cursor)
  365. }
  366. if (request.data.ocs.data.entries.length > 0) {
  367. this.results[type].push(...request.data.ocs.data.entries)
  368. }
  369. // Check if we reached end of pagination
  370. if (request.data.ocs.data.entries.length < this.defaultLimit) {
  371. this.$set(this.reached, type, true)
  372. }
  373. } else
  374. // If no cursor, we might have all the results already,
  375. // let's fake pagination and show the next xxx entries
  376. if (this.limits[type] && this.limits[type] >= 0) {
  377. this.limits[type] += this.defaultLimit
  378. // Check if we reached end of pagination
  379. if (this.limits[type] >= this.results[type].length) {
  380. this.$set(this.reached, type, true)
  381. }
  382. }
  383. // Focus result after render
  384. if (this.focused !== null) {
  385. this.$nextTick(() => {
  386. this.focusIndex(this.focused)
  387. })
  388. }
  389. this.$set(this.loading, type, false)
  390. },
  391. /**
  392. * Return a subset of the array if the search provider
  393. * doesn't supports pagination
  394. *
  395. * @param {Array} list the results
  396. * @param {string} type the type
  397. * @returns {Array}
  398. */
  399. limitIfAny(list, type) {
  400. if (type in this.limits) {
  401. return list.slice(0, this.limits[type])
  402. }
  403. return list
  404. },
  405. getResultsList() {
  406. return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
  407. },
  408. /**
  409. * Focus the first result if any
  410. * @param {Event} event the keydown event
  411. */
  412. focusFirst(event) {
  413. const results = this.getResultsList()
  414. if (results && results.length > 0) {
  415. if (event) {
  416. event.preventDefault()
  417. }
  418. this.focused = 0
  419. this.focusIndex(this.focused)
  420. }
  421. },
  422. /**
  423. * Focus the next result if any
  424. * @param {Event} event the keydown event
  425. */
  426. focusNext(event) {
  427. if (this.focused === null) {
  428. this.focusFirst(event)
  429. return
  430. }
  431. const results = this.getResultsList()
  432. // If we're not focusing the last, focus the next one
  433. if (results && results.length > 0 && this.focused + 1 < results.length) {
  434. event.preventDefault()
  435. this.focused++
  436. this.focusIndex(this.focused)
  437. }
  438. },
  439. /**
  440. * Focus the previous result if any
  441. * @param {Event} event the keydown event
  442. */
  443. focusPrev(event) {
  444. if (this.focused === null) {
  445. this.focusFirst(event)
  446. return
  447. }
  448. const results = this.getResultsList()
  449. // If we're not focusing the first, focus the previous one
  450. if (results && results.length > 0 && this.focused > 0) {
  451. event.preventDefault()
  452. this.focused--
  453. this.focusIndex(this.focused)
  454. }
  455. },
  456. /**
  457. * Focus the specified result index if it exists
  458. * @param {number} index the result index
  459. */
  460. focusIndex(index) {
  461. const results = this.getResultsList()
  462. if (results && results[index]) {
  463. results[index].focus()
  464. }
  465. },
  466. /**
  467. * Set the current focused element based on the target
  468. * @param {Event} event the focus event
  469. */
  470. setFocusedIndex(event) {
  471. const entry = event.target
  472. const results = this.getResultsList()
  473. const index = [...results].findIndex(search => search === entry)
  474. if (index > -1) {
  475. // let's not use focusIndex as the entry is already focused
  476. this.focused = index
  477. }
  478. },
  479. onClickFilter(filter) {
  480. this.query = `${this.query} ${filter}`
  481. .replace(/ {2}/g, ' ')
  482. .trim()
  483. this.onInput()
  484. },
  485. },
  486. }
  487. </script>
  488. <style lang="scss" scoped>
  489. $margin: 10px;
  490. $input-padding: 6px;
  491. .unified-search {
  492. &__trigger {
  493. width: 20px;
  494. height: 20px;
  495. }
  496. &__input-wrapper {
  497. width: 100%;
  498. position: sticky;
  499. // above search results
  500. z-index: 2;
  501. top: 0;
  502. display: inline-flex;
  503. align-items: center;
  504. background-color: var(--color-main-background);
  505. }
  506. &__filters {
  507. margin: $margin / 2 $margin;
  508. ul {
  509. display: inline-flex;
  510. justify-content: space-between;
  511. }
  512. }
  513. &__input {
  514. width: 100%;
  515. height: 34px;
  516. margin: $margin;
  517. padding: $input-padding;
  518. &,
  519. &[placeholder],
  520. &::placeholder {
  521. overflow: hidden;
  522. white-space: nowrap;
  523. text-overflow: ellipsis;
  524. }
  525. }
  526. &__filters {
  527. margin-right: $margin / 2;
  528. }
  529. &__results {
  530. &::before {
  531. display: block;
  532. margin: $margin;
  533. margin-left: $margin + $input-padding;
  534. content: attr(aria-label);
  535. color: var(--color-primary-element);
  536. }
  537. }
  538. .unified-search__result-more::v-deep {
  539. color: var(--color-text-maxcontrast);
  540. }
  541. .empty-content {
  542. margin: 10vh 0;
  543. }
  544. }
  545. </style>