mirror of https://github.com/nextcloud/server.git
Feat: New UI global search
We are introducing a new search UI that providers a lot more space for users via a large centralized modal and providers various filters which can by applied by adding various chips on the UI. For example, users can now filter their search or scope it by limiting the results to specific apps, time period and people by apply the appropriate filters on the new UI, previously filters where applied using text in the search box by prefixing with `::`. Resolves: #39162 Signed-off-by: fenn-cs <fenn25.fn@gmail.com>pull/40823/head
parent
fa761b51cc
commit
20b3338288
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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>
|
@ -0,0 +1,72 @@
|
||||
<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;
|
||||
filter: grayscale(100%) invert(100%);
|
||||
|
||||
img {
|
||||
width: 20px;
|
||||
padding: 2px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
cursor: pointer;
|
||||
|
||||
:hover {
|
||||
border-radius: 4px;
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -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),
|
||||
})
|
@ -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
|
||||
}
|
@ -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>
|
@ -0,0 +1,651 @@
|
||||
<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>
|
||||
<NcSelect v-bind="peopleSeclectProps"
|
||||
v-model="peopleSeclectProps.value"
|
||||
@search="filterContacts"
|
||||
@option:selected="applyPersonFilter" />
|
||||
</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>
|
||||
<AccountIcon v-if="filter.type === 'person'" />
|
||||
<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 AccountIcon from 'vue-material-design-icons/AccountCircle.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 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 NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
|
||||
import MagnifyIcon from 'vue-material-design-icons/Magnify.vue'
|
||||
|
||||
import debounce from 'debounce'
|
||||
import { getProviders, search as globalSearch, getContacts } from '../services/GlobalSearchService.js'
|
||||
|
||||
export default {
|
||||
name: 'GlobalSearchModal',
|
||||
components: {
|
||||
AccountIcon,
|
||||
ArrowRight,
|
||||
CalendarRangeIcon,
|
||||
CustomDateRangeModal,
|
||||
DotsHorizontalIcon,
|
||||
FilterChip,
|
||||
ListBox,
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
NcButton,
|
||||
NcEmptyContent,
|
||||
NcModal,
|
||||
NcListItem,
|
||||
NcSelect,
|
||||
NcInputField,
|
||||
MagnifyIcon,
|
||||
},
|
||||
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', text: '' },
|
||||
dateFilterIsApplied: false,
|
||||
personFilterIsApplied: false,
|
||||
filteredProviders: [],
|
||||
searchQuery: '',
|
||||
placesFilter: '',
|
||||
dateTimeFilter: null,
|
||||
filters: [],
|
||||
results: [],
|
||||
contacts: [],
|
||||
debouncedFind: debounce(this.find, 300),
|
||||
showDateRangeModal: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
peopleSeclectProps: {
|
||||
get() {
|
||||
return {
|
||||
// inputId: getRandomId(),
|
||||
userSelect: true,
|
||||
label: t('core', 'People filter'),
|
||||
placeholder: t('core', 'Search people'),
|
||||
placement: 'top',
|
||||
options: this.contacts,
|
||||
value: null,
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
},
|
||||
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
|
||||
this.personFilter.id = person.id
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
/* Overwrite NcSelect styles */
|
||||
::v-deep div.v-select {
|
||||
min-width: 0; // reset NcSelect min width
|
||||
|
||||
div.vs__dropdown-toggle {
|
||||
height: 44px; // Overwrite height of NcSelect component to match button
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep>* {
|
||||
min-width: auto;
|
||||
/* Reset hard set min widths */
|
||||
min-height: 0;
|
||||
/* Reset any min heights */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
>* {
|
||||
flex: 1;
|
||||
min-width: auto;
|
||||
/* Reset hard set min widths */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
::v-deep>*:not(:last-child) {
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__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: invert(100%) grayscale(1) contrast(100) brightness(1);
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,3 @@
|
||||
/*! For license information please see 2250-2250.js.LICENSE.txt */
|
||||
"use strict";(self.webpackChunknextcloud=self.webpackChunknextcloud||[]).push([[2250],{82250:(e,n,c)=>{c.d(n,{FilePickerVue:()=>s});const s=(0,c(20144).defineAsyncComponent)((()=>Promise.all([c.e(7874),c.e(3240),c.e(701)]).then(c.bind(c,91845))))}}]);
|
||||
//# sourceMappingURL=2250-2250.js.map?v=62d7d19e457ac44372ef
|
@ -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":"2250-2250.js?v=62d7d19e457ac44372ef","mappings":";oIAsBA,MAAMA,GAAI,kCAAE,IAAM","sources":["webpack:///nextcloud/node_modules/@nextcloud/dialogs/dist/chunks/index-2b379907.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-c55dc760.mjs\"));\nexport {\n i as FilePickerVue\n};\n"],"names":["i"],"sourceRoot":""}
|
@ -1,3 +0,0 @@
|
||||
/*! 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
|
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
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
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
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
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue