- 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
<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> |
<template> | <template> | ||||
<NcModal v-if="isModalOpen" | <NcModal v-if="isModalOpen" | ||||
id="global-search" | |||||
id="unified-search" | |||||
:name="t('core', 'Custom date range')" | :name="t('core', 'Custom date range')" | ||||
:show.sync="isModalOpen" | :show.sync="isModalOpen" | ||||
:size="'small'" | :size="'small'" | ||||
:title="t('core', 'Custom date range')" | :title="t('core', 'Custom date range')" | ||||
@close="closeModal"> | @close="closeModal"> | ||||
<!-- Custom date range --> | <!-- Custom date range --> | ||||
<div class="global-search-custom-date-modal"> | |||||
<div class="unified-search-custom-date-modal"> | |||||
<h1>{{ t('core', 'Custom date range') }}</h1> | <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" | v-model="dateFilter.startFrom" | ||||
:label="t('core', 'Pick start date')" | :label="t('core', 'Pick start date')" | ||||
type="date" /> | type="date" /> | ||||
<NcDateTimePicker :id="'globalsearch-custom-date-range-end'" | |||||
<NcDateTimePicker :id="'unifiedsearch-custom-date-range-end'" | |||||
v-model="dateFilter.endAt" | v-model="dateFilter.endAt" | ||||
:label="t('core', 'Pick end date')" | :label="t('core', 'Pick end date')" | ||||
type="date" /> | type="date" /> | ||||
</div> | </div> | ||||
<div class="global-search-custom-date-modal__footer"> | |||||
<div class="unified-search-custom-date-modal__footer"> | |||||
<NcButton @click="applyCustomRange"> | <NcButton @click="applyCustomRange"> | ||||
{{ t('core', 'Search in date range') }} | {{ t('core', 'Search in date range') }} | ||||
<template #icon> | <template #icon> | ||||
</script> | </script> | ||||
<style lang="scss" scoped> | <style lang="scss" scoped> | ||||
.global-search-custom-date-modal { | |||||
.unified-search-custom-date-modal { | |||||
padding: 10px 20px 10px 20px; | padding: 10px 20px 10px 20px; | ||||
h1 { | h1 { |
<!-- | |||||
- @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> |
<!-- | |||||
- @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> | <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> | </template> | ||||
<script> | <script> | ||||
import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js' | |||||
import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js' | |||||
export default { | export default { | ||||
name: 'SearchResult', | name: 'SearchResult', | ||||
components: { | components: { | ||||
NcHighlight, | |||||
NcListItem, | |||||
}, | }, | ||||
props: { | props: { | ||||
thumbnailUrl: { | thumbnailUrl: { | ||||
type: String, | type: String, | ||||
default: false, | default: false, | ||||
}, | }, | ||||
}, | }, | ||||
data() { | data() { | ||||
return { | 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: { | watch: { | ||||
// Make sure to reset state on change even when vue recycle the component | |||||
thumbnailUrl() { | thumbnailUrl() { | ||||
this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== '' | |||||
this.loaded = false | |||||
this.thumbnailHasError = false | |||||
}, | }, | ||||
}, | }, | ||||
methods: { | methods: { | ||||
reEmitEvent(e) { | |||||
this.$emit(e.type, e) | |||||
}, | |||||
/** | |||||
* If the image fails to load, fallback to iconClass | |||||
*/ | |||||
onError() { | |||||
this.hasValidThumbnail = false | |||||
isValidIconOrPreviewUrl(url) { | |||||
return /^https?:\/\//.test(url) || url.startsWith('/') | |||||
}, | }, | ||||
onLoad() { | |||||
this.loaded = true | |||||
thumbnailErrorHandler() { | |||||
this.thumbnailHasError = true | |||||
}, | }, | ||||
}, | }, | ||||
} | } | ||||
<style lang="scss" scoped> | <style lang="scss" scoped> | ||||
@use "sass:math"; | @use "sass:math"; | ||||
$clickable-area: 44px; | $clickable-area: 44px; | ||||
$margin: 10px; | $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> | </style> |
/** | /** | ||||
* @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 | * @license AGPL-3.0-or-later | ||||
* | * | ||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n' | import { translate as t, translatePlural as n } from '@nextcloud/l10n' | ||||
import Vue from 'vue' | import Vue from 'vue' | ||||
import GlobalSearch from './views/GlobalSearch.vue' | |||||
import UnifiedSearch from './views/LegacyUnifiedSearch.vue' | |||||
// eslint-disable-next-line camelcase | // eslint-disable-next-line camelcase | ||||
__webpack_nonce__ = btoa(getRequestToken()) | __webpack_nonce__ = btoa(getRequestToken()) | ||||
const logger = getLoggerBuilder() | const logger = getLoggerBuilder() | ||||
.setApp('global-search') | |||||
.setApp('unified-search') | |||||
.detectUser() | .detectUser() | ||||
.build() | .build() | ||||
}) | }) | ||||
export default new Vue({ | export default new Vue({ | ||||
el: '#global-search', | |||||
el: '#unified-search', | |||||
// eslint-disable-next-line vue/match-component-file-name | // eslint-disable-next-line vue/match-component-file-name | ||||
name: 'GlobalSearchRoot', | |||||
render: h => h(GlobalSearch), | |||||
name: 'UnifiedSearchRoot', | |||||
render: h => h(UnifiedSearch), | |||||
}) | }) |
/** | /** | ||||
* @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 | * @license AGPL-3.0-or-later | ||||
* | * | ||||
* | * | ||||
*/ | */ | ||||
import { generateOcsUrl, generateUrl } from '@nextcloud/router' | |||||
import { generateOcsUrl } from '@nextcloud/router' | |||||
import { loadState } from '@nextcloud/initial-state' | |||||
import axios from '@nextcloud/axios' | 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 | * Create a cancel token | ||||
* | * | ||||
* | * | ||||
* @return {Promise<Array>} | * @return {Promise<Array>} | ||||
*/ | */ | ||||
export async function getProviders() { | |||||
export async function getTypes() { | |||||
try { | try { | ||||
const { data } = await axios.get(generateOcsUrl('search/providers'), { | const { data } = await axios.get(generateOcsUrl('search/providers'), { | ||||
params: { | params: { | ||||
* @param {string} options.type the type to search | * @param {string} options.type the type to search | ||||
* @param {string} options.query the search | * @param {string} options.query the search | ||||
* @param {number|string|undefined} options.cursor the offset for paginated searches | * @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} | * @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 | * Generate an axios cancel token | ||||
*/ | */ | ||||
params: { | params: { | ||||
term: query, | term: query, | ||||
cursor, | cursor, | ||||
since, | |||||
until, | |||||
limit, | |||||
person, | |||||
// Sending which location we're currently at | // Sending which location we're currently at | ||||
from: window.location.pathname.replace('/index.php', '') + window.location.search, | from: window.location.pathname.replace('/index.php', '') + window.location.search, | ||||
}, | }, | ||||
cancel: cancelToken.cancel, | cancel: cancelToken.cancel, | ||||
} | } | ||||
} | } | ||||
/** | |||||
* Get the list of active contacts | |||||
* | |||||
* @param {object} filter filter contacts by string | |||||
* @param filter.searchTerm | |||||
* @return {object} {request: Promise} | |||||
*/ | |||||
export async function getContacts({ searchTerm }) { | |||||
const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), { | |||||
filter: searchTerm, | |||||
}) | |||||
return contacts | |||||
} |
/** | /** | ||||
* @copyright 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 | * @license AGPL-3.0-or-later | ||||
* | * | ||||
* | * | ||||
*/ | */ | ||||
import { generateOcsUrl } from '@nextcloud/router' | |||||
import { loadState } from '@nextcloud/initial-state' | |||||
import { generateOcsUrl, generateUrl } from '@nextcloud/router' | |||||
import axios from '@nextcloud/axios' | 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 | * Create a cancel token | ||||
* | * | ||||
* | * | ||||
* @return {Promise<Array>} | * @return {Promise<Array>} | ||||
*/ | */ | ||||
export async function getTypes() { | |||||
export async function getProviders() { | |||||
try { | try { | ||||
const { data } = await axios.get(generateOcsUrl('search/providers'), { | const { data } = await axios.get(generateOcsUrl('search/providers'), { | ||||
params: { | params: { | ||||
* @param {string} options.type the type to search | * @param {string} options.type the type to search | ||||
* @param {string} options.query the search | * @param {string} options.query the search | ||||
* @param {number|string|undefined} options.cursor the offset for paginated searches | * @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} | * @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 | * Generate an axios cancel token | ||||
*/ | */ | ||||
params: { | params: { | ||||
term: query, | term: query, | ||||
cursor, | cursor, | ||||
since, | |||||
until, | |||||
limit, | |||||
person, | |||||
// Sending which location we're currently at | // Sending which location we're currently at | ||||
from: window.location.pathname.replace('/index.php', '') + window.location.search, | from: window.location.pathname.replace('/index.php', '') + window.location.search, | ||||
}, | }, | ||||
cancel: cancelToken.cancel, | cancel: cancelToken.cancel, | ||||
} | } | ||||
} | } | ||||
/** | |||||
* Get the list of active contacts | |||||
* | |||||
* @param {object} filter filter contacts by string | |||||
* @param filter.searchTerm | |||||
* @return {object} {request: Promise} | |||||
*/ | |||||
export async function getContacts({ searchTerm }) { | |||||
const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), { | |||||
filter: searchTerm, | |||||
}) | |||||
return contacts | |||||
} |
/** | /** | ||||
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> | |||||
* @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com> | |||||
* | * | ||||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||||
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.com> | |||||
* | * | ||||
* @license AGPL-3.0-or-later | * @license AGPL-3.0-or-later | ||||
* | * |
<!-- | |||||
- @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> |
<!-- | |||||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> | |||||
- | |||||
- @author John Molakvoæ <skjnldsv@protonmail.com> | |||||
- | |||||
- @license GNU AGPL version 3 or any later version | |||||
- | |||||
- This program is free software: you can redistribute it and/or modify | |||||
- it under the terms of the GNU Affero General Public License as | |||||
- published by the Free Software Foundation, either version 3 of the | |||||
- License, or (at your option) any later version. | |||||
- | |||||
- This program is distributed in the hope that it will be useful, | |||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||||
- GNU Affero General Public License for more details. | |||||
- | |||||
- You should have received a copy of the GNU Affero General Public License | |||||
- along with this program. If not, see <http://www.gnu.org/licenses/>. | |||||
- | |||||
--> | |||||
<template> | |||||
<NcHeaderMenu id="unified-search" | |||||
class="unified-search" | |||||
:exclude-click-outside-selectors="['.popover']" | |||||
:open.sync="open" | |||||
:aria-label="ariaLabel" | |||||
@open="onOpen" | |||||
@close="onClose"> | |||||
<!-- Header icon --> | |||||
<template #trigger> | |||||
<Magnify class="unified-search__trigger" | |||||
:size="22/* fit better next to other 20px icons */" /> | |||||
</template> | |||||
<!-- Search form & filters wrapper --> | |||||
<div class="unified-search__input-wrapper"> | |||||
<div class="unified-search__input-row"> | |||||
<NcTextField ref="input" | |||||
:value.sync="query" | |||||
trailing-button-icon="close" | |||||
:label="ariaLabel" | |||||
:trailing-button-label="t('core','Reset search')" | |||||
:show-trailing-button="query !== ''" | |||||
aria-describedby="unified-search-desc" | |||||
class="unified-search__form-input" | |||||
:class="{'unified-search__form-input--with-reset': !!query}" | |||||
:placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })" | |||||
@trailing-button-click="onReset" | |||||
@input="onInputDebounced" /> | |||||
<p id="unified-search-desc" class="hidden-visually"> | |||||
{{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }} | |||||
</p> | |||||
<!-- Search filters --> | |||||
<NcActions v-if="availableFilters.length > 1" | |||||
class="unified-search__filters" | |||||
placement="bottom-end" | |||||
container=".unified-search__input-wrapper"> | |||||
<!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 --> | |||||
<NcActionButton v-for="filter in availableFilters" | |||||
:key="filter" | |||||
icon="icon-filter" | |||||
@click.stop="onClickFilter(`in:${filter}`)"> | |||||
{{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }} | |||||
</NcActionButton> | |||||
</NcActions> | |||||
</div> | |||||
</div> | |||||
<template v-if="!hasResults"> | |||||
<!-- Loading placeholders --> | |||||
<SearchResultPlaceholders v-if="isLoading" /> | |||||
<NcEmptyContent v-else-if="isValidQuery" | |||||
:title="validQueryTitle"> | |||||
<template #icon> | |||||
<Magnify /> | |||||
</template> | |||||
</NcEmptyContent> | |||||
<NcEmptyContent v-else-if="!isLoading || isShortQuery" | |||||
:title="t('core', 'Start typing to search')" | |||||
:description="shortQueryDescription"> | |||||
<template #icon> | |||||
<Magnify /> | |||||
</template> | |||||
</NcEmptyContent> | |||||
</template> | |||||
<!-- Grouped search results --> | |||||
<template v-for="({list, type}, typesIndex) in orderedResults" v-else> | |||||
<h2 :key="type" class="unified-search__results-header"> | |||||
{{ typesMap[type] }} | |||||
</h2> | |||||
<ul :key="type" | |||||
class="unified-search__results" | |||||
:class="`unified-search__results-${type}`" | |||||
:aria-label="typesMap[type]"> | |||||
<!-- Search results --> | |||||
<li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl"> | |||||
<SearchResult v-bind="result" | |||||
:query="query" | |||||
:focused="focused === 0 && typesIndex === 0 && index === 0" | |||||
@focus="setFocusedIndex" /> | |||||
</li> | |||||
<!-- Load more button --> | |||||
<li> | |||||
<SearchResult v-if="!reached[type]" | |||||
class="unified-search__result-more" | |||||
:title="loading[type] | |||||
? t('core', 'Loading more results …') | |||||
: t('core', 'Load more results')" | |||||
:icon-class="loading[type] ? 'icon-loading-small' : ''" | |||||
@click.prevent.stop="loadMore(type)" | |||||
@focus="setFocusedIndex" /> | |||||
</li> | |||||
</ul> | |||||
</template> | |||||
</NcHeaderMenu> | |||||
</template> | |||||
<script> | |||||
import debounce from 'debounce' | |||||
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' | |||||
import { showError } from '@nextcloud/dialogs' | |||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' | |||||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' | |||||
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' | |||||
import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' | |||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' | |||||
import Magnify from 'vue-material-design-icons/Magnify.vue' | |||||
import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue' | |||||
import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue' | |||||
import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/LegacyUnifiedSearchService.js' | |||||
const REQUEST_FAILED = 0 | |||||
const REQUEST_OK = 1 | |||||
const REQUEST_CANCELED = 2 | |||||
export default { | |||||
name: 'LegacyUnifiedSearch', | |||||
components: { | |||||
Magnify, | |||||
NcActionButton, | |||||
NcActions, | |||||
NcEmptyContent, | |||||
NcHeaderMenu, | |||||
SearchResult, | |||||
SearchResultPlaceholders, | |||||
NcTextField, | |||||
}, | |||||
data() { | |||||
return { | |||||
types: [], | |||||
// Cursors per types | |||||
cursors: {}, | |||||
// Various search limits per types | |||||
limits: {}, | |||||
// Loading types | |||||
loading: {}, | |||||
// Reached search types | |||||
reached: {}, | |||||
// Pending cancellable requests | |||||
requests: [], | |||||
// List of all results | |||||
results: {}, | |||||
query: '', | |||||
focused: null, | |||||
triggered: false, | |||||
defaultLimit, | |||||
minSearchLength, | |||||
enableLiveSearch, | |||||
open: false, | |||||
} | |||||
}, | |||||
computed: { | |||||
typesIDs() { | |||||
return this.types.map(type => type.id) | |||||
}, | |||||
typesNames() { | |||||
return this.types.map(type => type.name) | |||||
}, | |||||
typesMap() { | |||||
return this.types.reduce((prev, curr) => { | |||||
prev[curr.id] = curr.name | |||||
return prev | |||||
}, {}) | |||||
}, | |||||
ariaLabel() { | |||||
return t('core', 'Search') | |||||
}, | |||||
/** | |||||
* Is there any result to display | |||||
* | |||||
* @return {boolean} | |||||
*/ | |||||
hasResults() { | |||||
return Object.keys(this.results).length !== 0 | |||||
}, | |||||
/** | |||||
* Return ordered results | |||||
* | |||||
* @return {Array} | |||||
*/ | |||||
orderedResults() { | |||||
return this.typesIDs | |||||
.filter(type => type in this.results) | |||||
.map(type => ({ | |||||
type, | |||||
list: this.results[type], | |||||
})) | |||||
}, | |||||
/** | |||||
* Available filters | |||||
* We only show filters that are available on the results | |||||
* | |||||
* @return {string[]} | |||||
*/ | |||||
availableFilters() { | |||||
return Object.keys(this.results) | |||||
}, | |||||
/** | |||||
* Applied filters | |||||
* | |||||
* @return {string[]} | |||||
*/ | |||||
usedFiltersIn() { | |||||
let match | |||||
const filters = [] | |||||
while ((match = regexFilterIn.exec(this.query)) !== null) { | |||||
filters.push(match[2]) | |||||
} | |||||
return filters | |||||
}, | |||||
/** | |||||
* Applied anti filters | |||||
* | |||||
* @return {string[]} | |||||
*/ | |||||
usedFiltersNot() { | |||||
let match | |||||
const filters = [] | |||||
while ((match = regexFilterNot.exec(this.query)) !== null) { | |||||
filters.push(match[2]) | |||||
} | |||||
return filters | |||||
}, | |||||
/** | |||||
* Valid query empty content title | |||||
* | |||||
* @return {string} | |||||
*/ | |||||
validQueryTitle() { | |||||
return this.triggered | |||||
? t('core', 'No results for {query}', { query: this.query }) | |||||
: t('core', 'Press Enter to start searching') | |||||
}, | |||||
/** | |||||
* Short query empty content description | |||||
* | |||||
* @return {string} | |||||
*/ | |||||
shortQueryDescription() { | |||||
if (!this.isShortQuery) { | |||||
return '' | |||||
} | |||||
return n('core', | |||||
'Please enter {minSearchLength} character or more to search', | |||||
'Please enter {minSearchLength} characters or more to search', | |||||
this.minSearchLength, | |||||
{ minSearchLength: this.minSearchLength }) | |||||
}, | |||||
/** | |||||
* Is the current search too short | |||||
* | |||||
* @return {boolean} | |||||
*/ | |||||
isShortQuery() { | |||||
return this.query && this.query.trim().length < minSearchLength | |||||
}, | |||||
/** | |||||
* Is the current search valid | |||||
* | |||||
* @return {boolean} | |||||
*/ | |||||
isValidQuery() { | |||||
return this.query && this.query.trim() !== '' && !this.isShortQuery | |||||
}, | |||||
/** | |||||
* Have we reached the end of all types searches | |||||
* | |||||
* @return {boolean} | |||||
*/ | |||||
isDoneSearching() { | |||||
return Object.values(this.reached).every(state => state === false) | |||||
}, | |||||
/** | |||||
* Is there any search in progress | |||||
* | |||||
* @return {boolean} | |||||
*/ | |||||
isLoading() { | |||||
return Object.values(this.loading).some(state => state === true) | |||||
}, | |||||
}, | |||||
async created() { | |||||
this.types = await getTypes() | |||||
this.logger.debug('Unified Search initialized with the following providers', this.types) | |||||
}, | |||||
beforeDestroy() { | |||||
unsubscribe('files:navigation:changed', this.onNavigationChange) | |||||
}, | |||||
mounted() { | |||||
// subscribe in mounted, as onNavigationChange relys on $el | |||||
subscribe('files:navigation:changed', this.onNavigationChange) | |||||
if (OCP.Accessibility.disableKeyboardShortcuts()) { | |||||
return | |||||
} | |||||
document.addEventListener('keydown', (event) => { | |||||
// if not already opened, allows us to trigger default browser on second keydown | |||||
if (event.ctrlKey && event.code === 'KeyF' && !this.open) { | |||||
event.preventDefault() | |||||
this.open = true | |||||
} else if (event.ctrlKey && event.key === 'f' && this.open) { | |||||
// User wants to use the native browser search, so we close ours again | |||||
this.open = false | |||||
} | |||||
// https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus | |||||
if (this.open) { | |||||
// If arrow down, focus next result | |||||
if (event.key === 'ArrowDown') { | |||||
this.focusNext(event) | |||||
} | |||||
// If arrow up, focus prev result | |||||
if (event.key === 'ArrowUp') { | |||||
this.focusPrev(event) | |||||
} | |||||
} | |||||
}) | |||||
}, | |||||
methods: { | |||||
async onOpen() { | |||||
// Update types list in the background | |||||
this.types = await getTypes() | |||||
}, | |||||
onClose() { | |||||
emit('nextcloud:unified-search.close') | |||||
}, | |||||
onNavigationChange() { | |||||
this.$el?.querySelector?.('form[role="search"]')?.reset?.() | |||||
}, | |||||
/** | |||||
* Reset the search state | |||||
*/ | |||||
onReset() { | |||||
emit('nextcloud:unified-search.reset') | |||||
this.logger.debug('Search reset') | |||||
this.query = '' | |||||
this.resetState() | |||||
this.focusInput() | |||||
}, | |||||
async resetState() { | |||||
this.cursors = {} | |||||
this.limits = {} | |||||
this.reached = {} | |||||
this.results = {} | |||||
this.focused = null | |||||
this.triggered = false | |||||
await this.cancelPendingRequests() | |||||
}, | |||||
/** | |||||
* Cancel any ongoing searches | |||||
*/ | |||||
async cancelPendingRequests() { | |||||
// Cloning so we can keep processing other requests | |||||
const requests = this.requests.slice(0) | |||||
this.requests = [] | |||||
// Cancel all pending requests | |||||
await Promise.all(requests.map(cancel => cancel())) | |||||
}, | |||||
/** | |||||
* Focus the search input on next tick | |||||
*/ | |||||
focusInput() { | |||||
this.$nextTick(() => { | |||||
this.$refs.input.focus() | |||||
this.$refs.input.select() | |||||
}) | |||||
}, | |||||
/** | |||||
* If we have results already, open first one | |||||
* If not, trigger the search again | |||||
*/ | |||||
onInputEnter() { | |||||
if (this.hasResults) { | |||||
const results = this.getResultsList() | |||||
results[0].click() | |||||
return | |||||
} | |||||
this.onInput() | |||||
}, | |||||
/** | |||||
* Start searching on input | |||||
*/ | |||||
async onInput() { | |||||
// emit the search query | |||||
emit('nextcloud:unified-search.search', { query: this.query }) | |||||
// Do not search if not long enough | |||||
if (this.query.trim() === '' || this.isShortQuery) { | |||||
for (const type of this.typesIDs) { | |||||
this.$delete(this.results, type) | |||||
} | |||||
return | |||||
} | |||||
let types = this.typesIDs | |||||
let query = this.query | |||||
// Filter out types | |||||
if (this.usedFiltersNot.length > 0) { | |||||
types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1) | |||||
} | |||||
// Only use those filters if any and check if they are valid | |||||
if (this.usedFiltersIn.length > 0) { | |||||
types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1) | |||||
} | |||||
// Remove any filters from the query | |||||
query = query.replace(regexFilterIn, '').replace(regexFilterNot, '') | |||||
// Reset search if the query changed | |||||
await this.resetState() | |||||
this.triggered = true | |||||
if (!types.length) { | |||||
// no results since no types were selected | |||||
this.logger.error('No types to search in') | |||||
return | |||||
} | |||||
this.$set(this.loading, 'all', true) | |||||
this.logger.debug(`Searching ${query} in`, types) | |||||
Promise.all(types.map(async type => { | |||||
try { | |||||
// Init cancellable request | |||||
const { request, cancel } = search({ type, query }) | |||||
this.requests.push(cancel) | |||||
// Fetch results | |||||
const { data } = await request() | |||||
// Process results | |||||
if (data.ocs.data.entries.length > 0) { | |||||
this.$set(this.results, type, data.ocs.data.entries) | |||||
} else { | |||||
this.$delete(this.results, type) | |||||
} | |||||
// Save cursor if any | |||||
if (data.ocs.data.cursor) { | |||||
this.$set(this.cursors, type, data.ocs.data.cursor) | |||||
} else if (!data.ocs.data.isPaginated) { | |||||
// If no cursor and no pagination, we save the default amount | |||||
// provided by server's initial state `defaultLimit` | |||||
this.$set(this.limits, type, this.defaultLimit) | |||||
} | |||||
// Check if we reached end of pagination | |||||
if (data.ocs.data.entries.length < this.defaultLimit) { | |||||
this.$set(this.reached, type, true) | |||||
} | |||||
// If none already focused, focus the first rendered result | |||||
if (this.focused === null) { | |||||
this.focused = 0 | |||||
} | |||||
return REQUEST_OK | |||||
} catch (error) { | |||||
this.$delete(this.results, type) | |||||
// If this is not a cancelled throw | |||||
if (error.response && error.response.status) { | |||||
this.logger.error(`Error searching for ${this.typesMap[type]}`, error) | |||||
showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] })) | |||||
return REQUEST_FAILED | |||||
} | |||||
return REQUEST_CANCELED | |||||
} | |||||
})).then(results => { | |||||
// Do not declare loading finished if the request have been cancelled | |||||
// This means another search was triggered and we're therefore still loading | |||||
if (results.some(result => result === REQUEST_CANCELED)) { | |||||
return | |||||
} | |||||
// We finished all searches | |||||
this.loading = {} | |||||
}) | |||||
}, | |||||
onInputDebounced: enableLiveSearch | |||||
? debounce(function(e) { | |||||
this.onInput(e) | |||||
}, 500) | |||||
: function() { | |||||
this.triggered = false | |||||
}, | |||||
/** | |||||
* Load more results for the provided type | |||||
* | |||||
* @param {string} type type | |||||
*/ | |||||
async loadMore(type) { | |||||
// If already loading, ignore | |||||
if (this.loading[type]) { | |||||
return | |||||
} | |||||
if (this.cursors[type]) { | |||||
// Init cancellable request | |||||
const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] }) | |||||
this.requests.push(cancel) | |||||
// Fetch results | |||||
const { data } = await request() | |||||
// Save cursor if any | |||||
if (data.ocs.data.cursor) { | |||||
this.$set(this.cursors, type, data.ocs.data.cursor) | |||||
} | |||||
// Process results | |||||
if (data.ocs.data.entries.length > 0) { | |||||
this.results[type].push(...data.ocs.data.entries) | |||||
} | |||||
// Check if we reached end of pagination | |||||
if (data.ocs.data.entries.length < this.defaultLimit) { | |||||
this.$set(this.reached, type, true) | |||||
} | |||||
} else { | |||||
// If no cursor, we might have all the results already, | |||||
// let's fake pagination and show the next xxx entries | |||||
if (this.limits[type] && this.limits[type] >= 0) { | |||||
this.limits[type] += this.defaultLimit | |||||
// Check if we reached end of pagination | |||||
if (this.limits[type] >= this.results[type].length) { | |||||
this.$set(this.reached, type, true) | |||||
} | |||||
} | |||||
} | |||||
// Focus result after render | |||||
if (this.focused !== null) { | |||||
this.$nextTick(() => { | |||||
this.focusIndex(this.focused) | |||||
}) | |||||
} | |||||
}, | |||||
/** | |||||
* Return a subset of the array if the search provider | |||||
* doesn't supports pagination | |||||
* | |||||
* @param {Array} list the results | |||||
* @param {string} type the type | |||||
* @return {Array} | |||||
*/ | |||||
limitIfAny(list, type) { | |||||
if (type in this.limits) { | |||||
return list.slice(0, this.limits[type]) | |||||
} | |||||
return list | |||||
}, | |||||
getResultsList() { | |||||
return this.$el.querySelectorAll('.unified-search__results .unified-search__result') | |||||
}, | |||||
/** | |||||
* Focus the first result if any | |||||
* | |||||
* @param {Event} event the keydown event | |||||
*/ | |||||
focusFirst(event) { | |||||
const results = this.getResultsList() | |||||
if (results && results.length > 0) { | |||||
if (event) { | |||||
event.preventDefault() | |||||
} | |||||
this.focused = 0 | |||||
this.focusIndex(this.focused) | |||||
} | |||||
}, | |||||
/** | |||||
* Focus the next result if any | |||||
* | |||||
* @param {Event} event the keydown event | |||||
*/ | |||||
focusNext(event) { | |||||
if (this.focused === null) { | |||||
this.focusFirst(event) | |||||
return | |||||
} | |||||
const results = this.getResultsList() | |||||
// If we're not focusing the last, focus the next one | |||||
if (results && results.length > 0 && this.focused + 1 < results.length) { | |||||
event.preventDefault() | |||||
this.focused++ | |||||
this.focusIndex(this.focused) | |||||
} | |||||
}, | |||||
/** | |||||
* Focus the previous result if any | |||||
* | |||||
* @param {Event} event the keydown event | |||||
*/ | |||||
focusPrev(event) { | |||||
if (this.focused === null) { | |||||
this.focusFirst(event) | |||||
return | |||||
} | |||||
const results = this.getResultsList() | |||||
// If we're not focusing the first, focus the previous one | |||||
if (results && results.length > 0 && this.focused > 0) { | |||||
event.preventDefault() | |||||
this.focused-- | |||||
this.focusIndex(this.focused) | |||||
} | |||||
}, | |||||
/** | |||||
* Focus the specified result index if it exists | |||||
* | |||||
* @param {number} index the result index | |||||
*/ | |||||
focusIndex(index) { | |||||
const results = this.getResultsList() | |||||
if (results && results[index]) { | |||||
results[index].focus() | |||||
} | |||||
}, | |||||
/** | |||||
* Set the current focused element based on the target | |||||
* | |||||
* @param {Event} event the focus event | |||||
*/ | |||||
setFocusedIndex(event) { | |||||
const entry = event.target | |||||
const results = this.getResultsList() | |||||
const index = [...results].findIndex(search => search === entry) | |||||
if (index > -1) { | |||||
// let's not use focusIndex as the entry is already focused | |||||
this.focused = index | |||||
} | |||||
}, | |||||
onClickFilter(filter) { | |||||
this.query = `${this.query} ${filter}` | |||||
.replace(/ {2}/g, ' ') | |||||
.trim() | |||||
this.onInput() | |||||
}, | |||||
}, | |||||
} | |||||
</script> | |||||
<style lang="scss" scoped> | |||||
@use "sass:math"; | |||||
$margin: 10px; | |||||
$input-height: 34px; | |||||
$input-padding: 10px; | |||||
.unified-search { | |||||
&__input-wrapper { | |||||
position: sticky; | |||||
// above search results | |||||
z-index: 2; | |||||
top: 0; | |||||
display: inline-flex; | |||||
flex-direction: column; | |||||
align-items: center; | |||||
width: 100%; | |||||
background-color: var(--color-main-background); | |||||
label[for="unified-search__input"] { | |||||
align-self: flex-start; | |||||
font-weight: bold; | |||||
font-size: 19px; | |||||
margin-left: 13px; | |||||
} | |||||
} | |||||
&__form-input { | |||||
margin: 0 !important; | |||||
&:focus, | |||||
&:focus-visible, | |||||
&:active { | |||||
border-color: 2px solid var(--color-main-text) !important; | |||||
box-shadow: 0 0 0 2px var(--color-main-background) !important; | |||||
} | |||||
} | |||||
&__input-row { | |||||
display: flex; | |||||
width: 100%; | |||||
align-items: center; | |||||
} | |||||
&__filters { | |||||
margin: $margin 0 $margin math.div($margin, 2); | |||||
padding-top: 5px; | |||||
ul { | |||||
display: inline-flex; | |||||
justify-content: space-between; | |||||
} | |||||
} | |||||
&__form { | |||||
position: relative; | |||||
width: 100%; | |||||
margin: $margin 0; | |||||
// Loading spinner | |||||
&::after { | |||||
right: $input-padding; | |||||
left: auto; | |||||
} | |||||
&-input, | |||||
&-reset { | |||||
margin: math.div($input-padding, 2); | |||||
} | |||||
&-input { | |||||
width: 100%; | |||||
height: $input-height; | |||||
padding: $input-padding; | |||||
&, | |||||
&[placeholder], | |||||
&::placeholder { | |||||
overflow: hidden; | |||||
white-space: nowrap; | |||||
text-overflow: ellipsis; | |||||
} | |||||
// Hide webkit clear search | |||||
&::-webkit-search-decoration, | |||||
&::-webkit-search-cancel-button, | |||||
&::-webkit-search-results-button, | |||||
&::-webkit-search-results-decoration { | |||||
-webkit-appearance: none; | |||||
} | |||||
} | |||||
&-reset, &-submit { | |||||
position: absolute; | |||||
top: 0; | |||||
right: 4px; | |||||
width: $input-height - $input-padding; | |||||
height: $input-height - $input-padding; | |||||
min-height: 30px; | |||||
padding: 0; | |||||
opacity: .5; | |||||
border: none; | |||||
background-color: transparent; | |||||
margin-right: 0; | |||||
&:hover, | |||||
&:focus, | |||||
&:active { | |||||
opacity: 1; | |||||
} | |||||
} | |||||
&-submit { | |||||
right: 28px; | |||||
} | |||||
} | |||||
&__results { | |||||
&-header { | |||||
display: block; | |||||
margin: $margin; | |||||
margin-bottom: $margin - 4px; | |||||
margin-left: 13px; | |||||
color: var(--color-primary-element); | |||||
font-size: 19px; | |||||
font-weight: bold; | |||||
} | |||||
display: flex; | |||||
flex-direction: column; | |||||
gap: 4px; | |||||
} | |||||
.unified-search__result-more::v-deep { | |||||
color: var(--color-text-maxcontrast); | |||||
} | |||||
.empty-content { | |||||
margin: 10vh 0; | |||||
::v-deep .empty-content__title { | |||||
font-weight: normal; | |||||
font-size: var(--default-font-size); | |||||
text-align: center; | |||||
} | |||||
} | |||||
} | |||||
</style> |
<!-- | <!-- | ||||
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> | |||||
- @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com> | |||||
- | - | ||||
- @author John Molakvoæ <skjnldsv@protonmail.com> | |||||
- @author Fon E. Noel NFEBE <fenn25.fn@gmail.com> | |||||
- | - | ||||
- @license GNU AGPL version 3 or any later version | - @license GNU AGPL version 3 or any later version | ||||
- | - | ||||
- | - | ||||
--> | --> | ||||
<template> | <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> | </template> | ||||
<script> | <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 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 { | export default { | ||||
name: 'UnifiedSearch', | name: 'UnifiedSearch', | ||||
components: { | components: { | ||||
NcButton, | |||||
Magnify, | Magnify, | ||||
NcActionButton, | |||||
NcActions, | |||||
NcEmptyContent, | |||||
NcHeaderMenu, | |||||
SearchResult, | |||||
SearchResultPlaceholders, | |||||
NcTextField, | |||||
UnifiedSearchModal, | |||||
}, | }, | ||||
data() { | data() { | ||||
return { | 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() { | 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: { | 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> | </script> | ||||
<style lang="scss" scoped> | <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; | display: flex; | ||||
width: 100%; | |||||
align-items: center; | 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> | </style> |
<template> | <template> | ||||
<NcModal id="global-search" | |||||
ref="globalSearchModal" | |||||
<NcModal id="unified-search" | |||||
ref="unifiedSearchModal" | |||||
:name="t('core', 'Unified search')" | |||||
:show.sync="internalIsVisible" | :show.sync="internalIsVisible" | ||||
:clear-view-delay="0" | :clear-view-delay="0" | ||||
:title="t('Unified search')" | :title="t('Unified search')" | ||||
@close="closeModal"> | @close="closeModal"> | ||||
<CustomDateRangeModal :is-open="showDateRangeModal" | <CustomDateRangeModal :is-open="showDateRangeModal" | ||||
:class="'global-search__date-range'" | |||||
class="unified-search__date-range" | |||||
@set:custom-date-range="setCustomDateRange" | @set:custom-date-range="setCustomDateRange" | ||||
@update:is-open="showDateRangeModal = $event" /> | @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" | <NcInputField ref="searchInput" | ||||
:value.sync="searchQuery" | :value.sync="searchQuery" | ||||
type="text" | type="text" | ||||
:label="t('core', 'Search apps, files, tags, messages') + '...'" | :label="t('core', 'Search apps, files, tags, messages') + '...'" | ||||
@update:value="debouncedFind" /> | @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"> | <NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen"> | ||||
<template #icon> | <template #icon> | ||||
<ListBox :size="20" /> | <ListBox :size="20" /> | ||||
</template> | </template> | ||||
</SearchableList> | </SearchableList> | ||||
</div> | </div> | ||||
<div class="global-search-modal__filters-applied"> | |||||
<div class="unified-search-modal__filters-applied"> | |||||
<FilterChip v-for="filter in filters" | <FilterChip v-for="filter in filters" | ||||
:key="filter.id" | :key="filter.id" | ||||
:text="filter.name ?? filter.text" | :text="filter.name ?? filter.text" | ||||
</template> | </template> | ||||
</FilterChip> | </FilterChip> | ||||
</div> | </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"> | <NcEmptyContent :name="noContentInfo.text"> | ||||
<template #icon> | <template #icon> | ||||
<component :is="noContentInfo.icon" /> | <component :is="noContentInfo.icon" /> | ||||
</template> | </template> | ||||
</NcEmptyContent> | </NcEmptyContent> | ||||
</div> | </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="results"> | ||||
<div class="result-title"> | <div class="result-title"> | ||||
<span>{{ providerResult.provider }}</span> | <span>{{ providerResult.provider }}</span> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
<div v-if="supportFiltering()" class="global-search-modal__results"> | |||||
<div v-if="supportFiltering()" class="unified-search-modal__results"> | |||||
<NcButton @click="closeModal"> | <NcButton @click="closeModal"> | ||||
{{ t('core', 'Filter in current view') }} | {{ t('core', 'Filter in current view') }} | ||||
<template #icon> | <template #icon> | ||||
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' | import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' | ||||
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue' | import AccountGroup from 'vue-material-design-icons/AccountGroup.vue' | ||||
import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.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 DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue' | ||||
import FilterIcon from 'vue-material-design-icons/Filter.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 ListBox from 'vue-material-design-icons/ListBox.vue' | ||||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' | import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' | ||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' | import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' | ||||
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' | import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' | ||||
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' | import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' | ||||
import MagnifyIcon from 'vue-material-design-icons/Magnify.vue' | 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 debounce from 'debounce' | ||||
import { emit } from '@nextcloud/event-bus' | 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 { | export default { | ||||
name: 'GlobalSearchModal', | |||||
name: 'UnifiedSearchModal', | |||||
components: { | components: { | ||||
ArrowRight, | ArrowRight, | ||||
AccountGroup, | AccountGroup, | ||||
this.searching = false | this.searching = false | ||||
return | 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 }) | emit('nextcloud:unified-search.search', { query }) | ||||
const newResults = [] | const newResults = [] | ||||
const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers | const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers | ||||
params.limit = this.providerResultLimit | params.limit = this.providerResultLimit | ||||
} | } | ||||
const request = globalSearch(params).request | |||||
const request = unifiedSearch(params).request | |||||
request().then((response) => { | request().then((response) => { | ||||
newResults.push({ | newResults.push({ | ||||
}) | }) | ||||
console.debug('New results', newResults) | console.debug('New results', newResults) | ||||
console.debug('Global search results:', this.results) | |||||
console.debug('Unified search results:', this.results) | |||||
this.updateResults(newResults) | this.updateResults(newResults) | ||||
this.searching = false | this.searching = false | ||||
</script> | </script> | ||||
<style lang="scss" scoped> | <style lang="scss" scoped> | ||||
.global-search-modal { | |||||
.unified-search-modal { | |||||
padding: 10px 20px 10px 20px; | padding: 10px 20px 10px 20px; | ||||
height: 60%; | height: 60%; | ||||
</div> | </div> | ||||
<div class="header-right"> | <div class="header-right"> | ||||
<div id="global-search"></div> | |||||
<div id="unified-search"></div> | <div id="unified-search"></div> | ||||
<div id="notifications"></div> | <div id="notifications"></div> | ||||
<div id="contactsmenu"></div> | <div id="contactsmenu"></div> |
$this->initialState->provideInitialState('unified-search', 'limit-default', (int)$this->config->getAppValue('core', 'unified-search.limit-default', (string)SearchQuery::LIMIT_DEFAULT)); | $this->initialState->provideInitialState('unified-search', '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', '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'); | $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 { | } else { | ||||
Util::addScript('core', 'global-search', 'core'); | |||||
Util::addScript('core', 'unified-search', 'core'); | |||||
} | } | ||||
// Set body data-theme | // Set body data-theme | ||||
$this->assign('enabledThemes', []); | $this->assign('enabledThemes', []); |
profile: path.join(__dirname, 'core/src', 'profile.js'), | profile: path.join(__dirname, 'core/src', 'profile.js'), | ||||
recommendedapps: path.join(__dirname, 'core/src', 'recommendedapps.js'), | recommendedapps: path.join(__dirname, 'core/src', 'recommendedapps.js'), | ||||
systemtags: path.resolve(__dirname, 'core/src', 'systemtags/merged-systemtags.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'), | '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': path.join(__dirname, 'core/src', 'unsupported-browser.js'), | ||||
'unsupported-browser-redirect': path.join(__dirname, 'core/src', 'unsupported-browser-redirect.js'), | 'unsupported-browser-redirect': path.join(__dirname, 'core/src', 'unsupported-browser-redirect.js'), | ||||
}, | }, |