Merge pull request #40823 from nextcloud/39162-global-search-2.0

New UI for global search
This commit is contained in:
F. E Noel Nfebe 2023-11-10 12:40:02 +01:00 committed by GitHub
commit 95e5642fa0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
97 changed files with 1413 additions and 146 deletions

View file

@ -2395,4 +2395,11 @@ $CONFIG = [
* Defaults to ``true``
*/
'reference_opengraph' => true,
/**
* Enable use of old unified search
*
* Defaults to ``false``
*/
'unified_search.enabled' => false,
];

View file

@ -0,0 +1,98 @@
<template>
<NcModal v-if="isModalOpen"
id="global-search"
:name="t('core', 'Date range filter')"
:show.sync="isModalOpen"
:size="'small'"
:clear-view-delay="0"
:title="t('Date range filter')"
@close="closeModal">
<!-- Custom date range -->
<div class="global-search-custom-date-modal">
<h1>{{ t('core', 'Date range filter') }}</h1>
<div class="global-search-custom-date-modal__pickers">
<NcDateTimePicker :id="'globalsearch-custom-date-range-start'"
v-model="dateFilter.startFrom"
:max="new Date()"
:label="t('core', 'Pick start date')"
type="date" />
<NcDateTimePicker :id="'globalsearch-custom-date-range-end'"
v-model="dateFilter.endAt"
:max="new Date()"
:label="t('core', 'Pick end date')"
type="date" />
</div>
<NcButton @click="applyCustomRange">
{{ t('core', 'Apply range') }}
<template #icon>
<CalendarRangeIcon :size="20" />
</template>
</NcButton>
</div>
</NcModal>
</template>
<script>
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
export default {
name: 'CustomDateRangeModal',
components: {
NcButton,
NcModal,
CalendarRangeIcon,
NcDateTimePicker,
},
props: {
isOpen: {
type: Boolean,
required: true,
},
},
data() {
return {
dateFilter: { startFrom: null, endAt: null },
}
},
computed: {
isModalOpen: {
get() {
return this.isOpen
},
set(value) {
this.$emit('update:is-open', value)
},
},
},
methods: {
closeModal() {
this.isModalOpen = false
},
applyCustomRange() {
this.$emit('set:custom-date-range', this.dateFilter)
this.closeModal()
},
},
}
</script>
<style lang="scss" scoped>
.global-search-custom-date-modal {
padding: 10px 20px 10px 20px;
h1 {
font-size: 16px;
font-weight: bolder;
line-height: 2em;
}
&__pickers {
display: flex;
flex-direction: column;
}
}
</style>

View file

@ -0,0 +1,71 @@
<template>
<div class="chip">
<span class="icon">
<slot name="icon" />
<span v-if="pretext.length"> {{ pretext }} : </span>
</span>
<span class="text">{{ text }}</span>
<span class="close-icon" @click="deleteChip">
<CloseIcon :size="16" />
</span>
</div>
</template>
<script>
import CloseIcon from 'vue-material-design-icons/CloseThick.vue'
export default {
name: 'SearchFilterChip',
components: {
CloseIcon,
},
props: {
text: String,
pretext: String,
},
methods: {
deleteChip() {
this.$emit('delete', this.filter)
},
},
}
</script>
<style lang="scss" scoped>
.chip {
display: flex;
align-items: center;
padding: 2px 4px;
border: 1px solid var(--color-primary-element-light);
border-radius: 20px;
background-color: var(--color-primary-element-light);
margin: 2px;
font-size: 10px;
font-weight: bolder;
.icon {
display: flex;
align-items: center;
padding-right: 5px;
img {
width: 20px;
padding: 2px;
border-radius: 20px;
}
}
.text {
margin: 0 2px;
}
.close-icon {
cursor: pointer;
:hover {
border-radius: 4px;
padding: 1px;
}
}
}
</style>

View file

@ -0,0 +1,157 @@
<!--
- @copyright 2023 Marco Ambrosini <marcoambrosini@proton.me>
-
- @author Marco Ambrosini <marcoambrosini@proton.me>
-
- @license AGPL-3.0-or-later
-
- 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>
<NcPopover :shown="opened">
<template #trigger>
<slot name="trigger" />
</template>
<div class="searchable-list__wrapper">
<NcTextField :value.sync="searchTerm"
:label="labelText"
trailing-button-icon="close"
:show-trailing-button="searchTerm !== ''"
@trailing-button-click="clearSearch">
<Magnify :size="20" />
</NcTextField>
<ul v-if="filteredList.length > 0" class="searchable-list__list">
<li v-for="element in filteredList"
:key="element.id"
:title="element.displayName"
role="button">
<NcButton alignment="start"
type="tertiary"
:wide="true"
@click="itemSelected(element)">
<template #icon>
<NcAvatar :user="element.user" :show-user-status="false" :hide-favorite="false" />
</template>
{{ element.displayName }}
</NcButton>
</li>
</ul>
<div v-else class="searchable-list__empty-content">
<NcEmptyContent :name="emptyContentText">
<template #icon>
<AlertCircleOutline />
</template>
</NcEmptyContent>
</div>
</div>
</NcPopover>
</template>
<script>
import { NcPopover, NcTextField, NcAvatar, NcEmptyContent, NcButton } from '@nextcloud/vue'
import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import Magnify from 'vue-material-design-icons/Magnify.vue'
export default {
name: 'SearchableList',
components: {
NcPopover,
NcTextField,
Magnify,
AlertCircleOutline,
NcAvatar,
NcEmptyContent,
NcButton,
},
props: {
labelText: {
type: String,
default: 'this is a label',
},
searchList: {
type: Array,
required: true,
},
emptyContentText: {
type: String,
required: true,
},
},
data() {
return {
opened: false,
error: false,
searchTerm: '',
}
},
computed: {
filteredList() {
return this.searchList.filter((element) => {
if (!this.searchTerm.toLowerCase().length) {
return true
}
return ['displayName'].some(prop => element[prop].toLowerCase().includes(this.searchTerm.toLowerCase()))
})
},
},
methods: {
clearSearch() {
this.searchTerm = ''
},
itemSelected(element) {
this.$emit('item-selected', element)
this.clearSearch()
this.opened = false
},
},
}
</script>
<style lang="scss" scoped>
.searchable-list {
&__wrapper {
padding: calc(var(--default-grid-baseline) * 3);
display: flex;
flex-direction: column;
align-items: center;
width: 250px;
}
&__list {
width: 100%;
max-height: 284px;
overflow-y: auto;
margin-top: var(--default-grid-baseline);
padding: var(--default-grid-baseline);
:deep(.button-vue) {
border-radius: var(--border-radius-large) !important;
}
}
&__empty-content {
margin-top: calc(var(--default-grid-baseline) * 3);
}
}
</style>

55
core/src/global-search.js Normal file
View file

@ -0,0 +1,55 @@
/**
* @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
import { getLoggerBuilder } from '@nextcloud/logger'
import { getRequestToken } from '@nextcloud/auth'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Vue from 'vue'
import GlobalSearch from './views/GlobalSearch.vue'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())
const logger = getLoggerBuilder()
.setApp('global-search')
.detectUser()
.build()
Vue.mixin({
data() {
return {
logger,
}
},
methods: {
t,
n,
},
})
export default new Vue({
el: '#global-search',
// eslint-disable-next-line vue/match-component-file-name
name: 'GlobalSearchRoot',
render: h => h(GlobalSearch),
})

View file

@ -0,0 +1,107 @@
/**
* @copyright 2023, Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
/**
* Create a cancel token
*
* @return {import('axios').CancelTokenSource}
*/
const createCancelToken = () => axios.CancelToken.source()
/**
* Get the list of available search providers
*
* @return {Promise<Array>}
*/
export async function getProviders() {
try {
const { data } = await axios.get(generateOcsUrl('search/providers'), {
params: {
// Sending which location we're currently at
from: window.location.pathname.replace('/index.php', '') + window.location.search,
},
})
if ('ocs' in data && 'data' in data.ocs && Array.isArray(data.ocs.data) && data.ocs.data.length > 0) {
// Providers are sorted by the api based on their order key
return data.ocs.data
}
} catch (error) {
console.error(error)
}
return []
}
/**
* Get the list of available search providers
*
* @param {object} options destructuring object
* @param {string} options.type the type to search
* @param {string} options.query the search
* @param {number|string|undefined} options.cursor the offset for paginated searches
* @param {string} options.since the search
* @param {string} options.until the search
* @param {string} options.limit the search
* @param {string} options.person the search
* @return {object} {request: Promise, cancel: Promise}
*/
export function search({ type, query, cursor, since, until, limit, person }) {
/**
* Generate an axios cancel token
*/
const cancelToken = createCancelToken()
const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), {
cancelToken: cancelToken.token,
params: {
term: query,
cursor,
since,
until,
limit,
person,
// Sending which location we're currently at
from: window.location.pathname.replace('/index.php', '') + window.location.search,
},
})
return {
request,
cancel: cancelToken.cancel,
}
}
/**
* Get the list of active contacts
*
* @param {object} filter filter contacts by string
* @param filter.searchTerm
* @return {object} {request: Promise}
*/
export async function getContacts({ searchTerm }) {
const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
filter: searchTerm,
})
return contacts
}

View file

@ -0,0 +1,93 @@
<!--
- @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', 'Global search')" @click="toggleGlobalSearch">
<template #icon>
<Magnify class="global-search__trigger" :size="22" />
</template>
</NcButton>
<GlobalSearchModal :is-visible="showGlobalSearch" :class="'global-search-modal'" />
</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
},
},
}
</script>
<style lang="scss" scoped>
.header-menu {
display: flex;
align-items: center;
justify-content: center;
.global-search__button {
display: flex;
align-items: center;
justify-content: center;
width: var(--header-height);
// height: var(--header-height);
margin: 0;
padding: 0;
cursor: pointer;
opacity: .85;
background-color: transparent;
border: none;
filter: none !important;
color: var(--color-primary-text) !important;
&:hover {
background-color: transparent !important;
}
}
}
.global-search-modal {
::v-deep .modal-container {
height: 80%;
}
}
</style>

View file

@ -0,0 +1,648 @@
<template>
<NcModal v-if="isVisible"
id="global-search"
:name="t('core', 'Global search')"
:show.sync="isVisible"
:clear-view-delay="0"
:title="t('Global search')"
@close="closeModal">
<CustomDateRangeModal :is-open="showDateRangeModal"
:class="'global-search__date-range'"
@set:custom-date-range="setCustomDateRange"
@update:is-open="showDateRangeModal = $event" />
<!-- Global search form -->
<div ref="globalSearch" class="global-search-modal">
<h1>{{ t('core', 'Global search') }}</h1>
<NcInputField :value.sync="searchQuery"
type="text"
:label="t('core', 'Search apps, files, tags, messages') + '...'"
@update:value="debouncedFind" />
<div class="global-search-modal__filters">
<NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen">
<template #icon>
<ListBox :size="20" />
</template>
<NcActionButton v-for="provider in providers" :key="provider.id" @click="addProviderFilter(provider)">
<template #icon>
<img :src="provider.icon">
</template>
{{ t('core', provider.name) }}
</NcActionButton>
</NcActions>
<NcActions :menu-name="t('core', 'Modified')" :open.sync="dateActionMenuIsOpen">
<template #icon>
<CalendarRangeIcon :size="20" />
</template>
<NcActionButton @click="applyQuickDateRange('today')">
{{ t('core', 'Today') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('7days')">
{{ t('core', 'Last 7 days') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('30days')">
{{ t('core', 'Last 30 days') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('thisyear')">
{{ t('core', 'This year') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('lastyear')">
{{ t('core', 'Last year') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('custom')">
{{ t('core', 'Custom date range') }}
</NcActionButton>
</NcActions>
<SearchableList :label-text="t('core', 'Search people')"
:search-list="userContacts"
:empty-content-text="t('core', 'Not found')"
@item-selected="applyPersonFilter">
<template #trigger>
<NcButton>
<template #icon>
<AccountGroup :size="20" />
</template>
{{ t('core', 'People') }}
</NcButton>
</template>
</SearchableList>
</div>
<div class="global-search-modal__filters-applied">
<FilterChip v-for="filter in filters"
:key="filter.id"
:text="filter.name ?? filter.text"
:pretext="''"
@delete="removeFilter(filter)">
<template #icon>
<NcAvatar v-if="filter.type === 'person'"
:user="filter.user"
:show-user-status="false"
:hide-favorite="false" />
<CalendarRangeIcon v-else-if="filter.type === 'date'" />
<img v-else :src="filter.icon" alt="">
</template>
</FilterChip>
</div>
<div v-if="searchQuery.length === 0">
<NcEmptyContent :name="t('core', 'Start typing in search')">
<template #icon>
<MagnifyIcon />
</template>
</NcEmptyContent>
</div>
<div v-for="providerResult in results" :key="providerResult.id" class="global-search-modal__results">
<div class="results">
<div class="result-title">
<span>{{ providerResult.provider }}</span>
</div>
<ul class="result-items">
<NcListItem v-for="(result, index) in providerResult.results"
:key="index"
class="result-items__item"
:name="result.title ?? ''"
:bold="false"
@click="openResult(result)">
<template #icon>
<div v-if="result.icon"
class="result-items__item-icon"
:class="{
'result-items__item-icon--no-preview': !isValidUrl(result.thumbnailUrl),
'result-items__item-icon--with-thumbnail': isValidUrl(result.thumbnailUrl),
[result.icon]: !isValidUrl(result.icon),
}"
:style="{
backgroundImage: isValidUrl(result.icon) ? `url(${result.icon})` : '',
}">
<img v-if="result.thumbnailUrl" :src="result.thumbnailUrl" class="">
</div>
</template>
<template #subname>
{{ result.subline }}
</template>
</NcListItem>
</ul>
<div class="result-footer">
<NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)">
Load more results
<template #icon>
<DotsHorizontalIcon :size="20" />
</template>
</NcButton>
<NcButton alignment="end-reverse" type="tertiary-no-background">
Search in {{ providerResult.provider }}
<template #icon>
<ArrowRight :size="20" />
</template>
</NcButton>
</div>
</div>
</div>
</div>
</NcModal>
</template>
<script>
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
import CustomDateRangeModal from '../components/GlobalSearch/CustomDateRangeModal.vue'
import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
import FilterChip from '../components/GlobalSearch/SearchFilterChip.vue'
import ListBox from 'vue-material-design-icons/ListBox.vue'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
import MagnifyIcon from 'vue-material-design-icons/Magnify.vue'
import SearchableList from '../components/GlobalSearch/SearchableList.vue'
import debounce from 'debounce'
import { getProviders, search as globalSearch, getContacts } from '../services/GlobalSearchService.js'
export default {
name: 'GlobalSearchModal',
components: {
ArrowRight,
AccountGroup,
CalendarRangeIcon,
CustomDateRangeModal,
DotsHorizontalIcon,
FilterChip,
ListBox,
NcActions,
NcActionButton,
NcAvatar,
NcButton,
NcEmptyContent,
NcModal,
NcListItem,
NcInputField,
MagnifyIcon,
SearchableList,
},
props: {
isVisible: {
type: Boolean,
required: true,
},
},
data() {
return {
providers: [],
providerActionMenuIsOpen: false,
dateActionMenuIsOpen: false,
providerResultLimit: 5,
dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
personFilter: { id: 'person', type: 'person', name: '' },
dateFilterIsApplied: false,
personFilterIsApplied: false,
filteredProviders: [],
searchQuery: '',
placesFilter: '',
dateTimeFilter: null,
filters: [],
results: [],
contacts: [],
debouncedFind: debounce(this.find, 300),
showDateRangeModal: false,
}
},
computed: {
userContacts: {
get() {
return this.contacts
},
},
},
mounted() {
getProviders().then((providers) => {
this.providers = providers
console.debug('Search providers', this.providers)
})
getContacts({ filter: '' }).then((contacts) => {
this.contacts = this.mapContacts(contacts)
console.debug('Contacts', this.contacts)
})
},
methods: {
find(query) {
if (query.length === 0) {
this.results = []
return
}
const newResults = []
const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers
const searchProvider = (provider, filters) => {
const params = {
type: provider.id,
query,
cursor: null,
}
if (filters.dateFilterIsApplied) {
if (provider.filters.since && provider.filters.until) {
params.since = this.dateFilter.startFrom
params.until = this.dateFilter.endAt
} else {
// Date filter is applied but provider does not support it, no need to search provider
return
}
}
if (filters.personFilterIsApplied) {
if (provider.filters.person) {
params.person = this.personFilter.id
} else {
// Person filter is applied but provider does not support it, no need to search provider
return
}
}
if (this.providerResultLimit > 5) {
params.limit = this.providerResultLimit
}
const request = globalSearch(params).request
request().then((response) => {
newResults.push({
id: provider.id,
provider: provider.name,
results: response.data.ocs.data.entries,
})
console.debug('New results', newResults)
console.debug('Global search results:', this.results)
this.updateResults(newResults)
})
}
providersToSearch.forEach(provider => {
const dateFilterIsApplied = this.dateFilterIsApplied
const personFilterIsApplied = this.personFilterIsApplied
searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied })
})
},
updateResults(newResults) {
let updatedResults = [...this.results]
// If filters are applied, remove any previous results for providers that are not in current filters
if (this.filters.length > 0) {
updatedResults = updatedResults.filter(result => {
return this.filters.some(filter => filter.id === result.id)
})
}
// Process the new results
newResults.forEach(newResult => {
const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id)
if (existingResultIndex !== -1) {
if (newResult.results.length === 0) {
// If the new results data has no matches for and existing result, remove the existing result
updatedResults.splice(existingResultIndex, 1)
} else {
// If input triggered a change in existing results, update existing result
updatedResults.splice(existingResultIndex, 1, newResult)
}
} else if (newResult.results.length > 0) {
// Push the new result to the array only if its results array is not empty
updatedResults.push(newResult)
}
})
const sortedResults = updatedResults.slice(0)
// Order results according to provider preference
sortedResults.sort((a, b) => {
const aProvider = this.providers.find(provider => provider.id === a.id)
const bProvider = this.providers.find(provider => provider.id === b.id)
const aOrder = aProvider ? aProvider.order : 0
const bOrder = bProvider ? bProvider.order : 0
return aOrder - bOrder
})
this.results = sortedResults
},
openResult(result) {
if (result.resourceUrl) {
window.location = result.resourceUrl
}
},
mapContacts(contacts) {
return contacts.map(contact => {
return {
// id: contact.id,
// name: '',
displayName: contact.fullName,
isNoUser: false,
subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '',
icon: '',
user: contact.id,
}
})
},
filterContacts(query) {
getContacts({ filter: query }).then((contacts) => {
this.contacts = this.mapContacts(contacts)
console.debug(`Contacts filtered by ${query}`, this.contacts)
})
},
applyPersonFilter(person) {
this.personFilterIsApplied = true
const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id)
if (existingPersonFilter === -1) {
this.personFilter.id = person.id
this.personFilter.user = person.user
this.personFilter.name = person.displayName
this.filters.push(this.personFilter)
} else {
this.filters[existingPersonFilter].id = person.id
this.filters[existingPersonFilter].user = person.user
this.filters[existingPersonFilter].name = person.displayName
}
this.debouncedFind(this.searchQuery)
console.debug('Person filter applied', person)
},
loadMoreResultsForProvider(providerId) {
this.providerResultLimit += 5
this.filters = this.filters.filter(filter => filter.type !== 'provider')
const provider = this.providers.find(provider => provider.id === providerId)
this.addProviderFilter(provider, true)
},
addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
if (!providerFilter.id) return
this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
this.providerActionMenuIsOpen = false
const existingFilter = this.filteredProviders.find(existing => existing.id === providerFilter.id)
if (!existingFilter) {
this.filteredProviders.push({ id: providerFilter.id, name: providerFilter.name, icon: providerFilter.icon, type: 'provider' })
}
this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
console.debug('Search filters (newly added)', this.filters)
this.debouncedFind(this.searchQuery)
},
removeFilter(filter) {
if (filter.type === 'provider') {
for (let i = 0; i < this.filteredProviders.length; i++) {
if (this.filteredProviders[i].id === filter.id) {
this.filteredProviders.splice(i, 1)
break
}
}
this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
console.debug('Search filters (recently removed)', this.filters)
} else {
for (let i = 0; i < this.filters.length; i++) {
if (this.filters[i].id === 'date') {
this.dateFilterIsApplied = false
this.filters.splice(i, 1)
break
}
}
}
this.debouncedFind(this.searchQuery)
},
syncProviderFilters(firstArray, secondArray) {
// Create a copy of the first array to avoid modifying it directly.
const synchronizedArray = firstArray.slice()
// Remove items from the synchronizedArray that are not in the secondArray.
synchronizedArray.forEach((item, index) => {
const itemId = item.id
if (item.type === 'provider') {
if (!secondArray.some(secondItem => secondItem.id === itemId)) {
synchronizedArray.splice(index, 1)
}
}
})
// Add items to the synchronizedArray that are in the secondArray but not in the firstArray.
secondArray.forEach(secondItem => {
const itemId = secondItem.id
if (secondItem.type === 'provider') {
if (!synchronizedArray.some(item => item.id === itemId)) {
synchronizedArray.push(secondItem)
}
}
})
return synchronizedArray
},
updateDateFilter() {
const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date')
if (currFilterIndex !== -1) {
this.filters[currFilterIndex] = this.dateFilter
} else {
this.filters.push(this.dateFilter)
}
this.dateFilterIsApplied = true
this.debouncedFind(this.searchQuery)
},
applyQuickDateRange(range) {
this.dateActionMenuIsOpen = false
const today = new Date()
let endDate = today
let startDate
switch (range) {
case 'today':
// For 'Today', both start and end are set to today
startDate = today
this.dateFilter.text = t('core', 'Today')
break
case '7days':
// For 'Last 7 days', start date is 7 days ago, end is today
startDate = new Date(today)
startDate.setDate(today.getDate() - 7)
this.dateFilter.text = t('core', 'Last 7 days')
break
case '30days':
// For 'Last 30 days', start date is 30 days ago, end is today
startDate = new Date(today)
startDate.setDate(today.getDate() - 30)
this.dateFilter.text = t('core', 'Last 30 days')
break
case 'thisyear':
// For 'This year', start date is the first day of the year, end is today
startDate = new Date(today.getFullYear(), 0, 1)
this.dateFilter.text = t('core', 'This year')
break
case 'lastyear':
// For 'Last year', start date is the first day of the previous year, end is the last day of the previous year
startDate = new Date(today.getFullYear() - 1, 0, 1)
endDate = new Date(today.getFullYear() - 1, 11, 31)
this.dateFilter.text = t('core', 'Last year')
break
case 'custom':
this.showDateRangeModal = true
return
default:
return
}
this.dateFilter.startFrom = startDate
this.dateFilter.endAt = endDate
this.updateDateFilter()
},
setCustomDateRange(event) {
console.debug('Custom date range', event)
this.dateFilter.startFrom = event.startFrom
this.dateFilter.endAt = event.endAt
this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
this.updateDateFilter()
},
isValidUrl(icon) {
return /^https?:\/\//.test(icon) || icon.startsWith('//')
},
closeModal() {
this.searchQuery = ''
},
},
}
</script>
<style lang="scss" scoped>
@use "sass:math";
$clickable-area: 44px;
$margin: 10px;
.global-search-modal {
padding: 10px 20px 10px 20px;
height: 60%;
h1 {
font-size: 16px;
font-weight: bolder;
line-height: 2em;
}
&__filters {
display: flex;
padding-top: 5px;
justify-content: space-between;
>*:not(:last-child) {
// flex: 1;
margin-right: 0.5m;
}
>* {
button {
min-width: 160px;
}
}
}
&__filters-applied {
display: flex;
flex-wrap: wrap;
}
&__results {
padding: 10px;
.results {
.result-title {
span {
color: var(--color-primary-element);
font-weight: bolder;
font-size: 16px;
}
}
.result-items {
::v-deep &__item {
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;
}
}
}
}
.result-footer {
justify-content: space-between;
align-items: center;
display: flex;
}
}
}
}
div.v-popper__wrapper {
ul {
li {
::v-deep button.action-button {
align-items: center !important;
img {
width: 24px;
margin: 0 4px;
filter: var(--background-invert-if-bright);
}
}
}
}
}
</style>

View file

@ -68,6 +68,7 @@ p($theme->getTitle());
</div>
<div class="header-right">
<div id="global-search"></div>
<div id="unified-search"></div>
<div id="notifications"></div>
<div id="contactsmenu"></div>

File diff suppressed because one or more lines are too long

1
dist/1436-1436.js.map vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/3240-3240.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/3998-3998.js vendored
View file

@ -1,3 +1,3 @@
/*! For license information please see 3998-3998.js.LICENSE.txt */
"use strict";(self.webpackChunknextcloud=self.webpackChunknextcloud||[]).push([[3998],{83998:(e,n,c)=>{c.d(n,{FilePickerVue:()=>s});const s=(0,c(20144).defineAsyncComponent)((()=>Promise.all([c.e(7874),c.e(3240),c.e(9064)]).then(c.bind(c,39064))))}}]);
//# sourceMappingURL=3998-3998.js.map?v=a49373c9d79e30e60f7b
"use strict";(self.webpackChunknextcloud=self.webpackChunknextcloud||[]).push([[3998],{83998:(e,n,c)=>{c.d(n,{FilePickerVue:()=>s});const s=(0,c(20144).defineAsyncComponent)((()=>Promise.all([c.e(7874),c.e(8928)]).then(c.bind(c,41253))))}}]);
//# sourceMappingURL=3998-3998.js.map?v=308c269b5c7e8357a090

View file

@ -1 +1 @@
{"version":3,"file":"3998-3998.js?v=a49373c9d79e30e60f7b","mappings":";oIAsBA,MAAMA,GAAI,kCAAE,IAAM","sources":["webpack:///nextcloud/node_modules/@nextcloud/dialogs/dist/chunks/index-22ace80c.mjs"],"sourcesContent":["import { defineAsyncComponent as e } from \"vue\";\n/**\n * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>\n *\n * @author Ferdinand Thiessen <opensource@fthiessen.de>\n *\n * @license AGPL-3.0-or-later\n *\n * This program is free software: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see <http://www.gnu.org/licenses/>.\n *\n */\nconst i = e(() => import(\"./FilePicker-5074f4ba.mjs\"));\nexport {\n i as FilePickerVue\n};\n"],"names":["i"],"sourceRoot":""}
{"version":3,"file":"3998-3998.js?v=308c269b5c7e8357a090","mappings":";oIAsBA,MAAMA,GAAI,kCAAE,IAAM","sources":["webpack:///nextcloud/node_modules/@nextcloud/dialogs/dist/chunks/index-22ace80c.mjs"],"sourcesContent":["import { defineAsyncComponent as e } from \"vue\";\n/**\n * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>\n *\n * @author Ferdinand Thiessen <opensource@fthiessen.de>\n *\n * @license AGPL-3.0-or-later\n *\n * This program is free software: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see <http://www.gnu.org/licenses/>.\n *\n */\nconst i = e(() => import(\"./FilePicker-5074f4ba.mjs\"));\nexport {\n i as FilePickerVue\n};\n"],"names":["i"],"sourceRoot":""}

4
dist/6318-6318.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
dist/8928-8928.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -87,28 +87,6 @@
*
*/
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*

1
dist/8928-8928.js.map vendored Normal file

File diff suppressed because one or more lines are too long

3
dist/9064-9064.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-common.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
dist/core-global-search.js vendored Normal file

File diff suppressed because one or more lines are too long

43
dist/core-global-search.js.LICENSE.txt vendored Normal file
View file

@ -0,0 +1,43 @@
/**
* @copyright 2023, Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
/**
* @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/

1
dist/core-global-search.js.map vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/core-login.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-init.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -106,11 +106,18 @@ class TemplateLayout extends \OC_Template {
$this->initialState->provideInitialState('core', 'active-app', $this->navigationManager->getActiveEntry());
$this->initialState->provideInitialState('core', 'apps', $this->navigationManager->getAll());
$this->initialState->provideInitialState('unified-search', 'limit-default', (int)$this->config->getAppValue('core', 'unified-search.limit-default', (string)SearchQuery::LIMIT_DEFAULT));
$this->initialState->provideInitialState('unified-search', 'min-search-length', (int)$this->config->getAppValue('core', 'unified-search.min-search-length', (string)1));
$this->initialState->provideInitialState('unified-search', 'live-search', $this->config->getAppValue('core', 'unified-search.live-search', 'yes') === 'yes');
Util::addScript('core', 'unified-search', 'core');
/*
* NB : Unified search enabled, defaults to true since new advanced search is
* unstable. Once we think otherwise, the default should be false.
*/
if ($this->config->getSystemValueBool('unified_search.enabled', true)) {
$this->initialState->provideInitialState('unified-search', 'limit-default', (int)$this->config->getAppValue('core', 'unified-search.limit-default', (string)SearchQuery::LIMIT_DEFAULT));
$this->initialState->provideInitialState('unified-search', 'min-search-length', (int)$this->config->getAppValue('core', 'unified-search.min-search-length', (string)1));
$this->initialState->provideInitialState('unified-search', 'live-search', $this->config->getAppValue('core', 'unified-search.live-search', 'yes') === 'yes');
Util::addScript('core', 'unified-search', 'core');
} else {
Util::addScript('core', 'global-search', 'core');
}
// Set body data-theme
$this->assign('enabledThemes', []);
if (\OC::$server->getAppManager()->isEnabledForUser('theming') && class_exists('\OCA\Theming\Service\ThemesService')) {

View file

@ -38,6 +38,7 @@ module.exports = {
profile: path.join(__dirname, 'core/src', 'profile.js'),
recommendedapps: path.join(__dirname, 'core/src', 'recommendedapps.js'),
systemtags: path.resolve(__dirname, 'core/src', 'systemtags/merged-systemtags.js'),
'global-search': path.join(__dirname, 'core/src', 'global-search.js'),
'unified-search': path.join(__dirname, 'core/src', 'unified-search.js'),
'unsupported-browser': path.join(__dirname, 'core/src', 'unsupported-browser.js'),
'unsupported-browser-redirect': path.join(__dirname, 'core/src', 'unsupported-browser-redirect.js'),