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:
fenn-cs 2023-12-07 13:20:59 +01:00 committed by Ferdinand Thiessen
parent 6c482bc5c8
commit 0b171f6095
17 changed files with 1338 additions and 1340 deletions

View File

@ -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>

View File

@ -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 {

View File

@ -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>

View File

@ -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>

View File

@ -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),
})

View File

@ -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
}

View File

@ -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
}

View File

@ -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
*

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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%;

View File

@ -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>

View File

@ -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', []);

View File

@ -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'),
},