aboutsummaryrefslogtreecommitdiffstats
path: root/core/src
diff options
context:
space:
mode:
authorfenn-cs <fenn25.fn@gmail.com>2023-12-07 13:20:59 +0100
committernextcloud-command <nextcloud-command@users.noreply.github.com>2023-12-22 13:13:03 +0000
commit525c2b82f6a3a6acec6312e368448e50274f7319 (patch)
treef53bc2130f1c5bccba83fbc6a96abfce79ef3e67 /core/src
parentfecb3ea41cd9fe2e7f12fdb4b9b9a24845de281a (diff)
downloadnextcloud-server-525c2b82f6a3a6acec6312e368448e50274f7319.tar.gz
nextcloud-server-525c2b82f6a3a6acec6312e368448e50274f7319.zip
Rename "global search" to "unified search"
- Changes appearances of "Global search" to "Unified search" in UI - Refactors code, to remove usage of term "GlobalSearch" in files and code structure - Rename old unified search to `legacy-unified-search` Signed-off-by: fenn-cs <fenn25.fn@gmail.com> Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
Diffstat (limited to 'core/src')
-rw-r--r--core/src/components/GlobalSearch/SearchResult.vue169
-rw-r--r--core/src/components/UnifiedSearch/CustomDateRangeModal.vue (renamed from core/src/components/GlobalSearch/CustomDateRangeModal.vue)14
-rw-r--r--core/src/components/UnifiedSearch/LegacySearchResult.vue259
-rw-r--r--core/src/components/UnifiedSearch/SearchFilterChip.vue (renamed from core/src/components/GlobalSearch/SearchFilterChip.vue)0
-rw-r--r--core/src/components/UnifiedSearch/SearchResult.vue286
-rw-r--r--core/src/components/UnifiedSearch/SearchableList.vue (renamed from core/src/components/GlobalSearch/SearchableList.vue)0
-rw-r--r--core/src/legacy-unified-search.js (renamed from core/src/global-search.js)14
-rw-r--r--core/src/services/LegacyUnifiedSearchService.js (renamed from core/src/services/GlobalSearchService.js)43
-rw-r--r--core/src/services/UnifiedSearchService.js43
-rw-r--r--core/src/unified-search.js4
-rw-r--r--core/src/views/GlobalSearch.vue96
-rw-r--r--core/src/views/LegacyUnifiedSearch.vue863
-rw-r--r--core/src/views/UnifiedSearch.vue854
-rw-r--r--core/src/views/UnifiedSearchModal.vue (renamed from core/src/views/GlobalSearchModal.vue)45
14 files changed, 1344 insertions, 1346 deletions
diff --git a/core/src/components/GlobalSearch/SearchResult.vue b/core/src/components/GlobalSearch/SearchResult.vue
deleted file mode 100644
index a746a5751b7..00000000000
--- a/core/src/components/GlobalSearch/SearchResult.vue
+++ /dev/null
@@ -1,169 +0,0 @@
-<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>
diff --git a/core/src/components/GlobalSearch/CustomDateRangeModal.vue b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
index 0ba6ddca015..ec592732a8d 100644
--- a/core/src/components/GlobalSearch/CustomDateRangeModal.vue
+++ b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
@@ -1,6 +1,6 @@
<template>
<NcModal v-if="isModalOpen"
- id="global-search"
+ id="unified-search"
:name="t('core', 'Custom date range')"
:show.sync="isModalOpen"
:size="'small'"
@@ -8,19 +8,19 @@
:title="t('core', 'Custom date range')"
@close="closeModal">
<!-- Custom date range -->
- <div class="global-search-custom-date-modal">
+ <div class="unified-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'"
+ <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="'globalsearch-custom-date-range-end'"
+ <NcDateTimePicker :id="'unifiedsearch-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">
+ <div class="unified-search-custom-date-modal__footer">
<NcButton @click="applyCustomRange">
{{ t('core', 'Search in date range') }}
<template #icon>
@@ -80,7 +80,7 @@ export default {
</script>
<style lang="scss" scoped>
-.global-search-custom-date-modal {
+.unified-search-custom-date-modal {
padding: 10px 20px 10px 20px;
h1 {
diff --git a/core/src/components/UnifiedSearch/LegacySearchResult.vue b/core/src/components/UnifiedSearch/LegacySearchResult.vue
new file mode 100644
index 00000000000..01f48a36709
--- /dev/null
+++ b/core/src/components/UnifiedSearch/LegacySearchResult.vue
@@ -0,0 +1,259 @@
+ <!--
+ - @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>
diff --git a/core/src/components/GlobalSearch/SearchFilterChip.vue b/core/src/components/UnifiedSearch/SearchFilterChip.vue
index 8342e9e256d..8342e9e256d 100644
--- a/core/src/components/GlobalSearch/SearchFilterChip.vue
+++ b/core/src/components/UnifiedSearch/SearchFilterChip.vue
diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue
index 03496fc5d92..a746a5751b7 100644
--- a/core/src/components/UnifiedSearch/SearchResult.vue
+++ b/core/src/components/UnifiedSearch/SearchResult.vue
@@ -1,73 +1,40 @@
- <!--
- - @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,
@@ -108,54 +75,22 @@ export default {
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
},
},
}
@@ -163,97 +98,72 @@ export default {
<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>
diff --git a/core/src/components/GlobalSearch/SearchableList.vue b/core/src/components/UnifiedSearch/SearchableList.vue
index 43f7ace1b64..43f7ace1b64 100644
--- a/core/src/components/GlobalSearch/SearchableList.vue
+++ b/core/src/components/UnifiedSearch/SearchableList.vue
diff --git a/core/src/global-search.js b/core/src/legacy-unified-search.js
index f0c47fa1895..943081f3d23 100644
--- a/core/src/global-search.js
+++ b/core/src/legacy-unified-search.js
@@ -1,7 +1,7 @@
/**
- * @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
- * @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
@@ -25,13 +25,13 @@ 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'
+import UnifiedSearch from './views/LegacyUnifiedSearch.vue'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())
const logger = getLoggerBuilder()
- .setApp('global-search')
+ .setApp('unified-search')
.detectUser()
.build()
@@ -48,8 +48,8 @@ Vue.mixin({
})
export default new Vue({
- el: '#global-search',
+ el: '#unified-search',
// eslint-disable-next-line vue/match-component-file-name
- name: 'GlobalSearchRoot',
- render: h => h(GlobalSearch),
+ name: 'UnifiedSearchRoot',
+ render: h => h(UnifiedSearch),
})
diff --git a/core/src/services/GlobalSearchService.js b/core/src/services/LegacyUnifiedSearchService.js
index e477a59eb4c..3c673479771 100644
--- a/core/src/services/GlobalSearchService.js
+++ b/core/src/services/LegacyUnifiedSearchService.js
@@ -1,7 +1,10 @@
/**
- * @copyright 2023, Fon E. Noel NFEBE <fenn25.fn@gmail.com>
+ * @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com>
*
- * @author 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>
*
* @license AGPL-3.0-or-later
*
@@ -20,9 +23,17 @@
*
*/
-import { generateOcsUrl, generateUrl } from '@nextcloud/router'
+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
*
@@ -35,7 +46,7 @@ const createCancelToken = () => axios.CancelToken.source()
*
* @return {Promise<Array>}
*/
-export async function getProviders() {
+export async function getTypes() {
try {
const { data } = await axios.get(generateOcsUrl('search/providers'), {
params: {
@@ -60,13 +71,9 @@ export async function getProviders() {
* @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 }) {
+export function search({ type, query, cursor }) {
/**
* Generate an axios cancel token
*/
@@ -77,10 +84,6 @@ export function search({ type, query, cursor, since, until, limit, person }) {
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,
},
@@ -91,17 +94,3 @@ export function search({ type, query, cursor, since, until, limit, person }) {
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
-}
diff --git a/core/src/services/UnifiedSearchService.js b/core/src/services/UnifiedSearchService.js
index 3c673479771..e477a59eb4c 100644
--- a/core/src/services/UnifiedSearchService.js
+++ b/core/src/services/UnifiedSearchService.js
@@ -1,10 +1,7 @@
/**
- * @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
*
@@ -23,17 +20,9 @@
*
*/
-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
*
@@ -46,7 +35,7 @@ const createCancelToken = () => axios.CancelToken.source()
*
* @return {Promise<Array>}
*/
-export async function getTypes() {
+export async function getProviders() {
try {
const { data } = await axios.get(generateOcsUrl('search/providers'), {
params: {
@@ -71,9 +60,13 @@ export async function getTypes() {
* @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
*/
@@ -84,6 +77,10 @@ export function search({ type, query, cursor }) {
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,
},
@@ -94,3 +91,17 @@ export function search({ type, query, cursor }) {
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
+}
diff --git a/core/src/unified-search.js b/core/src/unified-search.js
index cc390c0d6e7..f9bddff4c68 100644
--- a/core/src/unified-search.js
+++ b/core/src/unified-search.js
@@ -1,7 +1,7 @@
/**
- * @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
*
diff --git a/core/src/views/GlobalSearch.vue b/core/src/views/GlobalSearch.vue
deleted file mode 100644
index 09e2d4f725b..00000000000
--- a/core/src/views/GlobalSearch.vue
+++ /dev/null
@@ -1,96 +0,0 @@
- <!--
- - @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>
diff --git a/core/src/views/LegacyUnifiedSearch.vue b/core/src/views/LegacyUnifiedSearch.vue
new file mode 100644
index 00000000000..04e4c77fe39
--- /dev/null
+++ b/core/src/views/LegacyUnifiedSearch.vue
@@ -0,0 +1,863 @@
+ <!--
+ - @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>
diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue
index e87d7dc6a63..419c0b47c41 100644
--- a/core/src/views/UnifiedSearch.vue
+++ b/core/src/views/UnifiedSearch.vue
@@ -1,7 +1,7 @@
<!--
- - @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
-
@@ -20,845 +20,77 @@
-
-->
<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"
- :title="t('core', 'Search for {name} only', { name: typesMap[filter] })"
- @click.stop="onClickFilter(`in:${filter}`)">
- {{ `in:${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>
diff --git a/core/src/views/GlobalSearchModal.vue b/core/src/views/UnifiedSearchModal.vue
index 96fecd16245..1d6b138fdb3 100644
--- a/core/src/views/GlobalSearchModal.vue
+++ b/core/src/views/UnifiedSearchModal.vue
@@ -1,24 +1,23 @@
<template>
- <NcModal id="global-search"
- ref="globalSearchModal"
+ <NcModal id="unified-search"
+ ref="unifiedSearchModal"
+ :name="t('core', 'Unified search')"
:show.sync="internalIsVisible"
:clear-view-delay="0"
@close="closeModal">
<CustomDateRangeModal :is-open="showDateRangeModal"
- :class="'global-search__date-range'"
+ class="unified-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>
+ <!-- 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="global-search-modal__filters">
+ <div class="unified-search-modal__filters">
<NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen">
<template #icon>
<ListBox :size="20" />
@@ -67,7 +66,7 @@
</template>
</SearchableList>
</div>
- <div class="global-search-modal__filters-applied">
+ <div class="unified-search-modal__filters-applied">
<FilterChip v-for="filter in filters"
:key="filter.id"
:text="filter.name ?? filter.text"
@@ -85,14 +84,14 @@
</template>
</FilterChip>
</div>
- <div v-if="noContentInfo.show" class="global-search-modal__no-content">
+ <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="global-search-modal__results">
+ <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>
@@ -116,7 +115,7 @@
</div>
</div>
</div>
- <div v-if="supportFiltering()" class="global-search-modal__results">
+ <div v-if="supportFiltering()" class="unified-search-modal__results">
<NcButton @click="closeModal">
{{ t('core', 'Filter in current view') }}
<template #icon>
@@ -132,10 +131,10 @@
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 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/GlobalSearch/SearchFilterChip.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'
@@ -145,15 +144,15 @@ 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 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 globalSearch, getContacts } from '../services/GlobalSearchService.js'
+import { getProviders, search as unifiedSearch, getContacts } from '../services/UnifiedSearchService.js'
export default {
- name: 'GlobalSearchModal',
+ name: 'UnifiedSearchModal',
components: {
ArrowRight,
AccountGroup,
@@ -255,7 +254,7 @@ export default {
this.searching = false
return
}
- // Event should probably be refactored at some point to used nextcloud:global-search.search
+ // 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
@@ -289,7 +288,7 @@ export default {
params.limit = this.providerResultLimit
}
- const request = globalSearch(params).request
+ const request = unifiedSearch(params).request
request().then((response) => {
newResults.push({
@@ -300,7 +299,7 @@ export default {
})
console.debug('New results', newResults)
- console.debug('Global search results:', this.results)
+ console.debug('Unified search results:', this.results)
this.updateResults(newResults)
this.searching = false
@@ -534,7 +533,7 @@ export default {
</script>
<style lang="scss" scoped>
-.global-search-modal {
+.unified-search-modal {
padding: 10px 20px 10px 20px;
height: 60%;