+++ /dev/null
-<template>
- <NcModal v-if="isModalOpen"
- id="global-search"
- :name="t('core', 'Custom date range')"
- :show.sync="isModalOpen"
- :size="'small'"
- :clear-view-delay="0"
- :title="t('core', 'Custom date range')"
- @close="closeModal">
- <!-- Custom date range -->
- <div class="global-search-custom-date-modal">
- <h1>{{ t('core', 'Custom date range') }}</h1>
- <div class="global-search-custom-date-modal__pickers">
- <NcDateTimePicker :id="'globalsearch-custom-date-range-start'"
- v-model="dateFilter.startFrom"
- :label="t('core', 'Pick start date')"
- type="date" />
- <NcDateTimePicker :id="'globalsearch-custom-date-range-end'"
- v-model="dateFilter.endAt"
- :label="t('core', 'Pick end date')"
- type="date" />
- </div>
- <div class="global-search-custom-date-modal__footer">
- <NcButton @click="applyCustomRange">
- {{ t('core', 'Search in date range') }}
- <template #icon>
- <CalendarRangeIcon :size="20" />
- </template>
- </NcButton>
- </div>
- </div>
- </NcModal>
-</template>
-
-<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
-import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
-
-export default {
- name: 'CustomDateRangeModal',
- components: {
- NcButton,
- NcModal,
- CalendarRangeIcon,
- NcDateTimePicker,
- },
- props: {
- isOpen: {
- type: Boolean,
- required: true,
- },
- },
- data() {
- return {
- dateFilter: { startFrom: null, endAt: null },
- }
- },
- computed: {
- isModalOpen: {
- get() {
- return this.isOpen
- },
- set(value) {
- this.$emit('update:is-open', value)
- },
- },
- },
- methods: {
- closeModal() {
- this.isModalOpen = false
- },
- applyCustomRange() {
- this.$emit('set:custom-date-range', this.dateFilter)
- this.closeModal()
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.global-search-custom-date-modal {
- padding: 10px 20px 10px 20px;
-
- h1 {
- font-size: 16px;
- font-weight: bolder;
- line-height: 2em;
- }
-
- &__pickers {
- display: flex;
- flex-direction: column;
- }
-
- &__footer {
- display: flex;
- justify-content: end;
- }
-
-}
-</style>
+++ /dev/null
-<template>
- <div class="chip">
- <span class="icon">
- <slot name="icon" />
- <span v-if="pretext.length"> {{ pretext }} : </span>
- </span>
- <span class="text">{{ text }}</span>
- <span class="close-icon" @click="deleteChip">
- <CloseIcon :size="18" />
- </span>
- </div>
-</template>
-
-<script>
-import CloseIcon from 'vue-material-design-icons/Close.vue'
-
-export default {
- name: 'SearchFilterChip',
- components: {
- CloseIcon,
- },
- props: {
- text: {
- type: String,
- required: true,
- },
- pretext: {
- type: String,
- required: true,
- },
- },
- methods: {
- deleteChip() {
- this.$emit('delete', this.filter)
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.chip {
- display: flex;
- align-items: center;
- padding: 2px 4px;
- border: 1px solid var(--color-primary-element-light);
- border-radius: 20px;
- background-color: var(--color-primary-element-light);
- margin: 2px;
-
- .icon {
- display: flex;
- align-items: center;
- padding-right: 5px;
-
- img {
- width: 20px;
- padding: 2px;
- border-radius: 20px;
- filter: var(--background-invert-if-bright);
- }
- }
-
- .text {
- margin: 0 2px;
- }
-
- .close-icon {
- cursor: pointer ;
-
- :hover {
- filter: invert(20%);
- }
- }
-}
-</style>
+++ /dev/null
-<template>
- <NcListItem class="result-items__item"
- :name="title"
- :bold="false"
- :href="resourceUrl"
- target="_self">
- <template #icon>
- <div aria-hidden="true"
- class="result-items__item-icon"
- :class="{
- 'result-items__item-icon--rounded': rounded,
- 'result-items__item-icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
- 'result-items__item-icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
- [icon]: !isValidIconOrPreviewUrl(icon),
- }"
- :style="{
- backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '',
- }">
- <img v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError"
- :src="thumbnailUrl"
- @error="thumbnailErrorHandler">
- </div>
- </template>
- <template #subname>
- {{ subline }}
- </template>
- </NcListItem>
-</template>
-
-<script>
-import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
-
-export default {
- name: 'SearchResult',
- components: {
- NcListItem,
- },
- props: {
- thumbnailUrl: {
- type: String,
- default: null,
- },
- title: {
- type: String,
- required: true,
- },
- subline: {
- type: String,
- default: null,
- },
- resourceUrl: {
- type: String,
- default: null,
- },
- icon: {
- type: String,
- default: '',
- },
- rounded: {
- type: Boolean,
- default: false,
- },
- query: {
- type: String,
- default: '',
- },
-
- /**
- * Only used for the first result as a visual feedback
- * so we can keep the search input focused but pressing
- * enter still opens the first result
- */
- focused: {
- type: Boolean,
- default: false,
- },
- },
- data() {
- return {
- thumbnailHasError: false,
- }
- },
- watch: {
- thumbnailUrl() {
- this.thumbnailHasError = false
- },
- },
- methods: {
- isValidIconOrPreviewUrl(url) {
- return /^https?:\/\//.test(url) || url.startsWith('/')
- },
- thumbnailErrorHandler() {
- this.thumbnailHasError = true
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-@use "sass:math";
-$clickable-area: 44px;
-$margin: 10px;
-
-.result-items {
- &__item {
-
- ::v-deep a {
- border-radius: 12px;
- border: 2px solid transparent;
- border-radius: var(--border-radius-large) !important;
-
- &--focused {
- background-color: var(--color-background-hover);
- }
-
- &:active,
- &:hover,
- &:focus {
- background-color: var(--color-background-hover);
- border: 2px solid var(--color-border-maxcontrast);
- }
-
- * {
- cursor: pointer;
- }
-
- }
-
- &-icon {
- overflow: hidden;
- width: $clickable-area;
- height: $clickable-area;
- border-radius: var(--border-radius);
- background-repeat: no-repeat;
- background-position: center center;
- background-size: 32px;
-
- &--rounded {
- border-radius: math.div($clickable-area, 2);
- }
-
- &--no-preview {
- background-size: 32px;
- }
-
- &--with-thumbnail {
- background-size: cover;
- }
-
- &--with-thumbnail:not(&--rounded) {
- // compensate for border
- max-width: $clickable-area - 2px;
- max-height: $clickable-area - 2px;
- border: 1px solid var(--color-border);
- }
-
- img {
- // Make sure to keep ratio
- width: 100%;
- height: 100%;
-
- object-fit: cover;
- object-position: center;
- }
- }
-
- }
-}
-</style>
+++ /dev/null
-<!--
- - @copyright 2023 Marco Ambrosini <marcoambrosini@proton.me>
- -
- - @author Marco Ambrosini <marcoambrosini@proton.me>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <NcPopover :shown="opened"
- @show="opened = true"
- @hide="opened = false">
- <template #trigger>
- <slot ref="popoverTrigger" name="trigger" />
- </template>
- <div class="searchable-list__wrapper">
- <NcTextField :value.sync="searchTerm"
- :label="labelText"
- trailing-button-icon="close"
- :show-trailing-button="searchTerm !== ''"
- @trailing-button-click="clearSearch">
- <Magnify :size="20" />
- </NcTextField>
- <ul v-if="filteredList.length > 0" class="searchable-list__list">
- <li v-for="element in filteredList"
- :key="element.id"
- :title="element.displayName"
- role="button">
- <NcButton alignment="start"
- type="tertiary"
- :wide="true"
- @click="itemSelected(element)">
- <template #icon>
- <NcAvatar :user="element.user" :show-user-status="false" :hide-favorite="false" />
- </template>
- {{ element.displayName }}
- </NcButton>
- </li>
- </ul>
- <div v-else class="searchable-list__empty-content">
- <NcEmptyContent :name="emptyContentText">
- <template #icon>
- <AlertCircleOutline />
- </template>
- </NcEmptyContent>
- </div>
- </div>
- </NcPopover>
-</template>
-
-<script>
-import { NcPopover, NcTextField, NcAvatar, NcEmptyContent, NcButton } from '@nextcloud/vue'
-
-import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
-import Magnify from 'vue-material-design-icons/Magnify.vue'
-
-export default {
- name: 'SearchableList',
-
- components: {
- NcPopover,
- NcTextField,
- Magnify,
- AlertCircleOutline,
- NcAvatar,
- NcEmptyContent,
- NcButton,
- },
-
- props: {
- labelText: {
- type: String,
- default: 'this is a label',
- },
-
- searchList: {
- type: Array,
- required: true,
- },
-
- emptyContentText: {
- type: String,
- required: true,
- },
- },
-
- data() {
- return {
- opened: false,
- error: false,
- searchTerm: '',
- }
- },
-
- computed: {
- filteredList() {
- return this.searchList.filter((element) => {
- if (!this.searchTerm.toLowerCase().length) {
- return true
- }
- return ['displayName'].some(prop => element[prop].toLowerCase().includes(this.searchTerm.toLowerCase()))
- })
- },
- },
-
- methods: {
- clearSearch() {
- this.searchTerm = ''
- },
- itemSelected(element) {
- this.$emit('item-selected', element)
- this.clearSearch()
- this.opened = false
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.searchable-list {
- &__wrapper {
- padding: calc(var(--default-grid-baseline) * 3);
- display: flex;
- flex-direction: column;
- align-items: center;
- width: 250px;
- }
-
- &__list {
- width: 100%;
- max-height: 284px;
- overflow-y: auto;
- margin-top: var(--default-grid-baseline);
- padding: var(--default-grid-baseline);
-
- :deep(.button-vue) {
- border-radius: var(--border-radius-large) !important;
- span {
- font-weight: initial;
- }
- }
- }
-
- &__empty-content {
- margin-top: calc(var(--default-grid-baseline) * 3);
- }
-}
-</style>
--- /dev/null
+<template>
+ <NcModal v-if="isModalOpen"
+ id="unified-search"
+ :name="t('core', 'Custom date range')"
+ :show.sync="isModalOpen"
+ :size="'small'"
+ :clear-view-delay="0"
+ :title="t('core', 'Custom date range')"
+ @close="closeModal">
+ <!-- Custom date range -->
+ <div class="unified-search-custom-date-modal">
+ <h1>{{ t('core', 'Custom date range') }}</h1>
+ <div class="unified-search-custom-date-modal__pickers">
+ <NcDateTimePicker :id="'unifiedsearch-custom-date-range-start'"
+ v-model="dateFilter.startFrom"
+ :label="t('core', 'Pick start date')"
+ type="date" />
+ <NcDateTimePicker :id="'unifiedsearch-custom-date-range-end'"
+ v-model="dateFilter.endAt"
+ :label="t('core', 'Pick end date')"
+ type="date" />
+ </div>
+ <div class="unified-search-custom-date-modal__footer">
+ <NcButton @click="applyCustomRange">
+ {{ t('core', 'Search in date range') }}
+ <template #icon>
+ <CalendarRangeIcon :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ </div>
+ </NcModal>
+</template>
+
+<script>
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
+import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
+import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
+
+export default {
+ name: 'CustomDateRangeModal',
+ components: {
+ NcButton,
+ NcModal,
+ CalendarRangeIcon,
+ NcDateTimePicker,
+ },
+ props: {
+ isOpen: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dateFilter: { startFrom: null, endAt: null },
+ }
+ },
+ computed: {
+ isModalOpen: {
+ get() {
+ return this.isOpen
+ },
+ set(value) {
+ this.$emit('update:is-open', value)
+ },
+ },
+ },
+ methods: {
+ closeModal() {
+ this.isModalOpen = false
+ },
+ applyCustomRange() {
+ this.$emit('set:custom-date-range', this.dateFilter)
+ this.closeModal()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.unified-search-custom-date-modal {
+ padding: 10px 20px 10px 20px;
+
+ h1 {
+ font-size: 16px;
+ font-weight: bolder;
+ line-height: 2em;
+ }
+
+ &__pickers {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: end;
+ }
+
+}
+</style>
--- /dev/null
+ <!--
+ - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <a :href="resourceUrl || '#'"
+ class="unified-search__result"
+ :class="{
+ 'unified-search__result--focused': focused,
+ }"
+ @click="reEmitEvent"
+ @focus="reEmitEvent">
+
+ <!-- Icon describing the result -->
+ <div class="unified-search__result-icon"
+ :class="{
+ 'unified-search__result-icon--rounded': rounded,
+ 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded,
+ 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded,
+ [icon]: !loaded && !isIconUrl,
+ }"
+ :style="{
+ backgroundImage: isIconUrl ? `url(${icon})` : '',
+ }">
+
+ <img v-if="hasValidThumbnail"
+ v-show="loaded"
+ :src="thumbnailUrl"
+ alt=""
+ @error="onError"
+ @load="onLoad">
+ </div>
+
+ <!-- Title and sub-title -->
+ <span class="unified-search__result-content">
+ <span class="unified-search__result-line-one" :title="title">
+ <NcHighlight :text="title" :search="query" />
+ </span>
+ <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span>
+ </span>
+ </a>
+</template>
+
+<script>
+import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js'
+
+export default {
+ name: 'LegacySearchResult',
+
+ components: {
+ NcHighlight,
+ },
+
+ props: {
+ thumbnailUrl: {
+ type: String,
+ default: null,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ subline: {
+ type: String,
+ default: null,
+ },
+ resourceUrl: {
+ type: String,
+ default: null,
+ },
+ icon: {
+ type: String,
+ default: '',
+ },
+ rounded: {
+ type: Boolean,
+ default: false,
+ },
+ query: {
+ type: String,
+ default: '',
+ },
+
+ /**
+ * Only used for the first result as a visual feedback
+ * so we can keep the search input focused but pressing
+ * enter still opens the first result
+ */
+ focused: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '',
+ loaded: false,
+ }
+ },
+
+ computed: {
+ isIconUrl() {
+ // If we're facing an absolute url
+ if (this.icon.startsWith('/')) {
+ return true
+ }
+
+ // Otherwise, let's check if this is a valid url
+ try {
+ // eslint-disable-next-line no-new
+ new URL(this.icon)
+ } catch {
+ return false
+ }
+ return true
+ },
+ },
+
+ watch: {
+ // Make sure to reset state on change even when vue recycle the component
+ thumbnailUrl() {
+ this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== ''
+ this.loaded = false
+ },
+ },
+
+ methods: {
+ reEmitEvent(e) {
+ this.$emit(e.type, e)
+ },
+
+ /**
+ * If the image fails to load, fallback to iconClass
+ */
+ onError() {
+ this.hasValidThumbnail = false
+ },
+
+ onLoad() {
+ this.loaded = true
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+$clickable-area: 44px;
+$margin: 10px;
+
+.unified-search__result {
+ display: flex;
+ align-items: center;
+ height: $clickable-area;
+ padding: $margin;
+ border: 2px solid transparent;
+ border-radius: var(--border-radius-large) !important;
+
+ &--focused {
+ background-color: var(--color-background-hover);
+ }
+
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-hover);
+ border: 2px solid var(--color-border-maxcontrast);
+ }
+
+ * {
+ cursor: pointer;
+ }
+
+ &-icon {
+ overflow: hidden;
+ width: $clickable-area;
+ height: $clickable-area;
+ border-radius: var(--border-radius);
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 32px;
+ &--rounded {
+ border-radius: math.div($clickable-area, 2);
+ }
+ &--no-preview {
+ background-size: 32px;
+ }
+ &--with-thumbnail {
+ background-size: cover;
+ }
+ &--with-thumbnail:not(&--rounded) {
+ // compensate for border
+ max-width: $clickable-area - 2px;
+ max-height: $clickable-area - 2px;
+ border: 1px solid var(--color-border);
+ }
+
+ img {
+ // Make sure to keep ratio
+ width: 100%;
+ height: 100%;
+
+ object-fit: cover;
+ object-position: center;
+ }
+ }
+
+ &-icon,
+ &-actions {
+ flex: 0 0 $clickable-area;
+ }
+
+ &-content {
+ display: flex;
+ align-items: center;
+ flex: 1 1 100%;
+ flex-wrap: wrap;
+ // Set to minimum and gro from it
+ min-width: 0;
+ padding-left: $margin;
+ }
+
+ &-line-one,
+ &-line-two {
+ overflow: hidden;
+ flex: 1 1 100%;
+ margin: 1px 0;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ // Use the same color as the `a`
+ color: inherit;
+ font-size: inherit;
+ }
+ &-line-two {
+ opacity: .7;
+ font-size: var(--default-font-size);
+ }
+}
+
+</style>
--- /dev/null
+<template>
+ <div class="chip">
+ <span class="icon">
+ <slot name="icon" />
+ <span v-if="pretext.length"> {{ pretext }} : </span>
+ </span>
+ <span class="text">{{ text }}</span>
+ <span class="close-icon" @click="deleteChip">
+ <CloseIcon :size="18" />
+ </span>
+ </div>
+</template>
+
+<script>
+import CloseIcon from 'vue-material-design-icons/Close.vue'
+
+export default {
+ name: 'SearchFilterChip',
+ components: {
+ CloseIcon,
+ },
+ props: {
+ text: {
+ type: String,
+ required: true,
+ },
+ pretext: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ deleteChip() {
+ this.$emit('delete', this.filter)
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.chip {
+ display: flex;
+ align-items: center;
+ padding: 2px 4px;
+ border: 1px solid var(--color-primary-element-light);
+ border-radius: 20px;
+ background-color: var(--color-primary-element-light);
+ margin: 2px;
+
+ .icon {
+ display: flex;
+ align-items: center;
+ padding-right: 5px;
+
+ img {
+ width: 20px;
+ padding: 2px;
+ border-radius: 20px;
+ filter: var(--background-invert-if-bright);
+ }
+ }
+
+ .text {
+ margin: 0 2px;
+ }
+
+ .close-icon {
+ cursor: pointer ;
+
+ :hover {
+ filter: invert(20%);
+ }
+ }
+}
+</style>
- <!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
<template>
- <a :href="resourceUrl || '#'"
- class="unified-search__result"
- :class="{
- 'unified-search__result--focused': focused,
- }"
- @click="reEmitEvent"
- @focus="reEmitEvent">
-
- <!-- Icon describing the result -->
- <div class="unified-search__result-icon"
- :class="{
- 'unified-search__result-icon--rounded': rounded,
- 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded,
- 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded,
- [icon]: !loaded && !isIconUrl,
- }"
- :style="{
- backgroundImage: isIconUrl ? `url(${icon})` : '',
- }">
-
- <img v-if="hasValidThumbnail"
- v-show="loaded"
- :src="thumbnailUrl"
- alt=""
- @error="onError"
- @load="onLoad">
- </div>
-
- <!-- Title and sub-title -->
- <span class="unified-search__result-content">
- <span class="unified-search__result-line-one" :title="title">
- <NcHighlight :text="title" :search="query" />
- </span>
- <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span>
- </span>
- </a>
+ <NcListItem class="result-items__item"
+ :name="title"
+ :bold="false"
+ :href="resourceUrl"
+ target="_self">
+ <template #icon>
+ <div aria-hidden="true"
+ class="result-items__item-icon"
+ :class="{
+ 'result-items__item-icon--rounded': rounded,
+ 'result-items__item-icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
+ 'result-items__item-icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
+ [icon]: !isValidIconOrPreviewUrl(icon),
+ }"
+ :style="{
+ backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '',
+ }">
+ <img v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError"
+ :src="thumbnailUrl"
+ @error="thumbnailErrorHandler">
+ </div>
+ </template>
+ <template #subname>
+ {{ subline }}
+ </template>
+ </NcListItem>
</template>
<script>
-import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js'
+import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
export default {
name: 'SearchResult',
-
components: {
- NcHighlight,
+ NcListItem,
},
-
props: {
thumbnailUrl: {
type: String,
default: false,
},
},
-
data() {
return {
- hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '',
- loaded: false,
+ thumbnailHasError: false,
}
},
-
- computed: {
- isIconUrl() {
- // If we're facing an absolute url
- if (this.icon.startsWith('/')) {
- return true
- }
-
- // Otherwise, let's check if this is a valid url
- try {
- // eslint-disable-next-line no-new
- new URL(this.icon)
- } catch {
- return false
- }
- return true
- },
- },
-
watch: {
- // Make sure to reset state on change even when vue recycle the component
thumbnailUrl() {
- this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== ''
- this.loaded = false
+ this.thumbnailHasError = false
},
},
-
methods: {
- reEmitEvent(e) {
- this.$emit(e.type, e)
- },
-
- /**
- * If the image fails to load, fallback to iconClass
- */
- onError() {
- this.hasValidThumbnail = false
+ isValidIconOrPreviewUrl(url) {
+ return /^https?:\/\//.test(url) || url.startsWith('/')
},
-
- onLoad() {
- this.loaded = true
+ thumbnailErrorHandler() {
+ this.thumbnailHasError = true
},
},
}
<style lang="scss" scoped>
@use "sass:math";
-
$clickable-area: 44px;
$margin: 10px;
-.unified-search__result {
- display: flex;
- align-items: center;
- height: $clickable-area;
- padding: $margin;
- border: 2px solid transparent;
- border-radius: var(--border-radius-large) !important;
-
- &--focused {
- background-color: var(--color-background-hover);
- }
-
- &:active,
- &:hover,
- &:focus {
- background-color: var(--color-background-hover);
- border: 2px solid var(--color-border-maxcontrast);
- }
-
- * {
- cursor: pointer;
- }
-
- &-icon {
- overflow: hidden;
- width: $clickable-area;
- height: $clickable-area;
- border-radius: var(--border-radius);
- background-repeat: no-repeat;
- background-position: center center;
- background-size: 32px;
- &--rounded {
- border-radius: math.div($clickable-area, 2);
- }
- &--no-preview {
- background-size: 32px;
- }
- &--with-thumbnail {
- background-size: cover;
- }
- &--with-thumbnail:not(&--rounded) {
- // compensate for border
- max-width: $clickable-area - 2px;
- max-height: $clickable-area - 2px;
- border: 1px solid var(--color-border);
- }
-
- img {
- // Make sure to keep ratio
- width: 100%;
- height: 100%;
-
- object-fit: cover;
- object-position: center;
- }
- }
-
- &-icon,
- &-actions {
- flex: 0 0 $clickable-area;
- }
-
- &-content {
- display: flex;
- align-items: center;
- flex: 1 1 100%;
- flex-wrap: wrap;
- // Set to minimum and gro from it
- min-width: 0;
- padding-left: $margin;
- }
-
- &-line-one,
- &-line-two {
- overflow: hidden;
- flex: 1 1 100%;
- margin: 1px 0;
- white-space: nowrap;
- text-overflow: ellipsis;
- // Use the same color as the `a`
- color: inherit;
- font-size: inherit;
- }
- &-line-two {
- opacity: .7;
- font-size: var(--default-font-size);
- }
+.result-items {
+ &__item {
+
+ ::v-deep a {
+ border-radius: 12px;
+ border: 2px solid transparent;
+ border-radius: var(--border-radius-large) !important;
+
+ &--focused {
+ background-color: var(--color-background-hover);
+ }
+
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-hover);
+ border: 2px solid var(--color-border-maxcontrast);
+ }
+
+ * {
+ cursor: pointer;
+ }
+
+ }
+
+ &-icon {
+ overflow: hidden;
+ width: $clickable-area;
+ height: $clickable-area;
+ border-radius: var(--border-radius);
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 32px;
+
+ &--rounded {
+ border-radius: math.div($clickable-area, 2);
+ }
+
+ &--no-preview {
+ background-size: 32px;
+ }
+
+ &--with-thumbnail {
+ background-size: cover;
+ }
+
+ &--with-thumbnail:not(&--rounded) {
+ // compensate for border
+ max-width: $clickable-area - 2px;
+ max-height: $clickable-area - 2px;
+ border: 1px solid var(--color-border);
+ }
+
+ img {
+ // Make sure to keep ratio
+ width: 100%;
+ height: 100%;
+
+ object-fit: cover;
+ object-position: center;
+ }
+ }
+
+ }
}
-
</style>
--- /dev/null
+<!--
+ - @copyright 2023 Marco Ambrosini <marcoambrosini@proton.me>
+ -
+ - @author Marco Ambrosini <marcoambrosini@proton.me>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+-->
+
+<template>
+ <NcPopover :shown="opened"
+ @show="opened = true"
+ @hide="opened = false">
+ <template #trigger>
+ <slot ref="popoverTrigger" name="trigger" />
+ </template>
+ <div class="searchable-list__wrapper">
+ <NcTextField :value.sync="searchTerm"
+ :label="labelText"
+ trailing-button-icon="close"
+ :show-trailing-button="searchTerm !== ''"
+ @trailing-button-click="clearSearch">
+ <Magnify :size="20" />
+ </NcTextField>
+ <ul v-if="filteredList.length > 0" class="searchable-list__list">
+ <li v-for="element in filteredList"
+ :key="element.id"
+ :title="element.displayName"
+ role="button">
+ <NcButton alignment="start"
+ type="tertiary"
+ :wide="true"
+ @click="itemSelected(element)">
+ <template #icon>
+ <NcAvatar :user="element.user" :show-user-status="false" :hide-favorite="false" />
+ </template>
+ {{ element.displayName }}
+ </NcButton>
+ </li>
+ </ul>
+ <div v-else class="searchable-list__empty-content">
+ <NcEmptyContent :name="emptyContentText">
+ <template #icon>
+ <AlertCircleOutline />
+ </template>
+ </NcEmptyContent>
+ </div>
+ </div>
+ </NcPopover>
+</template>
+
+<script>
+import { NcPopover, NcTextField, NcAvatar, NcEmptyContent, NcButton } from '@nextcloud/vue'
+
+import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
+import Magnify from 'vue-material-design-icons/Magnify.vue'
+
+export default {
+ name: 'SearchableList',
+
+ components: {
+ NcPopover,
+ NcTextField,
+ Magnify,
+ AlertCircleOutline,
+ NcAvatar,
+ NcEmptyContent,
+ NcButton,
+ },
+
+ props: {
+ labelText: {
+ type: String,
+ default: 'this is a label',
+ },
+
+ searchList: {
+ type: Array,
+ required: true,
+ },
+
+ emptyContentText: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ opened: false,
+ error: false,
+ searchTerm: '',
+ }
+ },
+
+ computed: {
+ filteredList() {
+ return this.searchList.filter((element) => {
+ if (!this.searchTerm.toLowerCase().length) {
+ return true
+ }
+ return ['displayName'].some(prop => element[prop].toLowerCase().includes(this.searchTerm.toLowerCase()))
+ })
+ },
+ },
+
+ methods: {
+ clearSearch() {
+ this.searchTerm = ''
+ },
+ itemSelected(element) {
+ this.$emit('item-selected', element)
+ this.clearSearch()
+ this.opened = false
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.searchable-list {
+ &__wrapper {
+ padding: calc(var(--default-grid-baseline) * 3);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 250px;
+ }
+
+ &__list {
+ width: 100%;
+ max-height: 284px;
+ overflow-y: auto;
+ margin-top: var(--default-grid-baseline);
+ padding: var(--default-grid-baseline);
+
+ :deep(.button-vue) {
+ border-radius: var(--border-radius-large) !important;
+ span {
+ font-weight: initial;
+ }
+ }
+ }
+
+ &__empty-content {
+ margin-top: calc(var(--default-grid-baseline) * 3);
+ }
+}
+</style>
+++ /dev/null
-/**
- * @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- *
- * @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { getLoggerBuilder } from '@nextcloud/logger'
-import { getRequestToken } from '@nextcloud/auth'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import Vue from 'vue'
-
-import GlobalSearch from './views/GlobalSearch.vue'
-
-// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
-
-const logger = getLoggerBuilder()
- .setApp('global-search')
- .detectUser()
- .build()
-
-Vue.mixin({
- data() {
- return {
- logger,
- }
- },
- methods: {
- t,
- n,
- },
-})
-
-export default new Vue({
- el: '#global-search',
- // eslint-disable-next-line vue/match-component-file-name
- name: 'GlobalSearchRoot',
- render: h => h(GlobalSearch),
-})
--- /dev/null
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { getLoggerBuilder } from '@nextcloud/logger'
+import { getRequestToken } from '@nextcloud/auth'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import UnifiedSearch from './views/LegacyUnifiedSearch.vue'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = btoa(getRequestToken())
+
+const logger = getLoggerBuilder()
+ .setApp('unified-search')
+ .detectUser()
+ .build()
+
+Vue.mixin({
+ data() {
+ return {
+ logger,
+ }
+ },
+ methods: {
+ t,
+ n,
+ },
+})
+
+export default new Vue({
+ el: '#unified-search',
+ // eslint-disable-next-line vue/match-component-file-name
+ name: 'UnifiedSearchRoot',
+ render: h => h(UnifiedSearch),
+})
+++ /dev/null
-/**
- * @copyright 2023, Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- *
- * @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { generateOcsUrl, generateUrl } from '@nextcloud/router'
-import axios from '@nextcloud/axios'
-
-/**
- * Create a cancel token
- *
- * @return {import('axios').CancelTokenSource}
- */
-const createCancelToken = () => axios.CancelToken.source()
-
-/**
- * Get the list of available search providers
- *
- * @return {Promise<Array>}
- */
-export async function getProviders() {
- try {
- const { data } = await axios.get(generateOcsUrl('search/providers'), {
- params: {
- // Sending which location we're currently at
- from: window.location.pathname.replace('/index.php', '') + window.location.search,
- },
- })
- if ('ocs' in data && 'data' in data.ocs && Array.isArray(data.ocs.data) && data.ocs.data.length > 0) {
- // Providers are sorted by the api based on their order key
- return data.ocs.data
- }
- } catch (error) {
- console.error(error)
- }
- return []
-}
-
-/**
- * Get the list of available search providers
- *
- * @param {object} options destructuring object
- * @param {string} options.type the type to search
- * @param {string} options.query the search
- * @param {number|string|undefined} options.cursor the offset for paginated searches
- * @param {string} options.since the search
- * @param {string} options.until the search
- * @param {string} options.limit the search
- * @param {string} options.person the search
- * @return {object} {request: Promise, cancel: Promise}
- */
-export function search({ type, query, cursor, since, until, limit, person }) {
- /**
- * Generate an axios cancel token
- */
- const cancelToken = createCancelToken()
-
- const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), {
- cancelToken: cancelToken.token,
- params: {
- term: query,
- cursor,
- since,
- until,
- limit,
- person,
- // Sending which location we're currently at
- from: window.location.pathname.replace('/index.php', '') + window.location.search,
- },
- })
-
- return {
- request,
- cancel: cancelToken.cancel,
- }
-}
-
-/**
- * Get the list of active contacts
- *
- * @param {object} filter filter contacts by string
- * @param filter.searchTerm
- * @return {object} {request: Promise}
- */
-export async function getContacts({ searchTerm }) {
- const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
- filter: searchTerm,
- })
- return contacts
-}
--- /dev/null
+/**
+ * @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author Christoph Wurst <christoph@winzerhof-wurst.at>
+ * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
+ * @author Joas Schilling <coding@schilljs.com>
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { generateOcsUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import axios from '@nextcloud/axios'
+
+export const defaultLimit = loadState('unified-search', 'limit-default')
+export const minSearchLength = loadState('unified-search', 'min-search-length', 1)
+export const enableLiveSearch = loadState('unified-search', 'live-search', true)
+
+export const regexFilterIn = /(^|\s)in:([a-z_-]+)/ig
+export const regexFilterNot = /(^|\s)-in:([a-z_-]+)/ig
+
+/**
+ * Create a cancel token
+ *
+ * @return {import('axios').CancelTokenSource}
+ */
+const createCancelToken = () => axios.CancelToken.source()
+
+/**
+ * Get the list of available search providers
+ *
+ * @return {Promise<Array>}
+ */
+export async function getTypes() {
+ try {
+ const { data } = await axios.get(generateOcsUrl('search/providers'), {
+ params: {
+ // Sending which location we're currently at
+ from: window.location.pathname.replace('/index.php', '') + window.location.search,
+ },
+ })
+ if ('ocs' in data && 'data' in data.ocs && Array.isArray(data.ocs.data) && data.ocs.data.length > 0) {
+ // Providers are sorted by the api based on their order key
+ return data.ocs.data
+ }
+ } catch (error) {
+ console.error(error)
+ }
+ return []
+}
+
+/**
+ * Get the list of available search providers
+ *
+ * @param {object} options destructuring object
+ * @param {string} options.type the type to search
+ * @param {string} options.query the search
+ * @param {number|string|undefined} options.cursor the offset for paginated searches
+ * @return {object} {request: Promise, cancel: Promise}
+ */
+export function search({ type, query, cursor }) {
+ /**
+ * Generate an axios cancel token
+ */
+ const cancelToken = createCancelToken()
+
+ const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), {
+ cancelToken: cancelToken.token,
+ params: {
+ term: query,
+ cursor,
+ // Sending which location we're currently at
+ from: window.location.pathname.replace('/index.php', '') + window.location.search,
+ },
+ })
+
+ return {
+ request,
+ cancel: cancelToken.cancel,
+ }
+}
/**
- * @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com>
+ * @copyright 2023, Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @license AGPL-3.0-or-later
*
*
*/
-import { generateOcsUrl } from '@nextcloud/router'
-import { loadState } from '@nextcloud/initial-state'
+import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
-export const defaultLimit = loadState('unified-search', 'limit-default')
-export const minSearchLength = loadState('unified-search', 'min-search-length', 1)
-export const enableLiveSearch = loadState('unified-search', 'live-search', true)
-
-export const regexFilterIn = /(^|\s)in:([a-z_-]+)/ig
-export const regexFilterNot = /(^|\s)-in:([a-z_-]+)/ig
-
/**
* Create a cancel token
*
*
* @return {Promise<Array>}
*/
-export async function getTypes() {
+export async function getProviders() {
try {
const { data } = await axios.get(generateOcsUrl('search/providers'), {
params: {
* @param {string} options.type the type to search
* @param {string} options.query the search
* @param {number|string|undefined} options.cursor the offset for paginated searches
+ * @param {string} options.since the search
+ * @param {string} options.until the search
+ * @param {string} options.limit the search
+ * @param {string} options.person the search
* @return {object} {request: Promise, cancel: Promise}
*/
-export function search({ type, query, cursor }) {
+export function search({ type, query, cursor, since, until, limit, person }) {
/**
* Generate an axios cancel token
*/
params: {
term: query,
cursor,
+ since,
+ until,
+ limit,
+ person,
// Sending which location we're currently at
from: window.location.pathname.replace('/index.php', '') + window.location.search,
},
cancel: cancelToken.cancel,
}
}
+
+/**
+ * Get the list of active contacts
+ *
+ * @param {object} filter filter contacts by string
+ * @param filter.searchTerm
+ * @return {object} {request: Promise}
+ */
+export async function getContacts({ searchTerm }) {
+ const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
+ filter: searchTerm,
+ })
+ return contacts
+}
/**
- * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ * @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
- * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @license AGPL-3.0-or-later
*
+++ /dev/null
- <!--
- - @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- -
- - @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-<template>
- <div class="header-menu">
- <NcButton class="global-search__button" :aria-label="t('core', 'Unified search')" @click="toggleGlobalSearch">
- <template #icon>
- <Magnify class="global-search__trigger" :size="22" />
- </template>
- </NcButton>
- <GlobalSearchModal :class="'global-search-modal'" :is-visible="showGlobalSearch" @update:isVisible="handleModalVisibilityChange" />
- </div>
-</template>
-
-<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import Magnify from 'vue-material-design-icons/Magnify.vue'
-import GlobalSearchModal from './GlobalSearchModal.vue'
-
-export default {
- name: 'GlobalSearch',
- components: {
- NcButton,
- Magnify,
- GlobalSearchModal,
- },
- data() {
- return {
- showGlobalSearch: false,
- }
- },
- mounted() {
- console.debug('Global search initialized!')
- },
- methods: {
- toggleGlobalSearch() {
- this.showGlobalSearch = !this.showGlobalSearch
- },
- handleModalVisibilityChange(newVisibilityVal) {
- this.showGlobalSearch = newVisibilityVal
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.header-menu {
- display: flex;
- align-items: center;
- justify-content: center;
-
- .global-search__button {
- display: flex;
- align-items: center;
- justify-content: center;
- width: var(--header-height);
- // height: var(--header-height);
- margin: 0;
- padding: 0;
- cursor: pointer;
- opacity: .85;
- background-color: transparent;
- border: none;
- filter: none !important;
- color: var(--color-primary-text) !important;
-
- &:hover {
- background-color: transparent !important;
- }
- }
-}
-
-.global-search-modal {
- ::v-deep .modal-container {
- height: 80%;
- }
-}
-</style>
+++ /dev/null
-<template>
- <NcModal id="global-search"
- ref="globalSearchModal"
- :show.sync="internalIsVisible"
- :clear-view-delay="0"
- :title="t('Unified search')"
- @close="closeModal">
- <CustomDateRangeModal :is-open="showDateRangeModal"
- :class="'global-search__date-range'"
- @set:custom-date-range="setCustomDateRange"
- @update:is-open="showDateRangeModal = $event" />
- <!-- Global search form -->
- <div ref="globalSearch" class="global-search-modal">
- <h2 class="global-search-modal__heading">
- {{ t('core', 'Unified search') }}
- </h2>
- <NcInputField ref="searchInput"
- :value.sync="searchQuery"
- type="text"
- :label="t('core', 'Search apps, files, tags, messages') + '...'"
- @update:value="debouncedFind" />
- <div class="global-search-modal__filters">
- <NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen">
- <template #icon>
- <ListBox :size="20" />
- </template>
- <NcActionButton v-for="provider in providers" :key="provider.id" @click="addProviderFilter(provider)">
- <template #icon>
- <img :src="provider.icon">
- </template>
- {{ t('core', provider.name) }}
- </NcActionButton>
- </NcActions>
- <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen">
- <template #icon>
- <CalendarRangeIcon :size="20" />
- </template>
- <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')">
- {{ t('core', 'Today') }}
- </NcActionButton>
- <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')">
- {{ t('core', 'Last 7 days') }}
- </NcActionButton>
- <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')">
- {{ t('core', 'Last 30 days') }}
- </NcActionButton>
- <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')">
- {{ t('core', 'This year') }}
- </NcActionButton>
- <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')">
- {{ t('core', 'Last year') }}
- </NcActionButton>
- <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')">
- {{ t('core', 'Custom date range') }}
- </NcActionButton>
- </NcActions>
- <SearchableList :label-text="t('core', 'Search people')"
- :search-list="userContacts"
- :empty-content-text="t('core', 'Not found')"
- @item-selected="applyPersonFilter">
- <template #trigger>
- <NcButton>
- <template #icon>
- <AccountGroup :size="20" />
- </template>
- {{ t('core', 'People') }}
- </NcButton>
- </template>
- </SearchableList>
- </div>
- <div class="global-search-modal__filters-applied">
- <FilterChip v-for="filter in filters"
- :key="filter.id"
- :text="filter.name ?? filter.text"
- :pretext="''"
- @delete="removeFilter(filter)">
- <template #icon>
- <NcAvatar v-if="filter.type === 'person'"
- :user="filter.user"
- :size="24"
- :disable-menu="true"
- :show-user-status="false"
- :hide-favorite="false" />
- <CalendarRangeIcon v-else-if="filter.type === 'date'" />
- <img v-else :src="filter.icon" alt="">
- </template>
- </FilterChip>
- </div>
- <div v-if="noContentInfo.show" class="global-search-modal__no-content">
- <NcEmptyContent :name="noContentInfo.text">
- <template #icon>
- <component :is="noContentInfo.icon" />
- </template>
- </NcEmptyContent>
- </div>
- <div v-for="providerResult in results" :key="providerResult.id" class="global-search-modal__results">
- <div class="results">
- <div class="result-title">
- <span>{{ providerResult.provider }}</span>
- </div>
- <ul class="result-items">
- <SearchResult v-for="(result, index) in providerResult.results" :key="index" v-bind="result" />
- </ul>
- <div class="result-footer">
- <NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)">
- {{ t('core', 'Load more results') }}
- <template #icon>
- <DotsHorizontalIcon :size="20" />
- </template>
- </NcButton>
- <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background">
- {{ t('core', 'Search in') }} {{ providerResult.provider }}
- <template #icon>
- <ArrowRight :size="20" />
- </template>
- </NcButton>
- </div>
- </div>
- </div>
- <div v-if="supportFiltering()" class="global-search-modal__results">
- <NcButton @click="closeModal">
- {{ t('core', 'Filter in current view') }}
- <template #icon>
- <FilterIcon :size="20" />
- </template>
- </NcButton>
- </div>
- </div>
- </NcModal>
-</template>
-
-<script>
-import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
-import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
-import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
-import CustomDateRangeModal from '../components/GlobalSearch/CustomDateRangeModal.vue'
-import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
-import FilterIcon from 'vue-material-design-icons/Filter.vue'
-import FilterChip from '../components/GlobalSearch/SearchFilterChip.vue'
-import ListBox from 'vue-material-design-icons/ListBox.vue'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
-import MagnifyIcon from 'vue-material-design-icons/Magnify.vue'
-import SearchableList from '../components/GlobalSearch/SearchableList.vue'
-import SearchResult from '../components/GlobalSearch/SearchResult.vue'
-
-import debounce from 'debounce'
-import { emit } from '@nextcloud/event-bus'
-import { getProviders, search as globalSearch, getContacts } from '../services/GlobalSearchService.js'
-
-export default {
- name: 'GlobalSearchModal',
- components: {
- ArrowRight,
- AccountGroup,
- CalendarRangeIcon,
- CustomDateRangeModal,
- DotsHorizontalIcon,
- FilterIcon,
- FilterChip,
- ListBox,
- NcActions,
- NcActionButton,
- NcAvatar,
- NcButton,
- NcEmptyContent,
- NcModal,
- NcInputField,
- MagnifyIcon,
- SearchableList,
- SearchResult,
- },
- props: {
- isVisible: {
- type: Boolean,
- required: true,
- },
- },
- data() {
- return {
- providers: [],
- providerActionMenuIsOpen: false,
- dateActionMenuIsOpen: false,
- providerResultLimit: 5,
- dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
- personFilter: { id: 'person', type: 'person', name: '' },
- dateFilterIsApplied: false,
- personFilterIsApplied: false,
- filteredProviders: [],
- searching: false,
- searchQuery: '',
- placesFilter: '',
- dateTimeFilter: null,
- filters: [],
- results: [],
- contacts: [],
- debouncedFind: debounce(this.find, 300),
- showDateRangeModal: false,
- internalIsVisible: false,
- }
- },
-
- computed: {
- userContacts: {
- get() {
- return this.contacts
- },
- },
- noContentInfo: {
- get() {
- const isEmptySearch = this.searchQuery.length === 0
- const hasNoResults = this.searchQuery.length > 0 && this.results.length === 0
-
- return {
- show: isEmptySearch || hasNoResults,
- text: this.searching && hasNoResults ? t('core', 'Searching …') : (isEmptySearch ? t('core', 'Start typing in search') : t('core', 'No matching results')),
- icon: MagnifyIcon,
- }
- },
- },
- },
- watch: {
- isVisible(value) {
- this.internalIsVisible = value
- },
- internalIsVisible(value) {
- this.$emit('update:isVisible', value)
- this.$nextTick(() => {
- if (value) {
- this.focusInput()
- }
- })
- },
-
- },
- mounted() {
- getProviders().then((providers) => {
- this.providers = providers
- console.debug('Search providers', this.providers)
- })
- getContacts({ filter: '' }).then((contacts) => {
- this.contacts = this.mapContacts(contacts)
- console.debug('Contacts', this.contacts)
- })
- },
- methods: {
- find(query) {
- this.searching = true
- if (query.length === 0) {
- this.results = []
- this.searching = false
- return
- }
- // Event should probably be refactored at some point to used nextcloud:global-search.search
- emit('nextcloud:unified-search.search', { query })
- const newResults = []
- const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers
- const searchProvider = (provider, filters) => {
- const params = {
- type: provider.id,
- query,
- cursor: null,
- }
-
- if (filters.dateFilterIsApplied) {
- if (provider.filters.since && provider.filters.until) {
- params.since = this.dateFilter.startFrom
- params.until = this.dateFilter.endAt
- } else {
- // Date filter is applied but provider does not support it, no need to search provider
- return
- }
- }
-
- if (filters.personFilterIsApplied) {
- if (provider.filters.person) {
- params.person = this.personFilter.user
- } else {
- // Person filter is applied but provider does not support it, no need to search provider
- return
- }
- }
-
- if (this.providerResultLimit > 5) {
- params.limit = this.providerResultLimit
- }
-
- const request = globalSearch(params).request
-
- request().then((response) => {
- newResults.push({
- id: provider.id,
- provider: provider.name,
- inAppSearch: provider.inAppSearch,
- results: response.data.ocs.data.entries,
- })
-
- console.debug('New results', newResults)
- console.debug('Global search results:', this.results)
-
- this.updateResults(newResults)
- this.searching = false
- })
- }
- providersToSearch.forEach(provider => {
- const dateFilterIsApplied = this.dateFilterIsApplied
- const personFilterIsApplied = this.personFilterIsApplied
- searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied })
- })
-
- },
- updateResults(newResults) {
- let updatedResults = [...this.results]
- // If filters are applied, remove any previous results for providers that are not in current filters
- if (this.filters.length > 0) {
- updatedResults = updatedResults.filter(result => {
- return this.filters.some(filter => filter.id === result.id)
- })
- }
- // Process the new results
- newResults.forEach(newResult => {
- const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id)
- if (existingResultIndex !== -1) {
- if (newResult.results.length === 0) {
- // If the new results data has no matches for and existing result, remove the existing result
- updatedResults.splice(existingResultIndex, 1)
- } else {
- // If input triggered a change in existing results, update existing result
- updatedResults.splice(existingResultIndex, 1, newResult)
- }
- } else if (newResult.results.length > 0) {
- // Push the new result to the array only if its results array is not empty
- updatedResults.push(newResult)
- }
- })
- const sortedResults = updatedResults.slice(0)
- // Order results according to provider preference
- sortedResults.sort((a, b) => {
- const aProvider = this.providers.find(provider => provider.id === a.id)
- const bProvider = this.providers.find(provider => provider.id === b.id)
- const aOrder = aProvider ? aProvider.order : 0
- const bOrder = bProvider ? bProvider.order : 0
- return aOrder - bOrder
- })
- this.results = sortedResults
- },
- mapContacts(contacts) {
- return contacts.map(contact => {
- return {
- // id: contact.id,
- // name: '',
- displayName: contact.fullName,
- isNoUser: false,
- subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '',
- icon: '',
- user: contact.id,
- }
- })
- },
- filterContacts(query) {
- getContacts({ filter: query }).then((contacts) => {
- this.contacts = this.mapContacts(contacts)
- console.debug(`Contacts filtered by ${query}`, this.contacts)
- })
- },
- applyPersonFilter(person) {
- this.personFilterIsApplied = true
- const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id)
- if (existingPersonFilter === -1) {
- this.personFilter.id = person.id
- this.personFilter.user = person.user
- this.personFilter.name = person.displayName
- this.filters.push(this.personFilter)
- } else {
- this.filters[existingPersonFilter].id = person.id
- this.filters[existingPersonFilter].user = person.user
- this.filters[existingPersonFilter].name = person.displayName
- }
-
- this.debouncedFind(this.searchQuery)
- console.debug('Person filter applied', person)
- },
- loadMoreResultsForProvider(providerId) {
- this.providerResultLimit += 5
- this.filters = this.filters.filter(filter => filter.type !== 'provider')
- const provider = this.providers.find(provider => provider.id === providerId)
- this.addProviderFilter(provider, true)
- },
- addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
- if (!providerFilter.id) return
- this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
- this.providerActionMenuIsOpen = false
- const existingFilter = this.filteredProviders.find(existing => existing.id === providerFilter.id)
- if (!existingFilter) {
- this.filteredProviders.push({ id: providerFilter.id, name: providerFilter.name, icon: providerFilter.icon, type: 'provider', filters: providerFilter.filters })
- }
- this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
- console.debug('Search filters (newly added)', this.filters)
- this.debouncedFind(this.searchQuery)
- },
- removeFilter(filter) {
- if (filter.type === 'provider') {
- for (let i = 0; i < this.filteredProviders.length; i++) {
- if (this.filteredProviders[i].id === filter.id) {
- this.filteredProviders.splice(i, 1)
- break
- }
- }
- this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
- console.debug('Search filters (recently removed)', this.filters)
-
- } else {
- for (let i = 0; i < this.filters.length; i++) {
- // Remove date and person filter
- if (this.filters[i].id === 'date' || this.filters[i].id === filter.id) {
- this.dateFilterIsApplied = false
- this.filters.splice(i, 1)
- if (filter.type === 'person') {
- this.personFilterIsApplied = false
- }
- break
- }
- }
- }
- this.debouncedFind(this.searchQuery)
- },
- syncProviderFilters(firstArray, secondArray) {
- // Create a copy of the first array to avoid modifying it directly.
- const synchronizedArray = firstArray.slice()
- // Remove items from the synchronizedArray that are not in the secondArray.
- synchronizedArray.forEach((item, index) => {
- const itemId = item.id
- if (item.type === 'provider') {
- if (!secondArray.some(secondItem => secondItem.id === itemId)) {
- synchronizedArray.splice(index, 1)
- }
- }
- })
- // Add items to the synchronizedArray that are in the secondArray but not in the firstArray.
- secondArray.forEach(secondItem => {
- const itemId = secondItem.id
- if (secondItem.type === 'provider') {
- if (!synchronizedArray.some(item => item.id === itemId)) {
- synchronizedArray.push(secondItem)
- }
- }
- })
-
- return synchronizedArray
- },
- updateDateFilter() {
- const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date')
- if (currFilterIndex !== -1) {
- this.filters[currFilterIndex] = this.dateFilter
- } else {
- this.filters.push(this.dateFilter)
- }
- this.dateFilterIsApplied = true
- this.debouncedFind(this.searchQuery)
- },
- applyQuickDateRange(range) {
- this.dateActionMenuIsOpen = false
- const today = new Date()
- let startDate
- let endDate
-
- switch (range) {
- case 'today':
- // For 'Today', both start and end are set to today
- startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0)
- endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999)
- this.dateFilter.text = t('core', 'Today')
- break
- case '7days':
- // For 'Last 7 days', start date is 7 days ago, end is today
- startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0)
- this.dateFilter.text = t('core', 'Last 7 days')
- break
- case '30days':
- // For 'Last 30 days', start date is 30 days ago, end is today
- startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0)
- this.dateFilter.text = t('core', 'Last 30 days')
- break
- case 'thisyear':
- // For 'This year', start date is the first day of the year, end is the last day of the year
- startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0)
- endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999)
- this.dateFilter.text = t('core', 'This year')
- break
- case 'lastyear':
- // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year
- startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0)
- endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999)
- this.dateFilter.text = t('core', 'Last year')
- break
- case 'custom':
- this.showDateRangeModal = true
- return
- default:
- return
- }
- this.dateFilter.startFrom = startDate
- this.dateFilter.endAt = endDate
- this.updateDateFilter()
-
- },
- setCustomDateRange(event) {
- console.debug('Custom date range', event)
- this.dateFilter.startFrom = event.startFrom
- this.dateFilter.endAt = event.endAt
- this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
- this.updateDateFilter()
- },
- focusInput() {
- this.$refs.searchInput.$el.children[0].children[0].focus()
- },
- closeModal() {
- this.internalIsVisible = false
- this.searchQuery = ''
- },
- supportFiltering() {
- /* Hard coded apps for the moment this would be improved in coming updates. */
- const providerPaths = ['/settings/users', '/apps/files', '/apps/deck']
- const currentPath = window.location.pathname.replace('/index.php', '')
- const containsProvider = providerPaths.some(path => currentPath.includes(path))
- return containsProvider
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.global-search-modal {
- padding: 10px 20px 10px 20px;
- height: 60%;
-
- &__heading {
- font-size: 16px;
- font-weight: bolder;
- line-height: 2em;
- margin-bottom: 0;
- }
-
- &__filters {
- display: flex;
- padding-top: 4px;
- justify-content: left;
-
- >* {
- margin-right: 4px;
-
- }
-
- }
-
- &__filters-applied {
- padding-top: 4px;
- display: flex;
- flex-wrap: wrap;
- }
-
- &__no-content {
- display: flex;
- align-items: center;
- height: 100%;
- }
-
- &__results {
- padding: 10px;
-
- .results {
-
- .result-title {
- span {
- color: var(--color-primary-element);
- font-weight: bolder;
- font-size: 16px;
- }
- }
-
- .result-footer {
- justify-content: space-between;
- align-items: center;
- display: flex;
- }
- }
-
- }
-}
-
-div.v-popper__wrapper {
- ul {
- li {
- ::v-deep button.action-button {
- align-items: center !important;
-
- img {
- width: 20px;
- margin: 0 4px;
- filter: var(--background-invert-if-bright);
- }
-
- }
- }
- }
-}
-</style>
--- /dev/null
+ <!--
+ - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <NcHeaderMenu id="unified-search"
+ class="unified-search"
+ :exclude-click-outside-selectors="['.popover']"
+ :open.sync="open"
+ :aria-label="ariaLabel"
+ @open="onOpen"
+ @close="onClose">
+ <!-- Header icon -->
+ <template #trigger>
+ <Magnify class="unified-search__trigger"
+ :size="22/* fit better next to other 20px icons */" />
+ </template>
+
+ <!-- Search form & filters wrapper -->
+ <div class="unified-search__input-wrapper">
+ <div class="unified-search__input-row">
+ <NcTextField ref="input"
+ :value.sync="query"
+ trailing-button-icon="close"
+ :label="ariaLabel"
+ :trailing-button-label="t('core','Reset search')"
+ :show-trailing-button="query !== ''"
+ aria-describedby="unified-search-desc"
+ class="unified-search__form-input"
+ :class="{'unified-search__form-input--with-reset': !!query}"
+ :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
+ @trailing-button-click="onReset"
+ @input="onInputDebounced" />
+ <p id="unified-search-desc" class="hidden-visually">
+ {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
+ </p>
+
+ <!-- Search filters -->
+ <NcActions v-if="availableFilters.length > 1"
+ class="unified-search__filters"
+ placement="bottom-end"
+ container=".unified-search__input-wrapper">
+ <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
+ <NcActionButton v-for="filter in availableFilters"
+ :key="filter"
+ icon="icon-filter"
+ @click.stop="onClickFilter(`in:${filter}`)">
+ {{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }}
+ </NcActionButton>
+ </NcActions>
+ </div>
+ </div>
+
+ <template v-if="!hasResults">
+ <!-- Loading placeholders -->
+ <SearchResultPlaceholders v-if="isLoading" />
+
+ <NcEmptyContent v-else-if="isValidQuery"
+ :title="validQueryTitle">
+ <template #icon>
+ <Magnify />
+ </template>
+ </NcEmptyContent>
+
+ <NcEmptyContent v-else-if="!isLoading || isShortQuery"
+ :title="t('core', 'Start typing to search')"
+ :description="shortQueryDescription">
+ <template #icon>
+ <Magnify />
+ </template>
+ </NcEmptyContent>
+ </template>
+
+ <!-- Grouped search results -->
+ <template v-for="({list, type}, typesIndex) in orderedResults" v-else>
+ <h2 :key="type" class="unified-search__results-header">
+ {{ typesMap[type] }}
+ </h2>
+ <ul :key="type"
+ class="unified-search__results"
+ :class="`unified-search__results-${type}`"
+ :aria-label="typesMap[type]">
+ <!-- Search results -->
+ <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
+ <SearchResult v-bind="result"
+ :query="query"
+ :focused="focused === 0 && typesIndex === 0 && index === 0"
+ @focus="setFocusedIndex" />
+ </li>
+
+ <!-- Load more button -->
+ <li>
+ <SearchResult v-if="!reached[type]"
+ class="unified-search__result-more"
+ :title="loading[type]
+ ? t('core', 'Loading more results …')
+ : t('core', 'Load more results')"
+ :icon-class="loading[type] ? 'icon-loading-small' : ''"
+ @click.prevent.stop="loadMore(type)"
+ @focus="setFocusedIndex" />
+ </li>
+ </ul>
+ </template>
+ </NcHeaderMenu>
+</template>
+
+<script>
+import debounce from 'debounce'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { showError } from '@nextcloud/dialogs'
+
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+
+import Magnify from 'vue-material-design-icons/Magnify.vue'
+
+import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue'
+import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
+
+import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/LegacyUnifiedSearchService.js'
+
+const REQUEST_FAILED = 0
+const REQUEST_OK = 1
+const REQUEST_CANCELED = 2
+
+export default {
+ name: 'LegacyUnifiedSearch',
+
+ components: {
+ Magnify,
+ NcActionButton,
+ NcActions,
+ NcEmptyContent,
+ NcHeaderMenu,
+ SearchResult,
+ SearchResultPlaceholders,
+ NcTextField,
+ },
+
+ data() {
+ return {
+ types: [],
+
+ // Cursors per types
+ cursors: {},
+ // Various search limits per types
+ limits: {},
+ // Loading types
+ loading: {},
+ // Reached search types
+ reached: {},
+ // Pending cancellable requests
+ requests: [],
+ // List of all results
+ results: {},
+
+ query: '',
+ focused: null,
+ triggered: false,
+
+ defaultLimit,
+ minSearchLength,
+ enableLiveSearch,
+
+ open: false,
+ }
+ },
+
+ computed: {
+ typesIDs() {
+ return this.types.map(type => type.id)
+ },
+ typesNames() {
+ return this.types.map(type => type.name)
+ },
+ typesMap() {
+ return this.types.reduce((prev, curr) => {
+ prev[curr.id] = curr.name
+ return prev
+ }, {})
+ },
+
+ ariaLabel() {
+ return t('core', 'Search')
+ },
+
+ /**
+ * Is there any result to display
+ *
+ * @return {boolean}
+ */
+ hasResults() {
+ return Object.keys(this.results).length !== 0
+ },
+
+ /**
+ * Return ordered results
+ *
+ * @return {Array}
+ */
+ orderedResults() {
+ return this.typesIDs
+ .filter(type => type in this.results)
+ .map(type => ({
+ type,
+ list: this.results[type],
+ }))
+ },
+
+ /**
+ * Available filters
+ * We only show filters that are available on the results
+ *
+ * @return {string[]}
+ */
+ availableFilters() {
+ return Object.keys(this.results)
+ },
+
+ /**
+ * Applied filters
+ *
+ * @return {string[]}
+ */
+ usedFiltersIn() {
+ let match
+ const filters = []
+ while ((match = regexFilterIn.exec(this.query)) !== null) {
+ filters.push(match[2])
+ }
+ return filters
+ },
+
+ /**
+ * Applied anti filters
+ *
+ * @return {string[]}
+ */
+ usedFiltersNot() {
+ let match
+ const filters = []
+ while ((match = regexFilterNot.exec(this.query)) !== null) {
+ filters.push(match[2])
+ }
+ return filters
+ },
+
+ /**
+ * Valid query empty content title
+ *
+ * @return {string}
+ */
+ validQueryTitle() {
+ return this.triggered
+ ? t('core', 'No results for {query}', { query: this.query })
+ : t('core', 'Press Enter to start searching')
+ },
+
+ /**
+ * Short query empty content description
+ *
+ * @return {string}
+ */
+ shortQueryDescription() {
+ if (!this.isShortQuery) {
+ return ''
+ }
+
+ return n('core',
+ 'Please enter {minSearchLength} character or more to search',
+ 'Please enter {minSearchLength} characters or more to search',
+ this.minSearchLength,
+ { minSearchLength: this.minSearchLength })
+ },
+
+ /**
+ * Is the current search too short
+ *
+ * @return {boolean}
+ */
+ isShortQuery() {
+ return this.query && this.query.trim().length < minSearchLength
+ },
+
+ /**
+ * Is the current search valid
+ *
+ * @return {boolean}
+ */
+ isValidQuery() {
+ return this.query && this.query.trim() !== '' && !this.isShortQuery
+ },
+
+ /**
+ * Have we reached the end of all types searches
+ *
+ * @return {boolean}
+ */
+ isDoneSearching() {
+ return Object.values(this.reached).every(state => state === false)
+ },
+
+ /**
+ * Is there any search in progress
+ *
+ * @return {boolean}
+ */
+ isLoading() {
+ return Object.values(this.loading).some(state => state === true)
+ },
+ },
+
+ async created() {
+ this.types = await getTypes()
+ this.logger.debug('Unified Search initialized with the following providers', this.types)
+ },
+
+ beforeDestroy() {
+ unsubscribe('files:navigation:changed', this.onNavigationChange)
+ },
+
+ mounted() {
+ // subscribe in mounted, as onNavigationChange relys on $el
+ subscribe('files:navigation:changed', this.onNavigationChange)
+
+ if (OCP.Accessibility.disableKeyboardShortcuts()) {
+ return
+ }
+
+ document.addEventListener('keydown', (event) => {
+ // if not already opened, allows us to trigger default browser on second keydown
+ if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
+ event.preventDefault()
+ this.open = true
+ } else if (event.ctrlKey && event.key === 'f' && this.open) {
+ // User wants to use the native browser search, so we close ours again
+ this.open = false
+ }
+
+ // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
+ if (this.open) {
+ // If arrow down, focus next result
+ if (event.key === 'ArrowDown') {
+ this.focusNext(event)
+ }
+
+ // If arrow up, focus prev result
+ if (event.key === 'ArrowUp') {
+ this.focusPrev(event)
+ }
+ }
+ })
+ },
+
+ methods: {
+ async onOpen() {
+ // Update types list in the background
+ this.types = await getTypes()
+ },
+ onClose() {
+ emit('nextcloud:unified-search.close')
+ },
+
+ onNavigationChange() {
+ this.$el?.querySelector?.('form[role="search"]')?.reset?.()
+ },
+
+ /**
+ * Reset the search state
+ */
+ onReset() {
+ emit('nextcloud:unified-search.reset')
+ this.logger.debug('Search reset')
+ this.query = ''
+ this.resetState()
+ this.focusInput()
+ },
+ async resetState() {
+ this.cursors = {}
+ this.limits = {}
+ this.reached = {}
+ this.results = {}
+ this.focused = null
+ this.triggered = false
+ await this.cancelPendingRequests()
+ },
+
+ /**
+ * Cancel any ongoing searches
+ */
+ async cancelPendingRequests() {
+ // Cloning so we can keep processing other requests
+ const requests = this.requests.slice(0)
+ this.requests = []
+
+ // Cancel all pending requests
+ await Promise.all(requests.map(cancel => cancel()))
+ },
+
+ /**
+ * Focus the search input on next tick
+ */
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.input.focus()
+ this.$refs.input.select()
+ })
+ },
+
+ /**
+ * If we have results already, open first one
+ * If not, trigger the search again
+ */
+ onInputEnter() {
+ if (this.hasResults) {
+ const results = this.getResultsList()
+ results[0].click()
+ return
+ }
+ this.onInput()
+ },
+
+ /**
+ * Start searching on input
+ */
+ async onInput() {
+ // emit the search query
+ emit('nextcloud:unified-search.search', { query: this.query })
+
+ // Do not search if not long enough
+ if (this.query.trim() === '' || this.isShortQuery) {
+ for (const type of this.typesIDs) {
+ this.$delete(this.results, type)
+ }
+ return
+ }
+
+ let types = this.typesIDs
+ let query = this.query
+
+ // Filter out types
+ if (this.usedFiltersNot.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
+ }
+
+ // Only use those filters if any and check if they are valid
+ if (this.usedFiltersIn.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
+ }
+
+ // Remove any filters from the query
+ query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
+
+ // Reset search if the query changed
+ await this.resetState()
+ this.triggered = true
+
+ if (!types.length) {
+ // no results since no types were selected
+ this.logger.error('No types to search in')
+ return
+ }
+
+ this.$set(this.loading, 'all', true)
+ this.logger.debug(`Searching ${query} in`, types)
+
+ Promise.all(types.map(async type => {
+ try {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.$set(this.results, type, data.ocs.data.entries)
+ } else {
+ this.$delete(this.results, type)
+ }
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ } else if (!data.ocs.data.isPaginated) {
+ // If no cursor and no pagination, we save the default amount
+ // provided by server's initial state `defaultLimit`
+ this.$set(this.limits, type, this.defaultLimit)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+
+ // If none already focused, focus the first rendered result
+ if (this.focused === null) {
+ this.focused = 0
+ }
+ return REQUEST_OK
+ } catch (error) {
+ this.$delete(this.results, type)
+
+ // If this is not a cancelled throw
+ if (error.response && error.response.status) {
+ this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
+ showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
+ return REQUEST_FAILED
+ }
+ return REQUEST_CANCELED
+ }
+ })).then(results => {
+ // Do not declare loading finished if the request have been cancelled
+ // This means another search was triggered and we're therefore still loading
+ if (results.some(result => result === REQUEST_CANCELED)) {
+ return
+ }
+ // We finished all searches
+ this.loading = {}
+ })
+ },
+ onInputDebounced: enableLiveSearch
+ ? debounce(function(e) {
+ this.onInput(e)
+ }, 500)
+ : function() {
+ this.triggered = false
+ },
+
+ /**
+ * Load more results for the provided type
+ *
+ * @param {string} type type
+ */
+ async loadMore(type) {
+ // If already loading, ignore
+ if (this.loading[type]) {
+ return
+ }
+
+ if (this.cursors[type]) {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ }
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.results[type].push(...data.ocs.data.entries)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+ } else {
+ // If no cursor, we might have all the results already,
+ // let's fake pagination and show the next xxx entries
+ if (this.limits[type] && this.limits[type] >= 0) {
+ this.limits[type] += this.defaultLimit
+
+ // Check if we reached end of pagination
+ if (this.limits[type] >= this.results[type].length) {
+ this.$set(this.reached, type, true)
+ }
+ }
+ }
+
+ // Focus result after render
+ if (this.focused !== null) {
+ this.$nextTick(() => {
+ this.focusIndex(this.focused)
+ })
+ }
+ },
+
+ /**
+ * Return a subset of the array if the search provider
+ * doesn't supports pagination
+ *
+ * @param {Array} list the results
+ * @param {string} type the type
+ * @return {Array}
+ */
+ limitIfAny(list, type) {
+ if (type in this.limits) {
+ return list.slice(0, this.limits[type])
+ }
+ return list
+ },
+
+ getResultsList() {
+ return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
+ },
+
+ /**
+ * Focus the first result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusFirst(event) {
+ const results = this.getResultsList()
+ if (results && results.length > 0) {
+ if (event) {
+ event.preventDefault()
+ }
+ this.focused = 0
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the next result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusNext(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the last, focus the next one
+ if (results && results.length > 0 && this.focused + 1 < results.length) {
+ event.preventDefault()
+ this.focused++
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the previous result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusPrev(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the first, focus the previous one
+ if (results && results.length > 0 && this.focused > 0) {
+ event.preventDefault()
+ this.focused--
+ this.focusIndex(this.focused)
+ }
+
+ },
+
+ /**
+ * Focus the specified result index if it exists
+ *
+ * @param {number} index the result index
+ */
+ focusIndex(index) {
+ const results = this.getResultsList()
+ if (results && results[index]) {
+ results[index].focus()
+ }
+ },
+
+ /**
+ * Set the current focused element based on the target
+ *
+ * @param {Event} event the focus event
+ */
+ setFocusedIndex(event) {
+ const entry = event.target
+ const results = this.getResultsList()
+ const index = [...results].findIndex(search => search === entry)
+ if (index > -1) {
+ // let's not use focusIndex as the entry is already focused
+ this.focused = index
+ }
+ },
+
+ onClickFilter(filter) {
+ this.query = `${this.query} ${filter}`
+ .replace(/ {2}/g, ' ')
+ .trim()
+ this.onInput()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+$margin: 10px;
+$input-height: 34px;
+$input-padding: 10px;
+
+.unified-search {
+ &__input-wrapper {
+ position: sticky;
+ // above search results
+ z-index: 2;
+ top: 0;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ background-color: var(--color-main-background);
+
+ label[for="unified-search__input"] {
+ align-self: flex-start;
+ font-weight: bold;
+ font-size: 19px;
+ margin-left: 13px;
+ }
+ }
+
+ &__form-input {
+ margin: 0 !important;
+ &:focus,
+ &:focus-visible,
+ &:active {
+ border-color: 2px solid var(--color-main-text) !important;
+ box-shadow: 0 0 0 2px var(--color-main-background) !important;
+ }
+ }
+
+ &__input-row {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ }
+
+ &__filters {
+ margin: $margin 0 $margin math.div($margin, 2);
+ padding-top: 5px;
+ ul {
+ display: inline-flex;
+ justify-content: space-between;
+ }
+ }
+
+ &__form {
+ position: relative;
+ width: 100%;
+ margin: $margin 0;
+
+ // Loading spinner
+ &::after {
+ right: $input-padding;
+ left: auto;
+ }
+
+ &-input,
+ &-reset {
+ margin: math.div($input-padding, 2);
+ }
+
+ &-input {
+ width: 100%;
+ height: $input-height;
+ padding: $input-padding;
+
+ &,
+ &[placeholder],
+ &::placeholder {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ // Hide webkit clear search
+ &::-webkit-search-decoration,
+ &::-webkit-search-cancel-button,
+ &::-webkit-search-results-button,
+ &::-webkit-search-results-decoration {
+ -webkit-appearance: none;
+ }
+ }
+
+ &-reset, &-submit {
+ position: absolute;
+ top: 0;
+ right: 4px;
+ width: $input-height - $input-padding;
+ height: $input-height - $input-padding;
+ min-height: 30px;
+ padding: 0;
+ opacity: .5;
+ border: none;
+ background-color: transparent;
+ margin-right: 0;
+
+ &:hover,
+ &:focus,
+ &:active {
+ opacity: 1;
+ }
+ }
+
+ &-submit {
+ right: 28px;
+ }
+ }
+
+ &__results {
+ &-header {
+ display: block;
+ margin: $margin;
+ margin-bottom: $margin - 4px;
+ margin-left: 13px;
+ color: var(--color-primary-element);
+ font-size: 19px;
+ font-weight: bold;
+ }
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .unified-search__result-more::v-deep {
+ color: var(--color-text-maxcontrast);
+ }
+
+ .empty-content {
+ margin: 10vh 0;
+
+ ::v-deep .empty-content__title {
+ font-weight: normal;
+ font-size: var(--default-font-size);
+ text-align: center;
+ }
+ }
+}
+
+</style>
<!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ - @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
-
- - @author John Molakvoæ <skjnldsv@protonmail.com>
+ - @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
-
- @license GNU AGPL version 3 or any later version
-
-
-->
<template>
- <NcHeaderMenu id="unified-search"
- class="unified-search"
- :exclude-click-outside-selectors="['.popover']"
- :open.sync="open"
- :aria-label="ariaLabel"
- @open="onOpen"
- @close="onClose">
- <!-- Header icon -->
- <template #trigger>
- <Magnify class="unified-search__trigger"
- :size="22/* fit better next to other 20px icons */" />
- </template>
-
- <!-- Search form & filters wrapper -->
- <div class="unified-search__input-wrapper">
- <div class="unified-search__input-row">
- <NcTextField ref="input"
- :value.sync="query"
- trailing-button-icon="close"
- :label="ariaLabel"
- :trailing-button-label="t('core','Reset search')"
- :show-trailing-button="query !== ''"
- aria-describedby="unified-search-desc"
- class="unified-search__form-input"
- :class="{'unified-search__form-input--with-reset': !!query}"
- :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
- @trailing-button-click="onReset"
- @input="onInputDebounced" />
- <p id="unified-search-desc" class="hidden-visually">
- {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
- </p>
-
- <!-- Search filters -->
- <NcActions v-if="availableFilters.length > 1"
- class="unified-search__filters"
- placement="bottom-end"
- container=".unified-search__input-wrapper">
- <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
- <NcActionButton v-for="filter in availableFilters"
- :key="filter"
- icon="icon-filter"
- @click.stop="onClickFilter(`in:${filter}`)">
- {{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }}
- </NcActionButton>
- </NcActions>
- </div>
- </div>
-
- <template v-if="!hasResults">
- <!-- Loading placeholders -->
- <SearchResultPlaceholders v-if="isLoading" />
-
- <NcEmptyContent v-else-if="isValidQuery"
- :title="validQueryTitle">
- <template #icon>
- <Magnify />
- </template>
- </NcEmptyContent>
-
- <NcEmptyContent v-else-if="!isLoading || isShortQuery"
- :title="t('core', 'Start typing to search')"
- :description="shortQueryDescription">
- <template #icon>
- <Magnify />
- </template>
- </NcEmptyContent>
- </template>
-
- <!-- Grouped search results -->
- <template v-for="({list, type}, typesIndex) in orderedResults" v-else>
- <h2 :key="type" class="unified-search__results-header">
- {{ typesMap[type] }}
- </h2>
- <ul :key="type"
- class="unified-search__results"
- :class="`unified-search__results-${type}`"
- :aria-label="typesMap[type]">
- <!-- Search results -->
- <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
- <SearchResult v-bind="result"
- :query="query"
- :focused="focused === 0 && typesIndex === 0 && index === 0"
- @focus="setFocusedIndex" />
- </li>
-
- <!-- Load more button -->
- <li>
- <SearchResult v-if="!reached[type]"
- class="unified-search__result-more"
- :title="loading[type]
- ? t('core', 'Loading more results …')
- : t('core', 'Load more results')"
- :icon-class="loading[type] ? 'icon-loading-small' : ''"
- @click.prevent.stop="loadMore(type)"
- @focus="setFocusedIndex" />
- </li>
- </ul>
- </template>
- </NcHeaderMenu>
+ <div class="header-menu">
+ <NcButton class="unified-search__button" :aria-label="t('core', 'Unified search')" @click="toggleUnifiedSearch">
+ <template #icon>
+ <Magnify class="unified-search__trigger" :size="22" />
+ </template>
+ </NcButton>
+ <UnifiedSearchModal :class="'unified-search-modal'" :is-visible="showUnifiedSearch" @update:isVisible="handleModalVisibilityChange" />
+ </div>
</template>
<script>
-import debounce from 'debounce'
-import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { showError } from '@nextcloud/dialogs'
-
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import Magnify from 'vue-material-design-icons/Magnify.vue'
-
-import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
-import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
-
-import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/UnifiedSearchService.js'
-
-const REQUEST_FAILED = 0
-const REQUEST_OK = 1
-const REQUEST_CANCELED = 2
+import UnifiedSearchModal from './UnifiedSearchModal.vue'
export default {
name: 'UnifiedSearch',
-
components: {
+ NcButton,
Magnify,
- NcActionButton,
- NcActions,
- NcEmptyContent,
- NcHeaderMenu,
- SearchResult,
- SearchResultPlaceholders,
- NcTextField,
+ UnifiedSearchModal,
},
-
data() {
return {
- types: [],
-
- // Cursors per types
- cursors: {},
- // Various search limits per types
- limits: {},
- // Loading types
- loading: {},
- // Reached search types
- reached: {},
- // Pending cancellable requests
- requests: [],
- // List of all results
- results: {},
-
- query: '',
- focused: null,
- triggered: false,
-
- defaultLimit,
- minSearchLength,
- enableLiveSearch,
-
- open: false,
+ showUnifiedSearch: false,
}
},
-
- computed: {
- typesIDs() {
- return this.types.map(type => type.id)
- },
- typesNames() {
- return this.types.map(type => type.name)
- },
- typesMap() {
- return this.types.reduce((prev, curr) => {
- prev[curr.id] = curr.name
- return prev
- }, {})
- },
-
- ariaLabel() {
- return t('core', 'Search')
- },
-
- /**
- * Is there any result to display
- *
- * @return {boolean}
- */
- hasResults() {
- return Object.keys(this.results).length !== 0
- },
-
- /**
- * Return ordered results
- *
- * @return {Array}
- */
- orderedResults() {
- return this.typesIDs
- .filter(type => type in this.results)
- .map(type => ({
- type,
- list: this.results[type],
- }))
- },
-
- /**
- * Available filters
- * We only show filters that are available on the results
- *
- * @return {string[]}
- */
- availableFilters() {
- return Object.keys(this.results)
- },
-
- /**
- * Applied filters
- *
- * @return {string[]}
- */
- usedFiltersIn() {
- let match
- const filters = []
- while ((match = regexFilterIn.exec(this.query)) !== null) {
- filters.push(match[2])
- }
- return filters
- },
-
- /**
- * Applied anti filters
- *
- * @return {string[]}
- */
- usedFiltersNot() {
- let match
- const filters = []
- while ((match = regexFilterNot.exec(this.query)) !== null) {
- filters.push(match[2])
- }
- return filters
- },
-
- /**
- * Valid query empty content title
- *
- * @return {string}
- */
- validQueryTitle() {
- return this.triggered
- ? t('core', 'No results for {query}', { query: this.query })
- : t('core', 'Press Enter to start searching')
- },
-
- /**
- * Short query empty content description
- *
- * @return {string}
- */
- shortQueryDescription() {
- if (!this.isShortQuery) {
- return ''
- }
-
- return n('core',
- 'Please enter {minSearchLength} character or more to search',
- 'Please enter {minSearchLength} characters or more to search',
- this.minSearchLength,
- { minSearchLength: this.minSearchLength })
- },
-
- /**
- * Is the current search too short
- *
- * @return {boolean}
- */
- isShortQuery() {
- return this.query && this.query.trim().length < minSearchLength
- },
-
- /**
- * Is the current search valid
- *
- * @return {boolean}
- */
- isValidQuery() {
- return this.query && this.query.trim() !== '' && !this.isShortQuery
- },
-
- /**
- * Have we reached the end of all types searches
- *
- * @return {boolean}
- */
- isDoneSearching() {
- return Object.values(this.reached).every(state => state === false)
- },
-
- /**
- * Is there any search in progress
- *
- * @return {boolean}
- */
- isLoading() {
- return Object.values(this.loading).some(state => state === true)
- },
- },
-
- async created() {
- this.types = await getTypes()
- this.logger.debug('Unified Search initialized with the following providers', this.types)
- },
-
- beforeDestroy() {
- unsubscribe('files:navigation:changed', this.onNavigationChange)
- },
-
mounted() {
- // subscribe in mounted, as onNavigationChange relys on $el
- subscribe('files:navigation:changed', this.onNavigationChange)
-
- if (OCP.Accessibility.disableKeyboardShortcuts()) {
- return
- }
-
- document.addEventListener('keydown', (event) => {
- // if not already opened, allows us to trigger default browser on second keydown
- if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
- event.preventDefault()
- this.open = true
- } else if (event.ctrlKey && event.key === 'f' && this.open) {
- // User wants to use the native browser search, so we close ours again
- this.open = false
- }
-
- // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
- if (this.open) {
- // If arrow down, focus next result
- if (event.key === 'ArrowDown') {
- this.focusNext(event)
- }
-
- // If arrow up, focus prev result
- if (event.key === 'ArrowUp') {
- this.focusPrev(event)
- }
- }
- })
+ console.debug('Unified search initialized!')
},
-
methods: {
- async onOpen() {
- // Update types list in the background
- this.types = await getTypes()
- },
- onClose() {
- emit('nextcloud:unified-search.close')
- },
-
- onNavigationChange() {
- this.$el?.querySelector?.('form[role="search"]')?.reset?.()
- },
-
- /**
- * Reset the search state
- */
- onReset() {
- emit('nextcloud:unified-search.reset')
- this.logger.debug('Search reset')
- this.query = ''
- this.resetState()
- this.focusInput()
- },
- async resetState() {
- this.cursors = {}
- this.limits = {}
- this.reached = {}
- this.results = {}
- this.focused = null
- this.triggered = false
- await this.cancelPendingRequests()
- },
-
- /**
- * Cancel any ongoing searches
- */
- async cancelPendingRequests() {
- // Cloning so we can keep processing other requests
- const requests = this.requests.slice(0)
- this.requests = []
-
- // Cancel all pending requests
- await Promise.all(requests.map(cancel => cancel()))
- },
-
- /**
- * Focus the search input on next tick
- */
- focusInput() {
- this.$nextTick(() => {
- this.$refs.input.focus()
- this.$refs.input.select()
- })
- },
-
- /**
- * If we have results already, open first one
- * If not, trigger the search again
- */
- onInputEnter() {
- if (this.hasResults) {
- const results = this.getResultsList()
- results[0].click()
- return
- }
- this.onInput()
- },
-
- /**
- * Start searching on input
- */
- async onInput() {
- // emit the search query
- emit('nextcloud:unified-search.search', { query: this.query })
-
- // Do not search if not long enough
- if (this.query.trim() === '' || this.isShortQuery) {
- for (const type of this.typesIDs) {
- this.$delete(this.results, type)
- }
- return
- }
-
- let types = this.typesIDs
- let query = this.query
-
- // Filter out types
- if (this.usedFiltersNot.length > 0) {
- types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
- }
-
- // Only use those filters if any and check if they are valid
- if (this.usedFiltersIn.length > 0) {
- types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
- }
-
- // Remove any filters from the query
- query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
-
- // Reset search if the query changed
- await this.resetState()
- this.triggered = true
-
- if (!types.length) {
- // no results since no types were selected
- this.logger.error('No types to search in')
- return
- }
-
- this.$set(this.loading, 'all', true)
- this.logger.debug(`Searching ${query} in`, types)
-
- Promise.all(types.map(async type => {
- try {
- // Init cancellable request
- const { request, cancel } = search({ type, query })
- this.requests.push(cancel)
-
- // Fetch results
- const { data } = await request()
-
- // Process results
- if (data.ocs.data.entries.length > 0) {
- this.$set(this.results, type, data.ocs.data.entries)
- } else {
- this.$delete(this.results, type)
- }
-
- // Save cursor if any
- if (data.ocs.data.cursor) {
- this.$set(this.cursors, type, data.ocs.data.cursor)
- } else if (!data.ocs.data.isPaginated) {
- // If no cursor and no pagination, we save the default amount
- // provided by server's initial state `defaultLimit`
- this.$set(this.limits, type, this.defaultLimit)
- }
-
- // Check if we reached end of pagination
- if (data.ocs.data.entries.length < this.defaultLimit) {
- this.$set(this.reached, type, true)
- }
-
- // If none already focused, focus the first rendered result
- if (this.focused === null) {
- this.focused = 0
- }
- return REQUEST_OK
- } catch (error) {
- this.$delete(this.results, type)
-
- // If this is not a cancelled throw
- if (error.response && error.response.status) {
- this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
- showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
- return REQUEST_FAILED
- }
- return REQUEST_CANCELED
- }
- })).then(results => {
- // Do not declare loading finished if the request have been cancelled
- // This means another search was triggered and we're therefore still loading
- if (results.some(result => result === REQUEST_CANCELED)) {
- return
- }
- // We finished all searches
- this.loading = {}
- })
- },
- onInputDebounced: enableLiveSearch
- ? debounce(function(e) {
- this.onInput(e)
- }, 500)
- : function() {
- this.triggered = false
- },
-
- /**
- * Load more results for the provided type
- *
- * @param {string} type type
- */
- async loadMore(type) {
- // If already loading, ignore
- if (this.loading[type]) {
- return
- }
-
- if (this.cursors[type]) {
- // Init cancellable request
- const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
- this.requests.push(cancel)
-
- // Fetch results
- const { data } = await request()
-
- // Save cursor if any
- if (data.ocs.data.cursor) {
- this.$set(this.cursors, type, data.ocs.data.cursor)
- }
-
- // Process results
- if (data.ocs.data.entries.length > 0) {
- this.results[type].push(...data.ocs.data.entries)
- }
-
- // Check if we reached end of pagination
- if (data.ocs.data.entries.length < this.defaultLimit) {
- this.$set(this.reached, type, true)
- }
- } else {
- // If no cursor, we might have all the results already,
- // let's fake pagination and show the next xxx entries
- if (this.limits[type] && this.limits[type] >= 0) {
- this.limits[type] += this.defaultLimit
-
- // Check if we reached end of pagination
- if (this.limits[type] >= this.results[type].length) {
- this.$set(this.reached, type, true)
- }
- }
- }
-
- // Focus result after render
- if (this.focused !== null) {
- this.$nextTick(() => {
- this.focusIndex(this.focused)
- })
- }
- },
-
- /**
- * Return a subset of the array if the search provider
- * doesn't supports pagination
- *
- * @param {Array} list the results
- * @param {string} type the type
- * @return {Array}
- */
- limitIfAny(list, type) {
- if (type in this.limits) {
- return list.slice(0, this.limits[type])
- }
- return list
- },
-
- getResultsList() {
- return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
- },
-
- /**
- * Focus the first result if any
- *
- * @param {Event} event the keydown event
- */
- focusFirst(event) {
- const results = this.getResultsList()
- if (results && results.length > 0) {
- if (event) {
- event.preventDefault()
- }
- this.focused = 0
- this.focusIndex(this.focused)
- }
- },
-
- /**
- * Focus the next result if any
- *
- * @param {Event} event the keydown event
- */
- focusNext(event) {
- if (this.focused === null) {
- this.focusFirst(event)
- return
- }
-
- const results = this.getResultsList()
- // If we're not focusing the last, focus the next one
- if (results && results.length > 0 && this.focused + 1 < results.length) {
- event.preventDefault()
- this.focused++
- this.focusIndex(this.focused)
- }
- },
-
- /**
- * Focus the previous result if any
- *
- * @param {Event} event the keydown event
- */
- focusPrev(event) {
- if (this.focused === null) {
- this.focusFirst(event)
- return
- }
-
- const results = this.getResultsList()
- // If we're not focusing the first, focus the previous one
- if (results && results.length > 0 && this.focused > 0) {
- event.preventDefault()
- this.focused--
- this.focusIndex(this.focused)
- }
-
- },
-
- /**
- * Focus the specified result index if it exists
- *
- * @param {number} index the result index
- */
- focusIndex(index) {
- const results = this.getResultsList()
- if (results && results[index]) {
- results[index].focus()
- }
- },
-
- /**
- * Set the current focused element based on the target
- *
- * @param {Event} event the focus event
- */
- setFocusedIndex(event) {
- const entry = event.target
- const results = this.getResultsList()
- const index = [...results].findIndex(search => search === entry)
- if (index > -1) {
- // let's not use focusIndex as the entry is already focused
- this.focused = index
- }
+ toggleUnifiedSearch() {
+ this.showUnifiedSearch = !this.showUnifiedSearch
},
-
- onClickFilter(filter) {
- this.query = `${this.query} ${filter}`
- .replace(/ {2}/g, ' ')
- .trim()
- this.onInput()
+ handleModalVisibilityChange(newVisibilityVal) {
+ this.showUnifiedSearch = newVisibilityVal
},
},
}
</script>
<style lang="scss" scoped>
-@use "sass:math";
-
-$margin: 10px;
-$input-height: 34px;
-$input-padding: 10px;
-
-.unified-search {
- &__input-wrapper {
- position: sticky;
- // above search results
- z-index: 2;
- top: 0;
- display: inline-flex;
- flex-direction: column;
- align-items: center;
- width: 100%;
- background-color: var(--color-main-background);
+.header-menu {
+ display: flex;
+ align-items: center;
+ justify-content: center;
- label[for="unified-search__input"] {
- align-self: flex-start;
- font-weight: bold;
- font-size: 19px;
- margin-left: 13px;
- }
- }
-
- &__form-input {
- margin: 0 !important;
- &:focus,
- &:focus-visible,
- &:active {
- border-color: 2px solid var(--color-main-text) !important;
- box-shadow: 0 0 0 2px var(--color-main-background) !important;
- }
- }
-
- &__input-row {
+ .unified-search__button {
display: flex;
- width: 100%;
align-items: center;
- }
-
- &__filters {
- margin: $margin 0 $margin math.div($margin, 2);
- padding-top: 5px;
- ul {
- display: inline-flex;
- justify-content: space-between;
- }
- }
-
- &__form {
- position: relative;
- width: 100%;
- margin: $margin 0;
-
- // Loading spinner
- &::after {
- right: $input-padding;
- left: auto;
- }
-
- &-input,
- &-reset {
- margin: math.div($input-padding, 2);
- }
-
- &-input {
- width: 100%;
- height: $input-height;
- padding: $input-padding;
-
- &,
- &[placeholder],
- &::placeholder {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- // Hide webkit clear search
- &::-webkit-search-decoration,
- &::-webkit-search-cancel-button,
- &::-webkit-search-results-button,
- &::-webkit-search-results-decoration {
- -webkit-appearance: none;
- }
- }
-
- &-reset, &-submit {
- position: absolute;
- top: 0;
- right: 4px;
- width: $input-height - $input-padding;
- height: $input-height - $input-padding;
- min-height: 30px;
- padding: 0;
- opacity: .5;
- border: none;
- background-color: transparent;
- margin-right: 0;
-
- &:hover,
- &:focus,
- &:active {
- opacity: 1;
- }
- }
-
- &-submit {
- right: 28px;
+ justify-content: center;
+ width: var(--header-height);
+ // height: var(--header-height);
+ margin: 0;
+ padding: 0;
+ cursor: pointer;
+ opacity: .85;
+ background-color: transparent;
+ border: none;
+ filter: none !important;
+ color: var(--color-primary-text) !important;
+
+ &:hover {
+ background-color: transparent !important;
}
}
+}
- &__results {
- &-header {
- display: block;
- margin: $margin;
- margin-bottom: $margin - 4px;
- margin-left: 13px;
- color: var(--color-primary-element);
- font-size: 19px;
- font-weight: bold;
- }
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
-
- .unified-search__result-more::v-deep {
- color: var(--color-text-maxcontrast);
- }
-
- .empty-content {
- margin: 10vh 0;
-
- ::v-deep .empty-content__title {
- font-weight: normal;
- font-size: var(--default-font-size);
- text-align: center;
- }
+.unified-search-modal {
+ ::v-deep .modal-container {
+ height: 80%;
}
}
-
</style>
--- /dev/null
+<template>
+ <NcModal id="unified-search"
+ ref="unifiedSearchModal"
+ :name="t('core', 'Unified search')"
+ :show.sync="internalIsVisible"
+ :clear-view-delay="0"
+ :title="t('Unified search')"
+ @close="closeModal">
+ <CustomDateRangeModal :is-open="showDateRangeModal"
+ class="unified-search__date-range"
+ @set:custom-date-range="setCustomDateRange"
+ @update:is-open="showDateRangeModal = $event" />
+ <!-- Unified search form -->
+ <div ref="unifiedSearch" class="unified-search-modal">
+ <h1>{{ t('core', 'Unified search') }}</h1>
+ <NcInputField ref="searchInput"
+ :value.sync="searchQuery"
+ type="text"
+ :label="t('core', 'Search apps, files, tags, messages') + '...'"
+ @update:value="debouncedFind" />
+ <div class="unified-search-modal__filters">
+ <NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen">
+ <template #icon>
+ <ListBox :size="20" />
+ </template>
+ <NcActionButton v-for="provider in providers" :key="provider.id" @click="addProviderFilter(provider)">
+ <template #icon>
+ <img :src="provider.icon">
+ </template>
+ {{ t('core', provider.name) }}
+ </NcActionButton>
+ </NcActions>
+ <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen">
+ <template #icon>
+ <CalendarRangeIcon :size="20" />
+ </template>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')">
+ {{ t('core', 'Today') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')">
+ {{ t('core', 'Last 7 days') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')">
+ {{ t('core', 'Last 30 days') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')">
+ {{ t('core', 'This year') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')">
+ {{ t('core', 'Last year') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')">
+ {{ t('core', 'Custom date range') }}
+ </NcActionButton>
+ </NcActions>
+ <SearchableList :label-text="t('core', 'Search people')"
+ :search-list="userContacts"
+ :empty-content-text="t('core', 'Not found')"
+ @item-selected="applyPersonFilter">
+ <template #trigger>
+ <NcButton>
+ <template #icon>
+ <AccountGroup :size="20" />
+ </template>
+ {{ t('core', 'People') }}
+ </NcButton>
+ </template>
+ </SearchableList>
+ </div>
+ <div class="unified-search-modal__filters-applied">
+ <FilterChip v-for="filter in filters"
+ :key="filter.id"
+ :text="filter.name ?? filter.text"
+ :pretext="''"
+ @delete="removeFilter(filter)">
+ <template #icon>
+ <NcAvatar v-if="filter.type === 'person'"
+ :user="filter.user"
+ :size="24"
+ :disable-menu="true"
+ :show-user-status="false"
+ :hide-favorite="false" />
+ <CalendarRangeIcon v-else-if="filter.type === 'date'" />
+ <img v-else :src="filter.icon" alt="">
+ </template>
+ </FilterChip>
+ </div>
+ <div v-if="noContentInfo.show" class="unified-search-modal__no-content">
+ <NcEmptyContent :name="noContentInfo.text">
+ <template #icon>
+ <component :is="noContentInfo.icon" />
+ </template>
+ </NcEmptyContent>
+ </div>
+ <div v-for="providerResult in results" :key="providerResult.id" class="unified-search-modal__results">
+ <div class="results">
+ <div class="result-title">
+ <span>{{ providerResult.provider }}</span>
+ </div>
+ <ul class="result-items">
+ <SearchResult v-for="(result, index) in providerResult.results" :key="index" v-bind="result" />
+ </ul>
+ <div class="result-footer">
+ <NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)">
+ {{ t('core', 'Load more results') }}
+ <template #icon>
+ <DotsHorizontalIcon :size="20" />
+ </template>
+ </NcButton>
+ <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background">
+ {{ t('core', 'Search in') }} {{ providerResult.provider }}
+ <template #icon>
+ <ArrowRight :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ </div>
+ </div>
+ <div v-if="supportFiltering()" class="unified-search-modal__results">
+ <NcButton @click="closeModal">
+ {{ t('core', 'Filter in current view') }}
+ <template #icon>
+ <FilterIcon :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ </div>
+ </NcModal>
+</template>
+
+<script>
+import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
+import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
+import CustomDateRangeModal from '../components/UnifiedSearch/CustomDateRangeModal.vue'
+import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
+import FilterIcon from 'vue-material-design-icons/Filter.vue'
+import FilterChip from '../components/UnifiedSearch/SearchFilterChip.vue'
+import ListBox from 'vue-material-design-icons/ListBox.vue'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
+import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
+import MagnifyIcon from 'vue-material-design-icons/Magnify.vue'
+import SearchableList from '../components/UnifiedSearch/SearchableList.vue'
+import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
+
+import debounce from 'debounce'
+import { emit } from '@nextcloud/event-bus'
+import { getProviders, search as unifiedSearch, getContacts } from '../services/UnifiedSearchService.js'
+
+export default {
+ name: 'UnifiedSearchModal',
+ components: {
+ ArrowRight,
+ AccountGroup,
+ CalendarRangeIcon,
+ CustomDateRangeModal,
+ DotsHorizontalIcon,
+ FilterIcon,
+ FilterChip,
+ ListBox,
+ NcActions,
+ NcActionButton,
+ NcAvatar,
+ NcButton,
+ NcEmptyContent,
+ NcModal,
+ NcInputField,
+ MagnifyIcon,
+ SearchableList,
+ SearchResult,
+ },
+ props: {
+ isVisible: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ providers: [],
+ providerActionMenuIsOpen: false,
+ dateActionMenuIsOpen: false,
+ providerResultLimit: 5,
+ dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
+ personFilter: { id: 'person', type: 'person', name: '' },
+ dateFilterIsApplied: false,
+ personFilterIsApplied: false,
+ filteredProviders: [],
+ searching: false,
+ searchQuery: '',
+ placesFilter: '',
+ dateTimeFilter: null,
+ filters: [],
+ results: [],
+ contacts: [],
+ debouncedFind: debounce(this.find, 300),
+ showDateRangeModal: false,
+ internalIsVisible: false,
+ }
+ },
+
+ computed: {
+ userContacts: {
+ get() {
+ return this.contacts
+ },
+ },
+ noContentInfo: {
+ get() {
+ const isEmptySearch = this.searchQuery.length === 0
+ const hasNoResults = this.searchQuery.length > 0 && this.results.length === 0
+
+ return {
+ show: isEmptySearch || hasNoResults,
+ text: this.searching && hasNoResults ? t('core', 'Searching …') : (isEmptySearch ? t('core', 'Start typing in search') : t('core', 'No matching results')),
+ icon: MagnifyIcon,
+ }
+ },
+ },
+ },
+ watch: {
+ isVisible(value) {
+ this.internalIsVisible = value
+ },
+ internalIsVisible(value) {
+ this.$emit('update:isVisible', value)
+ this.$nextTick(() => {
+ if (value) {
+ this.focusInput()
+ }
+ })
+ },
+
+ },
+ mounted() {
+ getProviders().then((providers) => {
+ this.providers = providers
+ console.debug('Search providers', this.providers)
+ })
+ getContacts({ filter: '' }).then((contacts) => {
+ this.contacts = this.mapContacts(contacts)
+ console.debug('Contacts', this.contacts)
+ })
+ },
+ methods: {
+ find(query) {
+ this.searching = true
+ if (query.length === 0) {
+ this.results = []
+ this.searching = false
+ return
+ }
+ // Event should probably be refactored at some point to used nextcloud:unified-search.search
+ emit('nextcloud:unified-search.search', { query })
+ const newResults = []
+ const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers
+ const searchProvider = (provider, filters) => {
+ const params = {
+ type: provider.id,
+ query,
+ cursor: null,
+ }
+
+ if (filters.dateFilterIsApplied) {
+ if (provider.filters.since && provider.filters.until) {
+ params.since = this.dateFilter.startFrom
+ params.until = this.dateFilter.endAt
+ } else {
+ // Date filter is applied but provider does not support it, no need to search provider
+ return
+ }
+ }
+
+ if (filters.personFilterIsApplied) {
+ if (provider.filters.person) {
+ params.person = this.personFilter.user
+ } else {
+ // Person filter is applied but provider does not support it, no need to search provider
+ return
+ }
+ }
+
+ if (this.providerResultLimit > 5) {
+ params.limit = this.providerResultLimit
+ }
+
+ const request = unifiedSearch(params).request
+
+ request().then((response) => {
+ newResults.push({
+ id: provider.id,
+ provider: provider.name,
+ inAppSearch: provider.inAppSearch,
+ results: response.data.ocs.data.entries,
+ })
+
+ console.debug('New results', newResults)
+ console.debug('Unified search results:', this.results)
+
+ this.updateResults(newResults)
+ this.searching = false
+ })
+ }
+ providersToSearch.forEach(provider => {
+ const dateFilterIsApplied = this.dateFilterIsApplied
+ const personFilterIsApplied = this.personFilterIsApplied
+ searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied })
+ })
+
+ },
+ updateResults(newResults) {
+ let updatedResults = [...this.results]
+ // If filters are applied, remove any previous results for providers that are not in current filters
+ if (this.filters.length > 0) {
+ updatedResults = updatedResults.filter(result => {
+ return this.filters.some(filter => filter.id === result.id)
+ })
+ }
+ // Process the new results
+ newResults.forEach(newResult => {
+ const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id)
+ if (existingResultIndex !== -1) {
+ if (newResult.results.length === 0) {
+ // If the new results data has no matches for and existing result, remove the existing result
+ updatedResults.splice(existingResultIndex, 1)
+ } else {
+ // If input triggered a change in existing results, update existing result
+ updatedResults.splice(existingResultIndex, 1, newResult)
+ }
+ } else if (newResult.results.length > 0) {
+ // Push the new result to the array only if its results array is not empty
+ updatedResults.push(newResult)
+ }
+ })
+ const sortedResults = updatedResults.slice(0)
+ // Order results according to provider preference
+ sortedResults.sort((a, b) => {
+ const aProvider = this.providers.find(provider => provider.id === a.id)
+ const bProvider = this.providers.find(provider => provider.id === b.id)
+ const aOrder = aProvider ? aProvider.order : 0
+ const bOrder = bProvider ? bProvider.order : 0
+ return aOrder - bOrder
+ })
+ this.results = sortedResults
+ },
+ mapContacts(contacts) {
+ return contacts.map(contact => {
+ return {
+ // id: contact.id,
+ // name: '',
+ displayName: contact.fullName,
+ isNoUser: false,
+ subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '',
+ icon: '',
+ user: contact.id,
+ }
+ })
+ },
+ filterContacts(query) {
+ getContacts({ filter: query }).then((contacts) => {
+ this.contacts = this.mapContacts(contacts)
+ console.debug(`Contacts filtered by ${query}`, this.contacts)
+ })
+ },
+ applyPersonFilter(person) {
+ this.personFilterIsApplied = true
+ const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id)
+ if (existingPersonFilter === -1) {
+ this.personFilter.id = person.id
+ this.personFilter.user = person.user
+ this.personFilter.name = person.displayName
+ this.filters.push(this.personFilter)
+ } else {
+ this.filters[existingPersonFilter].id = person.id
+ this.filters[existingPersonFilter].user = person.user
+ this.filters[existingPersonFilter].name = person.displayName
+ }
+
+ this.debouncedFind(this.searchQuery)
+ console.debug('Person filter applied', person)
+ },
+ loadMoreResultsForProvider(providerId) {
+ this.providerResultLimit += 5
+ this.filters = this.filters.filter(filter => filter.type !== 'provider')
+ const provider = this.providers.find(provider => provider.id === providerId)
+ this.addProviderFilter(provider, true)
+ },
+ addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
+ if (!providerFilter.id) return
+ this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
+ this.providerActionMenuIsOpen = false
+ const existingFilter = this.filteredProviders.find(existing => existing.id === providerFilter.id)
+ if (!existingFilter) {
+ this.filteredProviders.push({ id: providerFilter.id, name: providerFilter.name, icon: providerFilter.icon, type: 'provider', filters: providerFilter.filters })
+ }
+ this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+ console.debug('Search filters (newly added)', this.filters)
+ this.debouncedFind(this.searchQuery)
+ },
+ removeFilter(filter) {
+ if (filter.type === 'provider') {
+ for (let i = 0; i < this.filteredProviders.length; i++) {
+ if (this.filteredProviders[i].id === filter.id) {
+ this.filteredProviders.splice(i, 1)
+ break
+ }
+ }
+ this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+ console.debug('Search filters (recently removed)', this.filters)
+
+ } else {
+ for (let i = 0; i < this.filters.length; i++) {
+ // Remove date and person filter
+ if (this.filters[i].id === 'date' || this.filters[i].id === filter.id) {
+ this.dateFilterIsApplied = false
+ this.filters.splice(i, 1)
+ if (filter.type === 'person') {
+ this.personFilterIsApplied = false
+ }
+ break
+ }
+ }
+ }
+ this.debouncedFind(this.searchQuery)
+ },
+ syncProviderFilters(firstArray, secondArray) {
+ // Create a copy of the first array to avoid modifying it directly.
+ const synchronizedArray = firstArray.slice()
+ // Remove items from the synchronizedArray that are not in the secondArray.
+ synchronizedArray.forEach((item, index) => {
+ const itemId = item.id
+ if (item.type === 'provider') {
+ if (!secondArray.some(secondItem => secondItem.id === itemId)) {
+ synchronizedArray.splice(index, 1)
+ }
+ }
+ })
+ // Add items to the synchronizedArray that are in the secondArray but not in the firstArray.
+ secondArray.forEach(secondItem => {
+ const itemId = secondItem.id
+ if (secondItem.type === 'provider') {
+ if (!synchronizedArray.some(item => item.id === itemId)) {
+ synchronizedArray.push(secondItem)
+ }
+ }
+ })
+
+ return synchronizedArray
+ },
+ updateDateFilter() {
+ const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date')
+ if (currFilterIndex !== -1) {
+ this.filters[currFilterIndex] = this.dateFilter
+ } else {
+ this.filters.push(this.dateFilter)
+ }
+ this.dateFilterIsApplied = true
+ this.debouncedFind(this.searchQuery)
+ },
+ applyQuickDateRange(range) {
+ this.dateActionMenuIsOpen = false
+ const today = new Date()
+ let startDate
+ let endDate
+
+ switch (range) {
+ case 'today':
+ // For 'Today', both start and end are set to today
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0)
+ endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999)
+ this.dateFilter.text = t('core', 'Today')
+ break
+ case '7days':
+ // For 'Last 7 days', start date is 7 days ago, end is today
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0)
+ this.dateFilter.text = t('core', 'Last 7 days')
+ break
+ case '30days':
+ // For 'Last 30 days', start date is 30 days ago, end is today
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0)
+ this.dateFilter.text = t('core', 'Last 30 days')
+ break
+ case 'thisyear':
+ // For 'This year', start date is the first day of the year, end is the last day of the year
+ startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0)
+ endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999)
+ this.dateFilter.text = t('core', 'This year')
+ break
+ case 'lastyear':
+ // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year
+ startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0)
+ endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999)
+ this.dateFilter.text = t('core', 'Last year')
+ break
+ case 'custom':
+ this.showDateRangeModal = true
+ return
+ default:
+ return
+ }
+ this.dateFilter.startFrom = startDate
+ this.dateFilter.endAt = endDate
+ this.updateDateFilter()
+
+ },
+ setCustomDateRange(event) {
+ console.debug('Custom date range', event)
+ this.dateFilter.startFrom = event.startFrom
+ this.dateFilter.endAt = event.endAt
+ this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
+ this.updateDateFilter()
+ },
+ focusInput() {
+ this.$refs.searchInput.$el.children[0].children[0].focus()
+ },
+ closeModal() {
+ this.internalIsVisible = false
+ this.searchQuery = ''
+ },
+ supportFiltering() {
+ /* Hard coded apps for the moment this would be improved in coming updates. */
+ const providerPaths = ['/settings/users', '/apps/files', '/apps/deck']
+ const currentPath = window.location.pathname.replace('/index.php', '')
+ const containsProvider = providerPaths.some(path => currentPath.includes(path))
+ return containsProvider
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.unified-search-modal {
+ padding: 10px 20px 10px 20px;
+ height: 60%;
+
+ &__heading {
+ font-size: 16px;
+ font-weight: bolder;
+ line-height: 2em;
+ margin-bottom: 0;
+ }
+
+ &__filters {
+ display: flex;
+ padding-top: 4px;
+ justify-content: left;
+
+ >* {
+ margin-right: 4px;
+
+ }
+
+ }
+
+ &__filters-applied {
+ padding-top: 4px;
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ &__no-content {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ }
+
+ &__results {
+ padding: 10px;
+
+ .results {
+
+ .result-title {
+ span {
+ color: var(--color-primary-element);
+ font-weight: bolder;
+ font-size: 16px;
+ }
+ }
+
+ .result-footer {
+ justify-content: space-between;
+ align-items: center;
+ display: flex;
+ }
+ }
+
+ }
+}
+
+div.v-popper__wrapper {
+ ul {
+ li {
+ ::v-deep button.action-button {
+ align-items: center !important;
+
+ img {
+ width: 20px;
+ margin: 0 4px;
+ filter: var(--background-invert-if-bright);
+ }
+
+ }
+ }
+ }
+}
+</style>
</div>
<div class="header-right">
- <div id="global-search"></div>
<div id="unified-search"></div>
<div id="notifications"></div>
<div id="contactsmenu"></div>
$this->initialState->provideInitialState('unified-search', 'limit-default', (int)$this->config->getAppValue('core', 'unified-search.limit-default', (string)SearchQuery::LIMIT_DEFAULT));
$this->initialState->provideInitialState('unified-search', 'min-search-length', (int)$this->config->getAppValue('core', 'unified-search.min-search-length', (string)1));
$this->initialState->provideInitialState('unified-search', 'live-search', $this->config->getAppValue('core', 'unified-search.live-search', 'yes') === 'yes');
- Util::addScript('core', 'unified-search', 'core');
+ Util::addScript('core', 'legacy-unified-search', 'core');
} else {
- Util::addScript('core', 'global-search', 'core');
+ Util::addScript('core', 'unified-search', 'core');
}
// Set body data-theme
$this->assign('enabledThemes', []);
profile: path.join(__dirname, 'core/src', 'profile.js'),
recommendedapps: path.join(__dirname, 'core/src', 'recommendedapps.js'),
systemtags: path.resolve(__dirname, 'core/src', 'systemtags/merged-systemtags.js'),
- 'global-search': path.join(__dirname, 'core/src', 'global-search.js'),
'unified-search': path.join(__dirname, 'core/src', 'unified-search.js'),
+ 'legacy-unified-search': path.join(__dirname, 'core/src', 'legacy-unified-search.js'),
'unsupported-browser': path.join(__dirname, 'core/src', 'unsupported-browser.js'),
'unsupported-browser-redirect': path.join(__dirname, 'core/src', 'unsupported-browser-redirect.js'),
},