- 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>tags/v29.0.0beta1
@@ -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) | |||
}, | |||
/** | |||
* If the image fails to load, fallback to iconClass | |||
*/ | |||
onError() { | |||
this.hasValidThumbnail = false | |||
isValidIconOrPreviewUrl(url) { | |||
return /^https?:\/\//.test(url) || url.startsWith('/') | |||
}, | |||
onLoad() { | |||
this.loaded = true | |||
thumbnailErrorHandler() { | |||
this.thumbnailHasError = true | |||
}, | |||
}, | |||
} | |||
@@ -163,97 +98,72 @@ export default { | |||
<style lang="scss" scoped> | |||
@use "sass:math"; | |||
$clickable-area: 44px; | |||
$margin: 10px; | |||
.unified-search__result { | |||
display: flex; | |||
align-items: center; | |||
height: $clickable-area; | |||
padding: $margin; | |||
border: 2px solid transparent; | |||
border-radius: var(--border-radius-large) !important; | |||
&--focused { | |||
background-color: var(--color-background-hover); | |||
} | |||
&:active, | |||
&:hover, | |||
&:focus { | |||
background-color: var(--color-background-hover); | |||
border: 2px solid var(--color-border-maxcontrast); | |||
} | |||
* { | |||
cursor: pointer; | |||
} | |||
&-icon { | |||
overflow: hidden; | |||
width: $clickable-area; | |||
height: $clickable-area; | |||
border-radius: var(--border-radius); | |||
background-repeat: no-repeat; | |||
background-position: center center; | |||
background-size: 32px; | |||
&--rounded { | |||
border-radius: math.div($clickable-area, 2); | |||
} | |||
&--no-preview { | |||
background-size: 32px; | |||
} | |||
&--with-thumbnail { | |||
background-size: cover; | |||
} | |||
&--with-thumbnail:not(&--rounded) { | |||
// compensate for border | |||
max-width: $clickable-area - 2px; | |||
max-height: $clickable-area - 2px; | |||
border: 1px solid var(--color-border); | |||
} | |||
img { | |||
// Make sure to keep ratio | |||
width: 100%; | |||
height: 100%; | |||
object-fit: cover; | |||
object-position: center; | |||
} | |||
} | |||
&-icon, | |||
&-actions { | |||
flex: 0 0 $clickable-area; | |||
} | |||
&-content { | |||
display: flex; | |||
align-items: center; | |||
flex: 1 1 100%; | |||
flex-wrap: wrap; | |||
// Set to minimum and gro from it | |||
min-width: 0; | |||
padding-left: $margin; | |||
} | |||
&-line-one, | |||
&-line-two { | |||
overflow: hidden; | |||
flex: 1 1 100%; | |||
margin: 1px 0; | |||
white-space: nowrap; | |||
text-overflow: ellipsis; | |||
// Use the same color as the `a` | |||
color: inherit; | |||
font-size: inherit; | |||
} | |||
&-line-two { | |||
opacity: .7; | |||
font-size: var(--default-font-size); | |||
} | |||
.result-items { | |||
&__item { | |||
::v-deep a { | |||
border-radius: 12px; | |||
border: 2px solid transparent; | |||
border-radius: var(--border-radius-large) !important; | |||
&--focused { | |||
background-color: var(--color-background-hover); | |||
} | |||
&:active, | |||
&:hover, | |||
&:focus { | |||
background-color: var(--color-background-hover); | |||
border: 2px solid var(--color-border-maxcontrast); | |||
} | |||
* { | |||
cursor: pointer; | |||
} | |||
} | |||
&-icon { | |||
overflow: hidden; | |||
width: $clickable-area; | |||
height: $clickable-area; | |||
border-radius: var(--border-radius); | |||
background-repeat: no-repeat; | |||
background-position: center center; | |||
background-size: 32px; | |||
&--rounded { | |||
border-radius: math.div($clickable-area, 2); | |||
} | |||
&--no-preview { | |||
background-size: 32px; | |||
} | |||
&--with-thumbnail { | |||
background-size: cover; | |||
} | |||
&--with-thumbnail:not(&--rounded) { | |||
// compensate for border | |||
max-width: $clickable-area - 2px; | |||
max-height: $clickable-area - 2px; | |||
border: 1px solid var(--color-border); | |||
} | |||
img { | |||
// Make sure to keep ratio | |||
width: 100%; | |||
height: 100%; | |||
object-fit: cover; | |||
object-position: center; | |||
} | |||
} | |||
} | |||
} | |||
</style> |
@@ -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() | |||
}, | |||
onClose() { | |||
emit('nextcloud:unified-search.close') | |||
}, | |||
onNavigationChange() { | |||
this.$el?.querySelector?.('form[role="search"]')?.reset?.() | |||
}, | |||
/** | |||
* Reset the search state | |||
*/ | |||
onReset() { | |||
emit('nextcloud:unified-search.reset') | |||
this.logger.debug('Search reset') | |||
this.query = '' | |||
this.resetState() | |||
this.focusInput() | |||
}, | |||
async resetState() { | |||
this.cursors = {} | |||
this.limits = {} | |||
this.reached = {} | |||
this.results = {} | |||
this.focused = null | |||
this.triggered = false | |||
await this.cancelPendingRequests() | |||
}, | |||
/** | |||
* Cancel any ongoing searches | |||
*/ | |||
async cancelPendingRequests() { | |||
// Cloning so we can keep processing other requests | |||
const requests = this.requests.slice(0) | |||
this.requests = [] | |||
// Cancel all pending requests | |||
await Promise.all(requests.map(cancel => cancel())) | |||
}, | |||
/** | |||
* Focus the search input on next tick | |||
*/ | |||
focusInput() { | |||
this.$nextTick(() => { | |||
this.$refs.input.focus() | |||
this.$refs.input.select() | |||
}) | |||
}, | |||
/** | |||
* If we have results already, open first one | |||
* If not, trigger the search again | |||
*/ | |||
onInputEnter() { | |||
if (this.hasResults) { | |||
const results = this.getResultsList() | |||
results[0].click() | |||
return | |||
} | |||
this.onInput() | |||
}, | |||
/** | |||
* Start searching on input | |||
*/ | |||
async onInput() { | |||
// emit the search query | |||
emit('nextcloud:unified-search.search', { query: this.query }) | |||
// Do not search if not long enough | |||
if (this.query.trim() === '' || this.isShortQuery) { | |||
for (const type of this.typesIDs) { | |||
this.$delete(this.results, type) | |||
} | |||
return | |||
} | |||
let types = this.typesIDs | |||
let query = this.query | |||
// Filter out types | |||
if (this.usedFiltersNot.length > 0) { | |||
types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1) | |||
} | |||
// Only use those filters if any and check if they are valid | |||
if (this.usedFiltersIn.length > 0) { | |||
types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1) | |||
} | |||
// Remove any filters from the query | |||
query = query.replace(regexFilterIn, '').replace(regexFilterNot, '') | |||
// Reset search if the query changed | |||
await this.resetState() | |||
this.triggered = true | |||
if (!types.length) { | |||
// no results since no types were selected | |||
this.logger.error('No types to search in') | |||
return | |||
} | |||
this.$set(this.loading, 'all', true) | |||
this.logger.debug(`Searching ${query} in`, types) | |||
Promise.all(types.map(async type => { | |||
try { | |||
// Init cancellable request | |||
const { request, cancel } = search({ type, query }) | |||
this.requests.push(cancel) | |||
// Fetch results | |||
const { data } = await request() | |||
// Process results | |||
if (data.ocs.data.entries.length > 0) { | |||
this.$set(this.results, type, data.ocs.data.entries) | |||
} else { | |||
this.$delete(this.results, type) | |||
} | |||
// Save cursor if any | |||
if (data.ocs.data.cursor) { | |||
this.$set(this.cursors, type, data.ocs.data.cursor) | |||
} else if (!data.ocs.data.isPaginated) { | |||
// If no cursor and no pagination, we save the default amount | |||
// provided by server's initial state `defaultLimit` | |||
this.$set(this.limits, type, this.defaultLimit) | |||
} | |||
// Check if we reached end of pagination | |||
if (data.ocs.data.entries.length < this.defaultLimit) { | |||
this.$set(this.reached, type, true) | |||
} | |||
// If none already focused, focus the first rendered result | |||
if (this.focused === null) { | |||
this.focused = 0 | |||
} | |||
return REQUEST_OK | |||
} catch (error) { | |||
this.$delete(this.results, type) | |||
// If this is not a cancelled throw | |||
if (error.response && error.response.status) { | |||
this.logger.error(`Error searching for ${this.typesMap[type]}`, error) | |||
showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] })) | |||
return REQUEST_FAILED | |||
} | |||
return REQUEST_CANCELED | |||
} | |||
})).then(results => { | |||
// Do not declare loading finished if the request have been cancelled | |||
// This means another search was triggered and we're therefore still loading | |||
if (results.some(result => result === REQUEST_CANCELED)) { | |||
return | |||
} | |||
// We finished all searches | |||
this.loading = {} | |||
}) | |||
}, | |||
onInputDebounced: enableLiveSearch | |||
? debounce(function(e) { | |||
this.onInput(e) | |||
}, 500) | |||
: function() { | |||
this.triggered = false | |||
}, | |||
/** | |||
* Load more results for the provided type | |||
* | |||
* @param {string} type type | |||
*/ | |||
async loadMore(type) { | |||
// If already loading, ignore | |||
if (this.loading[type]) { | |||
return | |||
} | |||
if (this.cursors[type]) { | |||
// Init cancellable request | |||
const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] }) | |||
this.requests.push(cancel) | |||
// Fetch results | |||
const { data } = await request() | |||
// Save cursor if any | |||
if (data.ocs.data.cursor) { | |||
this.$set(this.cursors, type, data.ocs.data.cursor) | |||
} | |||
// Process results | |||
if (data.ocs.data.entries.length > 0) { | |||
this.results[type].push(...data.ocs.data.entries) | |||
} | |||
// Check if we reached end of pagination | |||
if (data.ocs.data.entries.length < this.defaultLimit) { | |||
this.$set(this.reached, type, true) | |||
} | |||
} else { | |||
// If no cursor, we might have all the results already, | |||
// let's fake pagination and show the next xxx entries | |||
if (this.limits[type] && this.limits[type] >= 0) { | |||
this.limits[type] += this.defaultLimit | |||
// Check if we reached end of pagination | |||
if (this.limits[type] >= this.results[type].length) { | |||
this.$set(this.reached, type, true) | |||
} | |||
} | |||
} | |||
// Focus result after render | |||
if (this.focused !== null) { | |||
this.$nextTick(() => { | |||
this.focusIndex(this.focused) | |||
}) | |||
} | |||
}, | |||
/** | |||
* Return a subset of the array if the search provider | |||
* doesn't supports pagination | |||
* | |||
* @param {Array} list the results | |||
* @param {string} type the type | |||
* @return {Array} | |||
*/ | |||
limitIfAny(list, type) { | |||
if (type in this.limits) { | |||
return list.slice(0, this.limits[type]) | |||
} | |||
return list | |||
}, | |||
getResultsList() { | |||
return this.$el.querySelectorAll('.unified-search__results .unified-search__result') | |||
}, | |||
/** | |||
* Focus the first result if any | |||
* | |||
* @param {Event} event the keydown event | |||
*/ | |||
focusFirst(event) { | |||
const results = this.getResultsList() | |||
if (results && results.length > 0) { | |||
if (event) { | |||
event.preventDefault() | |||
} | |||
this.focused = 0 | |||
this.focusIndex(this.focused) | |||
} | |||
}, | |||
/** | |||
* Focus the next result if any | |||
* | |||
* @param {Event} event the keydown event | |||
*/ | |||
focusNext(event) { | |||
if (this.focused === null) { | |||
this.focusFirst(event) | |||
return | |||
} | |||
const results = this.getResultsList() | |||
// If we're not focusing the last, focus the next one | |||
if (results && results.length > 0 && this.focused + 1 < results.length) { | |||
event.preventDefault() | |||
this.focused++ | |||
this.focusIndex(this.focused) | |||
} | |||
}, | |||
/** | |||
* Focus the previous result if any | |||
* | |||
* @param {Event} event the keydown event | |||
*/ | |||
focusPrev(event) { | |||
if (this.focused === null) { | |||
this.focusFirst(event) | |||
return | |||
} | |||
const results = this.getResultsList() | |||
// If we're not focusing the first, focus the previous one | |||
if (results && results.length > 0 && this.focused > 0) { | |||
event.preventDefault() | |||
this.focused-- | |||
this.focusIndex(this.focused) | |||
} | |||
}, | |||
/** | |||
* Focus the specified result index if it exists | |||
* | |||
* @param {number} index the result index | |||
*/ | |||
focusIndex(index) { | |||
const results = this.getResultsList() | |||
if (results && results[index]) { | |||
results[index].focus() | |||
} | |||
}, | |||
/** | |||
* Set the current focused element based on the target | |||
* | |||
* @param {Event} event the focus event | |||
*/ | |||
setFocusedIndex(event) { | |||
const entry = event.target | |||
const results = this.getResultsList() | |||
const index = [...results].findIndex(search => search === entry) | |||
if (index > -1) { | |||
// let's not use focusIndex as the entry is already focused | |||
this.focused = index | |||
} | |||
toggleUnifiedSearch() { | |||
this.showUnifiedSearch = !this.showUnifiedSearch | |||
}, | |||
onClickFilter(filter) { | |||
this.query = `${this.query} ${filter}` | |||
.replace(/ {2}/g, ' ') | |||
.trim() | |||
this.onInput() | |||
handleModalVisibilityChange(newVisibilityVal) { | |||
this.showUnifiedSearch = newVisibilityVal | |||
}, | |||
}, | |||
} | |||
</script> | |||
<style lang="scss" scoped> | |||
@use "sass:math"; | |||
$margin: 10px; | |||
$input-height: 34px; | |||
$input-padding: 10px; | |||
.unified-search { | |||
&__input-wrapper { | |||
position: sticky; | |||
// above search results | |||
z-index: 2; | |||
top: 0; | |||
display: inline-flex; | |||
flex-direction: column; | |||
align-items: center; | |||
width: 100%; | |||
background-color: var(--color-main-background); | |||
.header-menu { | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
label[for="unified-search__input"] { | |||
align-self: flex-start; | |||
font-weight: bold; | |||
font-size: 19px; | |||
margin-left: 13px; | |||
} | |||
} | |||
&__form-input { | |||
margin: 0 !important; | |||
&:focus, | |||
&:focus-visible, | |||
&:active { | |||
border-color: 2px solid var(--color-main-text) !important; | |||
box-shadow: 0 0 0 2px var(--color-main-background) !important; | |||
} | |||
} | |||
&__input-row { | |||
.unified-search__button { | |||
display: flex; | |||
width: 100%; | |||
align-items: center; | |||
} | |||
&__filters { | |||
margin: $margin 0 $margin math.div($margin, 2); | |||
padding-top: 5px; | |||
ul { | |||
display: inline-flex; | |||
justify-content: space-between; | |||
} | |||
} | |||
&__form { | |||
position: relative; | |||
width: 100%; | |||
margin: $margin 0; | |||
// Loading spinner | |||
&::after { | |||
right: $input-padding; | |||
left: auto; | |||
} | |||
&-input, | |||
&-reset { | |||
margin: math.div($input-padding, 2); | |||
} | |||
&-input { | |||
width: 100%; | |||
height: $input-height; | |||
padding: $input-padding; | |||
&, | |||
&[placeholder], | |||
&::placeholder { | |||
overflow: hidden; | |||
white-space: nowrap; | |||
text-overflow: ellipsis; | |||
} | |||
// Hide webkit clear search | |||
&::-webkit-search-decoration, | |||
&::-webkit-search-cancel-button, | |||
&::-webkit-search-results-button, | |||
&::-webkit-search-results-decoration { | |||
-webkit-appearance: none; | |||
} | |||
} | |||
&-reset, &-submit { | |||
position: absolute; | |||
top: 0; | |||
right: 4px; | |||
width: $input-height - $input-padding; | |||
height: $input-height - $input-padding; | |||
min-height: 30px; | |||
padding: 0; | |||
opacity: .5; | |||
border: none; | |||
background-color: transparent; | |||
margin-right: 0; | |||
&:hover, | |||
&:focus, | |||
&:active { | |||
opacity: 1; | |||
} | |||
} | |||
&-submit { | |||
right: 28px; | |||
justify-content: center; | |||
width: var(--header-height); | |||
// height: var(--header-height); | |||
margin: 0; | |||
padding: 0; | |||
cursor: pointer; | |||
opacity: .85; | |||
background-color: transparent; | |||
border: none; | |||
filter: none !important; | |||
color: var(--color-primary-text) !important; | |||
&:hover { | |||
background-color: transparent !important; | |||
} | |||
} | |||
} | |||
&__results { | |||
&-header { | |||
display: block; | |||
margin: $margin; | |||
margin-bottom: $margin - 4px; | |||
margin-left: 13px; | |||
color: var(--color-primary-element); | |||
font-size: 19px; | |||
font-weight: bold; | |||
} | |||
display: flex; | |||
flex-direction: column; | |||
gap: 4px; | |||
} | |||
.unified-search__result-more::v-deep { | |||
color: var(--color-text-maxcontrast); | |||
} | |||
.empty-content { | |||
margin: 10vh 0; | |||
::v-deep .empty-content__title { | |||
font-weight: normal; | |||
font-size: var(--default-font-size); | |||
text-align: center; | |||
} | |||
.unified-search-modal { | |||
::v-deep .modal-container { | |||
height: 80%; | |||
} | |||
} | |||
</style> |
@@ -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'), | |||
}, |