mirror of https://github.com/nextcloud/server
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>
This commit is contained in:
parent
6c482bc5c8
commit
0b171f6095
|
@ -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>
|
|
@ -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 {
|
|
@ -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>
|
|
@ -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)
|
||||
isValidIconOrPreviewUrl(url) {
|
||||
return /^https?:\/\//.test(url) || url.startsWith('/')
|
||||
},
|
||||
|
||||
/**
|
||||
* If the image fails to load, fallback to iconClass
|
||||
*/
|
||||
onError() {
|
||||
this.hasValidThumbnail = false
|
||||
},
|
||||
|
||||
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;
|
||||
.result-items {
|
||||
&__item {
|
||||
|
||||
&--focused {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
::v-deep a {
|
||||
border-radius: 12px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--border-radius-large) !important;
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-background-hover);
|
||||
border: 2px solid var(--color-border-maxcontrast);
|
||||
}
|
||||
&--focused {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
&:active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-background-hover);
|
||||
border: 2px solid var(--color-border-maxcontrast);
|
||||
}
|
||||
|
||||
&-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);
|
||||
}
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
// Make sure to keep ratio
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
}
|
||||
&-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;
|
||||
|
||||
&-icon,
|
||||
&-actions {
|
||||
flex: 0 0 $clickable-area;
|
||||
}
|
||||
&--rounded {
|
||||
border-radius: math.div($clickable-area, 2);
|
||||
}
|
||||
|
||||
&-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;
|
||||
}
|
||||
&--no-preview {
|
||||
background-size: 32px;
|
||||
}
|
||||
|
||||
&-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);
|
||||
}
|
||||
&--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>
|
||||
|
|
|
@ -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),
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,844 +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"
|
||||
@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()
|
||||
toggleUnifiedSearch() {
|
||||
this.showUnifiedSearch = !this.showUnifiedSearch
|
||||
},
|
||||
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()
|
||||
handleModalVisibilityChange(newVisibilityVal) {
|
||||
this.showUnifiedSearch = newVisibilityVal
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "sass:math";
|
||||
.header-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
$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 {
|
||||
.unified-search__button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
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;
|
||||
|
||||
&__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;
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unified-search-modal {
|
||||
::v-deep .modal-container {
|
||||
height: 80%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
<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"
|
||||
:title="t('Unified search')"
|
||||
@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" />
|
||||
|
@ -68,7 +67,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"
|
||||
|
@ -86,14 +85,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>
|
||||
|
@ -117,7 +116,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>
|
||||
|
@ -133,10 +132,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'
|
||||
|
@ -146,15 +145,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,
|
||||
|
@ -256,7 +255,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
|
||||
|
@ -290,7 +289,7 @@ export default {
|
|||
params.limit = this.providerResultLimit
|
||||
}
|
||||
|
||||
const request = globalSearch(params).request
|
||||
const request = unifiedSearch(params).request
|
||||
|
||||
request().then((response) => {
|
||||
newResults.push({
|
||||
|
@ -301,7 +300,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
|
||||
|
@ -535,7 +534,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.global-search-modal {
|
||||
.unified-search-modal {
|
||||
padding: 10px 20px 10px 20px;
|
||||
height: 60%;
|
||||
|
|
@ -68,7 +68,6 @@ p($theme->getTitle());
|
|||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div id="global-search"></div>
|
||||
<div id="unified-search"></div>
|
||||
<div id="notifications"></div>
|
||||
<div id="contactsmenu"></div>
|
||||
|
|
|
@ -113,9 +113,9 @@ class TemplateLayout extends \OC_Template {
|
|||
$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', []);
|
||||
|
|
|
@ -38,8 +38,8 @@ module.exports = {
|
|||
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'),
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue