feat(files): render filters in top bar

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-01-20 16:41:40 +01:00
parent 871f037dda
commit 985b66c64f
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
17 changed files with 681 additions and 651 deletions

View file

@ -1,55 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcActions
force-menu
:variant="isActive ? 'secondary' : 'tertiary'"
:menu-name="filterName">
<template #icon>
<slot name="icon" />
</template>
<slot />
<template v-if="isActive">
<NcActionSeparator />
<NcActionButton
class="files-list-filter__clear-button"
close-after-click
@click="$emit('reset-filter')">
{{ t('files', 'Clear filter') }}
</NcActionButton>
</template>
</NcActions>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
defineProps<{
isActive: boolean
filterName: string
}>()
defineEmits<{
(event: 'reset-filter'): void
}>()
</script>
<style scoped>
.files-list-filter__clear-button :deep(.action-button__text) {
color: var(--color-text-error, var(--color-error-text));
}
:deep(.button-vue) {
font-weight: normal !important;
* {
font-weight: normal !important;
}
}
</style>

View file

@ -0,0 +1,45 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcChip from '@nextcloud/vue/components/NcChip'
import { useFiltersStore } from '../../store/filters.ts'
const filterStore = useFiltersStore()
const activeChips = computed(() => filterStore.activeChips)
</script>
<template>
<ul
v-if="activeChips.length > 0"
:class="$style.fileListFilterChips"
:aria-label="t('files', 'Active filters')">
<li v-for="(chip, index) of activeChips" :key="index">
<NcChip
:aria-label-close="t('files', 'Remove filter')"
:icon-svg="chip.icon"
:text="chip.text"
@close="chip.onclick">
<template v-if="chip.user" #icon>
<NcAvatar
disable-menu
hide-status
:size="24"
:user="chip.user" />
</template>
</NcChip>
</li>
</ul>
</template>
<style module>
.fileListFilterChips {
display: flex;
gap: var(--default-grid-baseline);
}
</style>

View file

@ -3,100 +3,122 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<FileListFilter
:is-active="isActive"
:filter-name="t('files', 'Modified')"
@reset-filter="resetFilter">
<template #icon>
<NcIconSvgWrapper :path="mdiCalendarRangeOutline" />
</template>
<NcActionButton
<div>
<NcButton
v-for="preset of timePresets"
:key="preset.id"
type="radio"
close-after-click
:model-value.sync="selectedOption"
:value="preset.id">
alignment="start"
:pressed="preset === selectedOption"
variant="tertiary"
wide
@update:pressed="$event ? (selectedOption = preset) : onReset()">
{{ preset.label }}
</NcActionButton>
<!-- TODO: Custom time range -->
</FileListFilter>
</NcButton>
<NcDateTimePicker
v-if="selectedOption?.id === 'custom'"
v-model="timeRange"
append-to-body
:aria-label="t('files', 'Custom date range')"
type="date-range" />
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { ITimePreset } from '../../filters/ModifiedFilter.ts'
<script setup lang="ts">
import type { ITimePreset, ModifiedFilter } from '../../filters/ModifiedFilter.ts'
import { mdiCalendarRangeOutline } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import FileListFilter from './FileListFilter.vue'
import { t } from '@nextcloud/l10n'
import { NcDateTimePicker } from '@nextcloud/vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
export default defineComponent({
components: {
FileListFilter,
NcActionButton,
NcIconSvgWrapper,
},
const props = defineProps<{
filter: ModifiedFilter
}>()
props: {
timePresets: {
type: Array as PropType<ITimePreset[]>,
required: true,
},
},
setup() {
return {
// icons used in template
mdiCalendarRangeOutline,
const selectedOption = ref<typeof timePresets[number]>()
watch(selectedOption, (preset) => {
if (selectedOption.value) {
if (selectedOption.value.id === 'custom' && !timeRange.value) {
timeRange.value = [new Date(startOfLastWeek()), new Date(startOfToday())]
selectedOption.value.timeRange = [...timeRange.value]
}
},
data() {
return {
selectedOption: null as string | null,
timeRangeEnd: null as number | null,
timeRangeStart: null as number | null,
}
},
computed: {
/**
* Is the filter currently active
*/
isActive() {
return this.selectedOption !== null
},
currentPreset() {
return this.timePresets.find(({ id }) => id === this.selectedOption) ?? null
},
},
watch: {
selectedOption() {
if (this.selectedOption === null) {
this.$emit('update:preset')
} else {
const preset = this.currentPreset
this.$emit('update:preset', preset)
}
},
},
methods: {
t,
resetFilter() {
this.selectedOption = null
this.timeRangeEnd = null
this.timeRangeStart = null
},
},
props.filter.setPreset(selectedOption.value)
} else {
props.filter.setPreset()
}
})
const timeRange = ref<[Date, Date]>()
watch(timeRange, () => {
if (timeRange.value) {
selectedOption.value!.timeRange = [...timeRange.value]
props.filter.setPreset(selectedOption.value)
}
})
onMounted(() => {
selectedOption.value = props.filter.preset && timePresets.find((f) => f.id === props.filter.preset!.id)
props.filter.addEventListener('reset', onReset)
})
onUnmounted(() => {
props.filter.removeEventListener('reset', onReset)
})
/**
* Handler for resetting the filter
*/
function onReset() {
selectedOption.value = undefined
timeRange.value = undefined
}
</script>
<script lang="ts">
const startOfToday = () => (new Date()).setHours(0, 0, 0, 0)
const startOfLastWeek = () => startOfToday() - (7 * 24 * 60 * 60 * 1000)
/**
* Available presets
*/
const timePresets = [
{
id: 'today',
label: t('files', 'Today'),
filter: (time: number) => time > startOfToday(),
} satisfies ITimePreset,
{
id: 'last-7',
label: t('files', 'Last 7 days'),
filter: (time: number) => time > startOfLastWeek(),
} satisfies ITimePreset,
{
id: 'last-30',
label: t('files', 'Last 30 days'),
filter: (time: number) => time > (startOfToday() - (30 * 24 * 60 * 60 * 1000)),
} satisfies ITimePreset,
{
id: 'this-year',
label: t('files', 'This year ({year})', { year: (new Date()).getFullYear() }),
filter: (time: number) => time > (new Date(startOfToday())).setMonth(0, 1),
} satisfies ITimePreset,
{
id: 'last-year',
label: t('files', 'Last year ({year})', { year: (new Date()).getFullYear() - 1 }),
filter: (time: number) => (time > (new Date(startOfToday())).setFullYear((new Date()).getFullYear() - 1, 0, 1)) && (time < (new Date(startOfToday())).setMonth(0, 1)),
} satisfies ITimePreset,
{
id: 'custom',
label: t('files', 'Custom range'),
timeRange: [new Date(startOfLastWeek()), new Date(startOfToday())],
filter(time: number) {
if (!this.timeRange) {
return true
}
const timeValue = new Date(time).getTime()
return timeValue >= this.timeRange[0].getTime() && timeValue <= this.timeRange[1].getTime()
},
} satisfies ITimePreset & Record<string, unknown>,
]
</script>
<style scoped lang="scss">

View file

@ -4,44 +4,26 @@
-->
<template>
<NcButton v-show="isVisible" @click="onClick">
<NcButton v-if="isVisible" size="small" @click="onClick">
{{ t('files', 'Search everywhere') }}
</NcButton>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { ref } from 'vue'
import { computed } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import { getPinia } from '../../store/index.ts'
import { useSearchStore } from '../../store/search.ts'
const isVisible = ref(false)
const searchStore = useSearchStore(getPinia())
defineExpose({
hideButton,
showButton,
})
/**
* Hide the button - called by the filter class
*/
function hideButton() {
isVisible.value = false
}
/**
* Show the button - called by the filter class
*/
function showButton() {
isVisible.value = true
}
const isVisible = computed(() => searchStore.query.length >= 3 && searchStore.scope === 'filter')
/**
* Button click handler to make the filtering a global search.
*/
function onClick() {
const searchStore = useSearchStore(getPinia())
searchStore.scope = 'globally'
}
</script>

View file

@ -3,124 +3,164 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<FileListFilter
class="file-list-filter-type"
:is-active="isActive"
:filter-name="t('files', 'Type')"
@reset-filter="resetFilter">
<template #icon>
<NcIconSvgWrapper :path="mdiFileOutline" />
</template>
<NcActionButton
<div :class="$style.fileListFilterType">
<NcButton
v-for="fileType of typePresets"
:key="fileType.id"
type="checkbox"
:model-value="selectedOptions.includes(fileType)"
@click="toggleOption(fileType)">
:pressed="selectedOptions.includes(fileType)"
variant="tertiary"
alignment="start"
wide
@update:pressed="toggleOption(fileType, $event)">
<template #icon>
<NcIconSvgWrapper :svg="fileType.icon" />
</template>
{{ fileType.label }}
</NcActionButton>
</FileListFilter>
</NcButton>
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { ITypePreset } from '../../filters/TypeFilter.ts'
<script setup lang="ts">
import type { ITypePreset, TypeFilter } from '../../filters/TypeFilter.ts'
import { mdiFileOutline } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import svgDocument from '@mdi/svg/svg/file-document.svg?raw'
import svgPDF from '@mdi/svg/svg/file-pdf-box.svg?raw'
import svgPresentation from '@mdi/svg/svg/file-presentation-box.svg?raw'
import svgSpreadsheet from '@mdi/svg/svg/file-table-box.svg?raw'
import svgFolder from '@mdi/svg/svg/folder.svg?raw'
import svgImage from '@mdi/svg/svg/image.svg?raw'
import svgMovie from '@mdi/svg/svg/movie.svg?raw'
import svgAudio from '@mdi/svg/svg/music.svg?raw'
import { t } from '@nextcloud/l10n'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import FileListFilter from './FileListFilter.vue'
export default defineComponent({
name: 'FileListFilterType',
const props = defineProps<{
filter: TypeFilter
}>()
components: {
FileListFilter,
NcActionButton,
NcIconSvgWrapper,
},
props: {
presets: {
type: Array as PropType<ITypePreset[]>,
default: () => [],
},
typePresets: {
type: Array as PropType<ITypePreset[]>,
required: true,
},
},
setup() {
return {
mdiFileOutline,
t,
}
},
data() {
return {
selectedOptions: [] as ITypePreset[],
}
},
computed: {
isActive() {
return this.selectedOptions.length > 0
},
},
watch: {
/** Reset selected options if property is changed */
presets() {
this.selectedOptions = this.presets ?? []
},
selectedOptions(newValue, oldValue) {
if (this.selectedOptions.length === 0) {
if (oldValue.length !== 0) {
this.$emit('update:presets')
}
} else {
this.$emit('update:presets', this.selectedOptions)
}
},
},
mounted() {
this.selectedOptions = this.presets ?? []
},
methods: {
resetFilter() {
this.selectedOptions = []
},
/**
* Toggle option from selected option
*
* @param option The option to toggle
*/
toggleOption(option: ITypePreset) {
const idx = this.selectedOptions.indexOf(option)
if (idx !== -1) {
this.selectedOptions.splice(idx, 1)
} else {
this.selectedOptions.push(option)
}
},
},
const selectedOptions = ref<ITypePreset[]>([])
watch(selectedOptions, () => {
props.filter.setPresets([...selectedOptions.value])
})
onMounted(() => {
props.filter.addEventListener('reset', resetFilter)
props.filter.addEventListener('deselect', onDeselect)
selectedOptions.value = typePresets.filter(({ id }) => props.filter.presets.some((preset) => preset.id === id))
})
onUnmounted(() => {
props.filter.removeEventListener('reset', resetFilter)
props.filter.removeEventListener('deselect', onDeselect)
})
/**
* Handler for reset event from filter
*/
function resetFilter() {
selectedOptions.value = []
}
/**
* Handle deselect event from filter
*
* @param event - The custom event
*/
function onDeselect(event: CustomEvent<string>) {
const option = typePresets.find((preset) => preset.id === event.detail)
if (option) {
toggleOption(option, false)
}
}
/**
* Toggle option from selected option
*
* @param option The option to toggle
* @param selected Whether the option is selected or not
*/
function toggleOption(option: ITypePreset, selected: boolean) {
selectedOptions.value = selectedOptions.value.filter((o) => o.id !== option.id)
if (selected) {
selectedOptions.value.push(option)
}
}
</script>
<style>
.file-list-filter-type {
max-width: 220px;
<script lang="ts">
/**
* Available presets
*/
const typePresets = [
{
id: 'document',
label: t('files', 'Documents'),
icon: colorize(svgDocument, '#49abea'),
mime: ['x-office/document'],
},
{
id: 'spreadsheet',
label: t('files', 'Spreadsheets'),
icon: colorize(svgSpreadsheet, '#9abd4e'),
mime: ['x-office/spreadsheet'],
},
{
id: 'presentation',
label: t('files', 'Presentations'),
icon: colorize(svgPresentation, '#f0965f'),
mime: ['x-office/presentation'],
},
{
id: 'pdf',
label: t('files', 'PDFs'),
icon: colorize(svgPDF, '#dc5047'),
mime: ['application/pdf'],
},
{
id: 'folder',
label: t('files', 'Folders'),
icon: colorize(svgFolder, window.getComputedStyle(document.body).getPropertyValue('--color-primary-element')),
mime: ['httpd/unix-directory'],
},
{
id: 'audio',
label: t('files', 'Audio'),
icon: svgAudio,
mime: ['audio'],
},
{
id: 'image',
// TRANSLATORS: This is for filtering files, e.g. PNG or JPEG, so photos, drawings, or images in general
label: t('files', 'Images'),
icon: svgImage,
mime: ['image'],
},
{
id: 'video',
label: t('files', 'Videos'),
icon: svgMovie,
mime: ['video'],
},
] as ITypePreset[]
/**
* Helper to colorize an svg icon
*
* @param svg - the svg content
* @param color - the color to apply
*/
function colorize(svg: string, color: string) {
return svg.replace('<path ', `<path fill="${color}" `)
}
</script>
<style module>
.fileListFilterType {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
width: 100%;
}
</style>

View file

@ -0,0 +1,139 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IFileListFilterWithUi } from '@nextcloud/files'
import { mdiArrowLeft, mdiFilterVariant } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { computed, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
import { useFiltersStore } from '../../store/filters.ts'
const filterStore = useFiltersStore()
const visualFilters = computed(() => filterStore.filtersWithUI)
const hasActiveFilters = computed(() => filterStore.activeChips.length > 0)
const selectedFilter = ref<IFileListFilterWithUi>()
const { isWide } = useFileListWidth()
const menuTriggerId = 'file-list-filters-menu-trigger'
const boundary = document.getElementById('app-content-vue')!
</script>
<template>
<div :class="$style.fileListFilters" data-test-id="files-list-filters">
<template v-if="isWide">
<NcPopover v-for="filter of visualFilters" :key="filter.id" :boundary="boundary">
<template #trigger>
<NcButton variant="tertiary">
<template #icon>
<NcIconSvgWrapper :svg="filter.iconSvgInline" />
</template>
{{ filter.displayName }}
</NcButton>
</template>
<template #default>
<div :class="$style.fileListFilters__popoverContainer">
<component :is="filter.tagName" :filter.prop="filter" />
</div>
</template>
</NcPopover>
</template>
<NcPopover
v-else
:boundary="boundary"
:popup-role="selectedFilter ? 'dialog' : 'menu'"
@update:shown="selectedFilter = undefined">
<template #trigger>
<NcButton
:id="menuTriggerId"
:aria-label="t('files', 'Filters')"
:pressed="hasActiveFilters"
variant="tertiary">
<template #icon>
<NcIconSvgWrapper :path="mdiFilterVariant" />
</template>
</NcButton>
</template>
<template #default>
<div v-if="selectedFilter" :class="$style.fileListFilters__popoverFilterView">
<NcButton wide variant="tertiary" @click="selectedFilter = undefined">
<template #icon>
<NcIconSvgWrapper directional :path="mdiArrowLeft" />
</template>
{{ t('files', 'Back to filters') }}
</NcButton>
<component :is="selectedFilter.tagName" :filter.prop="selectedFilter" />
</div>
<template v-else>
<ul :class="$style.fileListFilters__popoverContainer" :aria-labelledby="menuTriggerId" role="menu">
<li v-for="filter of visualFilters" :key="filter.id" role="presentation">
<NcButton
role="menuitem"
alignment="start"
variant="tertiary"
wide
@click="selectedFilter = filter">
<template #icon>
<NcIconSvgWrapper :svg="filter.iconSvgInline" />
</template>
{{ filter.displayName }}
</NcButton>
</li>
</ul>
</template>
</template>
</NcPopover>
</div>
</template>
<style module>
.fileListFilters {
display: flex;
flex-direction: row;
gap: var(--default-grid-baseline);
margin-inline-end: var(--default-grid-baseline);
height: 100%;
width: 100%;
}
.fileListFilters__popoverFilterView {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: calc(2 * var(--default-grid-baseline));
padding: calc(var(--default-grid-baseline) / 2);
min-width: calc(7 * var(--default-clickable-area));
}
.fileListFilters__popoverContainer {
box-sizing: border-box;
padding: calc(var(--default-grid-baseline) / 2);
min-width: calc(7 * var(--default-clickable-area));
}
.fileListFilters__filter {
display: flex;
align-items: start;
justify-content: start;
gap: calc(var(--default-grid-baseline, 4px) * 2);
> * {
flex: 0 1 fit-content;
}
}
.fileListFilters__active {
display: flex;
flex-direction: row;
gap: calc(var(--default-grid-baseline, 4px) * 2);
}
</style>

View file

@ -1,76 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="file-list-filters">
<div class="file-list-filters__filter" data-cy-files-filters>
<span
v-for="filter of visualFilters"
:key="filter.id"
ref="filterElements" />
</div>
<ul v-if="activeChips.length > 0" class="file-list-filters__active" :aria-label="t('files', 'Active filters')">
<li v-for="(chip, index) of activeChips" :key="index">
<NcChip
:aria-label-close="t('files', 'Remove filter')"
:icon-svg="chip.icon"
:text="chip.text"
@close="chip.onclick">
<template v-if="chip.user" #icon>
<NcAvatar
disable-menu
hide-status
:size="24"
:user="chip.user" />
</template>
</NcChip>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed, ref, watchEffect } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcChip from '@nextcloud/vue/components/NcChip'
import { useFiltersStore } from '../store/filters.ts'
const filterStore = useFiltersStore()
const visualFilters = computed(() => filterStore.filtersWithUI)
const activeChips = computed(() => filterStore.activeChips)
const filterElements = ref<HTMLElement[]>([])
watchEffect(() => {
filterElements.value
.forEach((el, index) => visualFilters.value[index].mount(el))
})
</script>
<style scoped lang="scss">
.file-list-filters {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
height: 100%;
width: 100%;
&__filter {
display: flex;
align-items: start;
justify-content: start;
gap: calc(var(--default-grid-baseline, 4px) * 2);
> * {
flex: 0 1 fit-content;
}
}
&__active {
display: flex;
flex-direction: row;
gap: calc(var(--default-grid-baseline, 4px) * 2);
}
}
</style>

View file

@ -17,9 +17,8 @@
}"
:scroll-to-index="scrollToIndex"
:caption="caption">
<template #filters>
<FileListFilters />
</template>
<!-- eslint-disable-next-line vue/singleline-html-element-content-newline -- no space allowed as otherwise `:empty` css selector does not trigger! -->
<template #filters><FileListFilterToSearch /><FileListFilterChips /></template>
<template v-if="!isNoneSelected" #header-overlay>
<span class="files-list__selected">
@ -81,7 +80,8 @@ import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { computed, defineComponent } from 'vue'
import FileEntry from './FileEntry.vue'
import FileEntryGrid from './FileEntryGrid.vue'
import FileListFilters from './FileListFilters.vue'
import FileListFilterChips from './FileListFilter/FileListFilterChips.vue'
import FileListFilterToSearch from './FileListFilter/FileListFilterToSearch.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
@ -99,7 +99,8 @@ export default defineComponent({
name: 'FilesListVirtual',
components: {
FileListFilters,
FileListFilterChips,
FileListFilterToSearch,
FilesListHeader,
FilesListTableFooter,
FilesListTableHeader,
@ -511,15 +512,15 @@ export default defineComponent({
--clickable-area: var(--default-clickable-area);
--icon-preview-size: 24px;
--fixed-block-start-position: var(--default-clickable-area);
--fixed-block-start-position: calc(var(--clickable-area-small) + var(--default-grid-baseline, 4px));
display: flex;
flex-direction: column;
overflow: auto;
height: 100%;
will-change: scroll-position;
&:has(.file-list-filters__active) {
--fixed-block-start-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small));
&:has(&__filters:empty) {
--fixed-block-start-position: 0px;
}
& :deep() {
@ -572,6 +573,10 @@ export default defineComponent({
}
.files-list__filters {
display: flex;
gap: var(--default-grid-baseline);
box-sizing: border-box;
// Pinned on top when scrolling above table header
position: sticky;
top: 0;
@ -582,6 +587,10 @@ export default defineComponent({
padding-inline: var(--row-height) var(--default-grid-baseline, 4px);
height: var(--fixed-block-start-position);
width: 100%;
&:not(:empty) {
padding-block: calc(var(--default-grid-baseline, 4px) / 2);
}
}
.files-list__thead-overlay {

View file

@ -2,11 +2,13 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFileListFilterChip, INode } from '@nextcloud/files'
import calendarSvg from '@mdi/svg/svg/calendar.svg?raw'
import type { IFileListFilterChip, IFileListFilterWithUi, INode } from '@nextcloud/files'
import svgCalendarRangeOutline from '@mdi/svg/svg/calendar-range-outline.svg?raw'
import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import wrap from '@vue/web-component-wrapper'
import Vue from 'vue'
import FileListFilterModified from '../components/FileListFilter/FileListFilterModified.vue'
@ -16,63 +18,20 @@ export interface ITimePreset {
filter: (time: number) => boolean
}
const startOfToday = () => (new Date()).setHours(0, 0, 0, 0)
const tagName = 'files-file-list-filter-modified'
/**
* Available presets
*/
const timePresets: ITimePreset[] = [
{
id: 'today',
label: t('files', 'Today'),
filter: (time: number) => time > startOfToday(),
},
{
id: 'last-7',
label: t('files', 'Last 7 days'),
filter: (time: number) => time > (startOfToday() - (7 * 24 * 60 * 60 * 1000)),
},
{
id: 'last-30',
label: t('files', 'Last 30 days'),
filter: (time: number) => time > (startOfToday() - (30 * 24 * 60 * 60 * 1000)),
},
{
id: 'this-year',
label: t('files', 'This year ({year})', { year: (new Date()).getFullYear() }),
filter: (time: number) => time > (new Date(startOfToday())).setMonth(0, 1),
},
{
id: 'last-year',
label: t('files', 'Last year ({year})', { year: (new Date()).getFullYear() - 1 }),
filter: (time: number) => (time > (new Date(startOfToday())).setFullYear((new Date()).getFullYear() - 1, 0, 1)) && (time < (new Date(startOfToday())).setMonth(0, 1)),
},
] as const
class ModifiedFilter extends FileListFilter {
class ModifiedFilter extends FileListFilter implements IFileListFilterWithUi {
private currentInstance?: Vue
private currentPreset?: ITimePreset
public readonly displayName = t('files', 'Modified')
public readonly iconSvgInline = svgCalendarRangeOutline
public readonly tagName = tagName
constructor() {
super('files:modified', 50)
}
public mount(el: HTMLElement) {
if (this.currentInstance) {
this.currentInstance.$destroy()
}
const View = Vue.extend(FileListFilterModified as never)
this.currentInstance = new View({
propsData: {
timePresets,
},
el,
})
.$on('update:preset', this.setPreset.bind(this))
.$mount()
}
public filter(nodes: INode[]): INode[] {
if (!this.currentPreset) {
return nodes
@ -82,7 +41,11 @@ class ModifiedFilter extends FileListFilter {
}
public reset(): void {
this.setPreset()
this.dispatchEvent(new CustomEvent('reset'))
}
public get preset() {
return this.currentPreset
}
public setPreset(preset?: ITimePreset) {
@ -92,9 +55,9 @@ class ModifiedFilter extends FileListFilter {
const chips: IFileListFilterChip[] = []
if (preset) {
chips.push({
icon: calendarSvg,
icon: svgCalendarRangeOutline,
text: preset.label,
onclick: () => this.setPreset(),
onclick: () => this.reset(),
})
} else {
(this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter()
@ -103,9 +66,26 @@ class ModifiedFilter extends FileListFilter {
}
}
export type { ModifiedFilter }
/**
* Register the file list filter by modification date
*/
export function registerModifiedFilter() {
const WrappedComponent = wrap(Vue, FileListFilterModified)
// In Vue 2, wrap doesn't support disabling shadow :(
// Disable with a hack
Object.defineProperty(WrappedComponent.prototype, 'attachShadow', {
value() {
return this
},
})
Object.defineProperty(WrappedComponent.prototype, 'shadowRoot', {
get() {
return this
},
})
customElements.define(tagName, WrappedComponent)
registerFileListFilter(new ModifiedFilter())
}

View file

@ -1,47 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
import type { ComponentPublicInstance } from 'vue'
import { subscribe } from '@nextcloud/event-bus'
import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
import Vue from 'vue'
import FileListFilterToSearch from '../components/FileListFilter/FileListFilterToSearch.vue'
class SearchFilter extends FileListFilter {
private currentInstance?: ComponentPublicInstance<typeof FileListFilterToSearch>
constructor() {
super('files:filter-to-search', 999)
subscribe('files:search:updated', ({ query, scope }) => {
if (query && scope === 'filter') {
this.currentInstance?.showButton()
} else {
this.currentInstance?.hideButton()
}
})
}
public mount(el: HTMLElement) {
if (this.currentInstance) {
this.currentInstance.$destroy()
}
const View = Vue.extend(FileListFilterToSearch)
this.currentInstance = new View().$mount(el) as unknown as ComponentPublicInstance<typeof FileListFilterToSearch>
}
public filter(nodes: INode[]): INode[] {
return nodes
}
}
/**
* Register a file list filter to only show hidden files if enabled by user config
*/
export function registerFilterToSearchToggle() {
registerFileListFilter(new SearchFilter())
}

View file

@ -2,21 +2,16 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFileListFilterChip, INode } from '@nextcloud/files'
// TODO: Create a modern replacement for OC.MimeType...
import svgDocument from '@mdi/svg/svg/file-document.svg?raw'
import svgPDF from '@mdi/svg/svg/file-pdf-box.svg?raw'
import svgPresentation from '@mdi/svg/svg/file-presentation-box.svg?raw'
import svgSpreadsheet from '@mdi/svg/svg/file-table-box.svg?raw'
import svgFolder from '@mdi/svg/svg/folder.svg?raw'
import svgImage from '@mdi/svg/svg/image.svg?raw'
import svgMovie from '@mdi/svg/svg/movie.svg?raw'
import svgAudio from '@mdi/svg/svg/music.svg?raw'
import type { IFileListFilterChip, IFileListFilterWithUi, INode } from '@nextcloud/files'
import svgFileOutline from '@mdi/svg/svg/file-outline.svg?raw'
import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import wrap from '@vue/web-component-wrapper'
import Vue from 'vue'
import FileListFilterType from '../components/FileListFilter/FileListFilterType.vue'
import logger from '../logger.ts'
export interface ITypePreset {
id: string
@ -25,106 +20,21 @@ export interface ITypePreset {
mime: string[]
}
/**
*
* @param svg
* @param color
*/
function colorize(svg: string, color: string) {
return svg.replace('<path ', `<path fill="${color}" `)
}
const tagName = 'files-file-list-filter-type'
/**
* Available presets
*/
async function getTypePresets() {
return [
{
id: 'document',
label: t('files', 'Documents'),
icon: colorize(svgDocument, '#49abea'),
mime: ['x-office/document'],
},
{
id: 'spreadsheet',
label: t('files', 'Spreadsheets'),
icon: colorize(svgSpreadsheet, '#9abd4e'),
mime: ['x-office/spreadsheet'],
},
{
id: 'presentation',
label: t('files', 'Presentations'),
icon: colorize(svgPresentation, '#f0965f'),
mime: ['x-office/presentation'],
},
{
id: 'pdf',
label: t('files', 'PDFs'),
icon: colorize(svgPDF, '#dc5047'),
mime: ['application/pdf'],
},
{
id: 'folder',
label: t('files', 'Folders'),
icon: colorize(svgFolder, window.getComputedStyle(document.body).getPropertyValue('--color-primary-element')),
mime: ['httpd/unix-directory'],
},
{
id: 'audio',
label: t('files', 'Audio'),
icon: svgAudio,
mime: ['audio'],
},
{
id: 'image',
// TRANSLATORS: This is for filtering files, e.g. PNG or JPEG, so photos, drawings, or images in general
label: t('files', 'Images'),
icon: svgImage,
mime: ['image'],
},
{
id: 'video',
label: t('files', 'Videos'),
icon: svgMovie,
mime: ['video'],
},
] as ITypePreset[]
}
class TypeFilter extends FileListFilter {
class TypeFilter extends FileListFilter implements IFileListFilterWithUi {
private currentInstance?: Vue
private currentPresets: ITypePreset[]
private allPresets?: ITypePreset[]
public readonly displayName = t('files', 'Type')
public readonly iconSvgInline = svgFileOutline
public readonly tagName = tagName
constructor() {
super('files:type', 10)
this.currentPresets = []
}
public async mount(el: HTMLElement) {
// We need to defer this as on init script this is not available:
if (this.allPresets === undefined) {
this.allPresets = await getTypePresets()
}
// Already mounted
if (this.currentInstance) {
this.currentInstance.$destroy()
delete this.currentInstance
}
const View = Vue.extend(FileListFilterType as never)
this.currentInstance = new View({
propsData: {
presets: this.currentPresets,
typePresets: this.allPresets!,
},
el,
})
.$on('update:presets', this.setPresets.bind(this))
.$mount()
}
public filter(nodes: INode[]): INode[] {
if (!this.currentPresets || this.currentPresets.length === 0) {
return nodes
@ -149,10 +59,17 @@ class TypeFilter extends FileListFilter {
}
public reset(): void {
this.setPresets()
// to be listener by the component
this.dispatchEvent(new CustomEvent('reset'))
}
public get presets(): ITypePreset[] {
return this.currentPresets
}
public setPresets(presets?: ITypePreset[]) {
logger.debug('TypeFilter: setting presets', { presets })
this.currentPresets = presets ?? []
if (this.currentInstance !== undefined) {
// could be called before the instance was created
@ -185,13 +102,31 @@ class TypeFilter extends FileListFilter {
*/
private removeFilterPreset(presetId: string) {
const filtered = this.currentPresets.filter(({ id }) => id !== presetId)
this.dispatchEvent(new CustomEvent('deselect', { detail: presetId }))
this.setPresets(filtered)
}
}
export type { TypeFilter }
/**
* Register the file list filter by file type
*/
export function registerTypeFilter() {
const WrappedComponent = wrap(Vue, FileListFilterType)
// In Vue 2, wrap doesn't support disabling shadow :(
// Disable with a hack
Object.defineProperty(WrappedComponent.prototype, 'attachShadow', {
value() {
return this
},
})
Object.defineProperty(WrappedComponent.prototype, 'shadowRoot', {
get() {
return this
},
})
window.customElements.define(tagName, WrappedComponent)
registerFileListFilter(new TypeFilter())
}

View file

@ -21,7 +21,6 @@ import { action as viewInFolderAction } from './actions/viewInFolderAction.ts'
import { registerFilenameFilter } from './filters/FilenameFilter.ts'
import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts'
import { registerModifiedFilter } from './filters/ModifiedFilter.ts'
import { registerFilterToSearchToggle } from './filters/SearchFilter.ts'
import { registerTypeFilter } from './filters/TypeFilter.ts'
import { entry as newFolderEntry } from './newMenu/newFolder.ts'
import { registerTemplateEntries } from './newMenu/newFromTemplate.ts'
@ -68,7 +67,6 @@ registerHiddenFilesFilter()
registerTypeFilter()
registerModifiedFilter()
registerFilenameFilter()
registerFilterToSearchToggle()
// Register sidebar action
registerSidebarFavoriteAction()

9
apps/files/src/shims.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare module '*.svg?raw' {
const content: string
export default content
}

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip } from '@nextcloud/files'
import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip, IFileListFilterWithUi } from '@nextcloud/files'
import { emit, subscribe } from '@nextcloud/event-bus'
import { getFileListFilters } from '@nextcloud/files'
@ -16,8 +16,8 @@ import logger from '../logger.ts'
*
* @param value The filter to check
*/
function isFileListFilterWithUi(value: IFileListFilter): value is Required<IFileListFilter> {
return 'mount' in value
function isFileListFilterWithUi(value: IFileListFilter): value is IFileListFilterWithUi {
return 'tagName' in value
}
export const useFiltersStore = defineStore('filters', () => {
@ -37,7 +37,7 @@ export const useFiltersStore = defineStore('filters', () => {
/**
* All filters that provide a UI for visual controlling the filter state
*/
const filtersWithUI = computed<Required<IFileListFilter>[]>(() => sortedFilters.value.filter(isFileListFilterWithUi))
const filtersWithUI = computed<IFileListFilterWithUi[]>(() => sortedFilters.value.filter(isFileListFilterWithUi))
/**
* Register a new filter on the store.

View file

@ -63,6 +63,8 @@
</NcActionButton>
</NcActions>
<FileListFilters />
<NcButton
v-if="enableGridView"
:aria-label="gridViewButtonLabel"
@ -194,6 +196,7 @@ import IconReload from 'vue-material-design-icons/Reload.vue'
import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue'
import BreadCrumbs from '../components/BreadCrumbs.vue'
import DragAndDropNotice from '../components/DragAndDropNotice.vue'
import FileListFilters from '../components/FileListFilter/FileListFilters.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
@ -220,6 +223,7 @@ export default defineComponent({
components: {
BreadCrumbs,
DragAndDropNotice,
FileListFilters,
FilesListVirtual,
LinkIcon,
ListViewIcon,

View file

@ -3,53 +3,45 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<FileListFilter
class="file-list-filter-accounts"
:is-active="selectedAccounts.length > 0"
:filter-name="t('files_sharing', 'People')"
@reset-filter="resetFilter">
<template #icon>
<NcIconSvgWrapper :path="mdiAccountMultipleOutline" />
</template>
<NcActionInput
<div :class="$style.fileListFilterAccount">
<NcTextField
v-if="availableAccounts.length > 1"
v-model="accountFilter"
:label="t('files_sharing', 'Filter accounts')"
:label-outside="false"
:show-trailing-button="false"
type="search" />
<NcActionButton
type="search"
:label="t('files_sharing', 'Filter accounts')" />
<NcButton
v-for="account of shownAccounts"
:key="account.id"
class="file-list-filter-accounts__item"
type="radio"
:model-value="selectedAccounts.includes(account)"
:value="account.id"
@click="toggleAccount(account.id)">
alignment="start"
:pressed="selectedAccounts.includes(account)"
variant="tertiary"
wide
@update:pressed="toggleAccount(account.id, $event)">
<template #icon>
<NcAvatar
class="file-list-filter-accounts__avatar"
:class="$style.fileListFilterAccount__avatar"
v-bind="account"
:size="24"
disable-menu
hide-status />
</template>
{{ account.displayName }}
</NcActionButton>
</FileListFilter>
<span v-if="account.id === currentUserId" :class="$style.fileListFilterAccount__currentUser">
({{ t('files', 'you') }})
</span>
</NcButton>
</div>
</template>
<script setup lang="ts">
import type { IAccountData } from '../files_filters/AccountFilter.ts'
import type { AccountFilter, IAccountData } from '../files_filters/AccountFilter.ts'
import { mdiAccountMultipleOutline } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { computed, ref, watch } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
import { t } from '@nextcloud/l10n'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import FileListFilter from '../../../files/src/components/FileListFilter/FileListFilter.vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { getCurrentUser } from '../../../../core/src/OC/currentuser.js'
interface IUserSelectData {
id: string
@ -57,48 +49,88 @@ interface IUserSelectData {
displayName: string
}
const emit = defineEmits<{
(event: 'update:accounts', value: IAccountData[]): void
const props = defineProps<{
filter: AccountFilter
}>()
const currentUserId = getCurrentUser()!.uid
const accountFilter = ref('')
const availableAccounts = ref<IUserSelectData[]>([])
const selectedAccounts = ref<IUserSelectData[]>([])
watch(selectedAccounts, () => {
const accounts = selectedAccounts.value.map(({ id: uid, displayName }) => ({ uid, displayName }))
props.filter.setAccounts(accounts.length > 0 ? accounts : undefined)
})
onMounted(() => {
setAvailableAccounts(props.filter.availableAccounts)
selectedAccounts.value = availableAccounts.value.filter(({ id }) => props.filter.filterAccounts?.some(({ uid }) => uid === id)) ?? []
props.filter.addEventListener('accounts-updated', setAvailableAccounts)
props.filter.addEventListener('reset', resetFilter)
props.filter.addEventListener('deselect', deselect)
})
onUnmounted(() => {
props.filter.removeEventListener('accounts-updated', setAvailableAccounts)
props.filter.removeEventListener('reset', resetFilter)
props.filter.removeEventListener('deselect', deselect)
})
/**
* Currently shown accounts (filtered)
*/
const shownAccounts = computed(() => {
if (!accountFilter.value) {
return availableAccounts.value
return [...availableAccounts.value].sort(sortAccounts)
}
const queryParts = accountFilter.value.toLocaleLowerCase().trim().split(' ')
return availableAccounts.value.filter((account) => queryParts.every((part) => account.user.toLocaleLowerCase().includes(part)
const accounts = availableAccounts.value.filter((account) => queryParts.every((part) => account.user.toLocaleLowerCase().includes(part)
|| account.displayName.toLocaleLowerCase().includes(part)))
return accounts.sort(sortAccounts)
})
/**
* Sort accounts, putting the current user at the begin
*
* @param a - First account
* @param b - Second account
*/
function sortAccounts(a: IUserSelectData, b: IUserSelectData) {
if (a.id === currentUserId) {
return -1
}
if (b.id === currentUserId) {
return 1
}
return a.displayName.localeCompare(b.displayName)
}
/**
* Toggle an account as selected
*
* @param accountId The account to toggle
* @param selected Whether to select or deselect the account
*/
function toggleAccount(accountId: string) {
const account = availableAccounts.value.find(({ id }) => id === accountId)
if (account && selectedAccounts.value.includes(account)) {
selectedAccounts.value = selectedAccounts.value.filter(({ id }) => id !== accountId)
} else {
function toggleAccount(accountId: string, selected: boolean) {
selectedAccounts.value = selectedAccounts.value.filter(({ id }) => id !== accountId)
if (selected) {
const account = availableAccounts.value.find(({ id }) => id === accountId)
if (account) {
selectedAccounts.value = [...selectedAccounts.value, account]
}
}
}
// Watch selected account, on change we emit the new account data to the filter instance
watch(selectedAccounts, () => {
// Emit selected accounts as account data
const accounts = selectedAccounts.value.map(({ id: uid, displayName }) => ({ uid, displayName }))
emit('update:accounts', accounts)
})
/**
* Deselect an account
*
* @param event - The custom event
*/
function deselect(event: CustomEvent) {
const accountId = event.detail as string
selectedAccounts.value = selectedAccounts.value.filter(({ id }) => id !== accountId)
}
/**
* Reset this filter
@ -113,26 +145,27 @@ function resetFilter() {
*
* @param accounts - Accounts to use
*/
function setAvailableAccounts(accounts: IAccountData[]): void {
function setAvailableAccounts(accounts: IAccountData[] | CustomEvent): void {
if (accounts instanceof CustomEvent) {
accounts = accounts.detail as IAccountData[]
}
availableAccounts.value = accounts.map(({ uid, displayName }) => ({ displayName, id: uid, user: uid }))
}
defineExpose({
resetFilter,
setAvailableAccounts,
toggleAccount,
})
</script>
<style scoped lang="scss">
.file-list-filter-accounts {
&__item {
min-width: 250px;
}
<style module>
.fileListFilterAccount {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
}
&__avatar {
// 24px is the avatar size
margin: calc((var(--default-clickable-area) - 24px) / 2)
}
.fileListFilterAccount__avatar {
/* 24px is the avatar size */
margin: calc((var(--default-clickable-area) - 24px) / 2);
}
.fileListFilterAccount__currentUser {
font-weight: normal !important;
}
</style>

View file

@ -3,12 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFileListFilterChip, INode } from '@nextcloud/files'
import type { IFileListFilterChip, IFileListFilterWithUi, INode } from '@nextcloud/files'
import svgAccountMultipleOutline from '@mdi/svg/svg/account-multiple-outline.svg?raw'
import { subscribe } from '@nextcloud/event-bus'
import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { ShareType } from '@nextcloud/sharing'
import { isPublicShare } from '@nextcloud/sharing/public'
import wrap from '@vue/web-component-wrapper'
import Vue from 'vue'
import FileListFilterAccount from '../components/FileListFilterAccount.vue'
@ -21,48 +24,42 @@ export interface IAccountData {
displayName: string
}
type CurrentInstance = Vue & {
resetFilter: () => void
setAvailableAccounts: (accounts: IAccountData[]) => void
toggleAccount: (account: string) => void
}
const tagName = 'files_sharing-file-list-filter-account'
/**
* File list filter to filter by owner / sharee
*/
class AccountFilter extends FileListFilter {
private availableAccounts: IAccountData[]
private currentInstance?: CurrentInstance
private filterAccounts?: IAccountData[]
class AccountFilter extends FileListFilter implements IFileListFilterWithUi {
#availableAccounts: IAccountData[]
#filterAccounts?: IAccountData[]
public readonly displayName = t('files_sharing', 'People')
public readonly iconSvgInline = svgAccountMultipleOutline
public readonly tagName = tagName
constructor() {
super('files_sharing:account', 100)
this.availableAccounts = []
this.#availableAccounts = []
subscribe('files:list:updated', ({ contents }) => {
this.updateAvailableAccounts(contents)
})
}
public mount(el: HTMLElement) {
if (this.currentInstance) {
this.currentInstance.$destroy()
}
public get availableAccounts() {
return this.#availableAccounts
}
const View = Vue.extend(FileListFilterAccount as never)
this.currentInstance = new View({ el })
.$on('update:accounts', (accounts?: IAccountData[]) => this.setAccounts(accounts))
.$mount() as CurrentInstance
this.currentInstance
.setAvailableAccounts(this.availableAccounts)
public get filterAccounts() {
return this.#filterAccounts
}
public filter(nodes: INode[]): INode[] {
if (!this.filterAccounts || this.filterAccounts.length === 0) {
if (!this.#filterAccounts || this.#filterAccounts.length === 0) {
return nodes
}
const userIds = this.filterAccounts.map(({ uid }) => uid)
const userIds = this.#filterAccounts.map(({ uid }) => uid)
// Filter if the owner of the node is in the list of filtered accounts
return nodes.filter((node) => {
if (window.OCP.Files.Router.params.view === TRASHBIN_VIEW_ID) {
@ -95,7 +92,7 @@ class AccountFilter extends FileListFilter {
}
public reset(): void {
this.currentInstance?.resetFilter()
this.dispatchEvent(new CustomEvent('reset'))
}
/**
@ -104,13 +101,13 @@ class AccountFilter extends FileListFilter {
* @param accounts - Account to filter or undefined if inactive.
*/
public setAccounts(accounts?: IAccountData[]) {
this.filterAccounts = accounts
this.#filterAccounts = accounts
let chips: IFileListFilterChip[] = []
if (this.filterAccounts && this.filterAccounts.length > 0) {
chips = this.filterAccounts.map(({ displayName, uid }) => ({
if (this.#filterAccounts && this.#filterAccounts.length > 0) {
chips = this.#filterAccounts.map(({ displayName, uid }) => ({
text: displayName,
user: uid,
onclick: () => this.currentInstance?.toggleAccount(uid),
onclick: () => this.dispatchEvent(new CustomEvent('deselect', { detail: uid })),
}))
}
@ -164,13 +161,13 @@ class AccountFilter extends FileListFilter {
}
}
this.availableAccounts = [...available.values()]
if (this.currentInstance) {
this.currentInstance.setAvailableAccounts(this.availableAccounts)
}
this.#availableAccounts = [...available.values()]
this.dispatchEvent(new CustomEvent('accounts-updated'))
}
}
export type { AccountFilter }
/**
* Register the file list filter by owner or sharees
*/
@ -180,5 +177,20 @@ export function registerAccountFilter() {
return
}
const WrappedComponent = wrap(Vue, FileListFilterAccount)
// In Vue 2, wrap doesn't support disabling shadow :(
// Disable with a hack
Object.defineProperty(WrappedComponent.prototype, 'attachShadow', {
value() {
return this
},
})
Object.defineProperty(WrappedComponent.prototype, 'shadowRoot', {
get() {
return this
},
})
customElements.define(tagName, WrappedComponent)
registerFileListFilter(new AccountFilter())
}