diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-07-25 20:12:52 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-07-25 20:12:52 +0200 |
commit | 97ea95714aeec2d23567ae5d85b28fd6b688b1cc (patch) | |
tree | f9c7ca7ff9e6e64487994fc3461830b1b289a220 /apps | |
parent | 4f2a29adf95c57bef5d01f27c8b741a9840e82b3 (diff) | |
parent | 66f77b562caa7396f1d03509a56af4bea0b3ce89 (diff) | |
download | nextcloud-server-97ea95714aeec2d23567ae5d85b28fd6b688b1cc.tar.gz nextcloud-server-97ea95714aeec2d23567ae5d85b28fd6b688b1cc.zip |
Merge pull request #45708 from nextcloud/feat/files-filters
feat(files): Implement files list filters
Diffstat (limited to 'apps')
27 files changed, 1279 insertions, 172 deletions
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 48b6dcfd8a0..298500dd472 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -89,7 +89,8 @@ import { defineComponent } from 'vue' import { formatFileSize } from '@nextcloud/files' import moment from '@nextcloud/moment' -import { useNavigation } from '../composables/useNavigation' +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' @@ -134,6 +135,10 @@ export default defineComponent({ const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() const { currentView } = useNavigation() + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() return { actionsMenuStore, @@ -142,6 +147,8 @@ export default defineComponent({ renamingStore, selectionStore, + currentDir, + currentFileId, currentView, } }, diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue index 1f0992bc851..6d31542a15b 100644 --- a/apps/files/src/components/FileEntryGrid.vue +++ b/apps/files/src/components/FileEntryGrid.vue @@ -71,7 +71,8 @@ import { defineComponent } from 'vue' import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' -import { useNavigation } from '../composables/useNavigation' +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' @@ -107,6 +108,10 @@ export default defineComponent({ const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() const { currentView } = useNavigation() + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() return { actionsMenuStore, @@ -115,6 +120,8 @@ export default defineComponent({ renamingStore, selectionStore, + currentDir, + currentFileId, currentView, } }, diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index da9b93107c7..d9117053dd8 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -56,17 +56,10 @@ export default defineComponent({ }, computed: { - currentDir() { - // Remove any trailing slash but leave root slash - return (this.$route.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') - }, - currentFileId() { - return this.$route.params?.fileid || this.$route.query?.fileid || null - }, - fileid() { return this.source.fileid ?? 0 }, + uniqueId() { return hashCode(this.source.source) }, diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue new file mode 100644 index 00000000000..447ae7abdaa --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilter.vue @@ -0,0 +1,53 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcActions force-menu + :type="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 NcActions from '@nextcloud/vue/dist/Components/NcActions.js' +import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' +import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js' + +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-error-text); +} + +:deep(.button-vue) { + font-weight: normal !important; + + * { + font-weight: normal !important; + } +} +</style> diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue new file mode 100644 index 00000000000..a69e1782c2d --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterModified.vue @@ -0,0 +1,107 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - 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="mdiCalendarRange" /> + </template> + <NcActionButton v-for="preset of timePresets" + :key="preset.id" + type="radio" + close-after-click + :model-value.sync="selectedOption" + :value="preset.id"> + {{ preset.label }} + </NcActionButton> + <!-- TODO: Custom time range --> + </FileListFilter> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { ITimePreset } from '../../filters/ModifiedFilter.ts' + +import { mdiCalendarRange } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import FileListFilter from './FileListFilter.vue' + +export default defineComponent({ + components: { + FileListFilter, + NcActionButton, + NcIconSvgWrapper, + }, + + props: { + timePresets: { + type: Array as PropType<ITimePreset[]>, + required: true, + }, + }, + + setup() { + return { + // icons used in template + mdiCalendarRange, + } + }, + + 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 + }, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list-filter-time { + &__clear-button :deep(.action-button__text) { + color: var(--color-error-text); + } +} +</style> diff --git a/apps/files/src/components/FileListFilter/FileListFilterType.vue b/apps/files/src/components/FileListFilter/FileListFilterType.vue new file mode 100644 index 00000000000..a2a703438f9 --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterType.vue @@ -0,0 +1,110 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - 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="mdiFile" /> + </template> + <NcActionButton v-for="fileType of typePresets" + :key="fileType.id" + type="checkbox" + :model-value="selectedOptions.includes(fileType)" + @click="toggleOption(fileType)"> + <template #icon> + <NcIconSvgWrapper :svg="fileType.icon" /> + </template> + {{ fileType.label }} + </NcActionButton> + </FileListFilter> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { ITypePreset } from '../../filters/TypeFilter.ts' + +import { mdiFile } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import FileListFilter from './FileListFilter.vue' + +export default defineComponent({ + name: 'FileListFilterType', + + components: { + FileListFilter, + NcActionButton, + NcIconSvgWrapper, + }, + + props: { + typePresets: { + type: Array as PropType<ITypePreset[]>, + required: true, + }, + }, + + setup() { + return { + mdiFile, + t, + } + }, + + data() { + return { + selectedOptions: [] as ITypePreset[], + } + }, + + computed: { + isActive() { + return this.selectedOptions.length > 0 + }, + }, + + watch: { + selectedOptions(newValue, oldValue) { + if (this.selectedOptions.length === 0) { + if (oldValue.length !== 0) { + this.$emit('update:preset') + } + } else { + this.$emit('update:preset', this.selectedOptions) + } + }, + }, + + 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) + } + }, + }, +}) +</script> + +<style> +.file-list-filter-type { + max-width: 220px; +} +</style> diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue new file mode 100644 index 00000000000..5cdc4e877fd --- /dev/null +++ b/apps/files/src/components/FileListFilters.vue @@ -0,0 +1,66 @@ +<!-- + - 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" /> + </li> + </ul> + </div> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import { computed, ref, watchEffect } from 'vue' +import { useFiltersStore } from '../store/filters.ts' + +import NcChip from '@nextcloud/vue/dist/Components/NcChip.js' + +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> diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 4bac5f84db6..17de4b15b68 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -16,6 +16,10 @@ }" :scroll-to-index="scrollToIndex" :caption="caption"> + <template #filters> + <FileListFilters /> + </template> + <template v-if="!isNoneSelected" #header-overlay> <span class="files-list__selected">{{ t('files', '{count} selected', { count: selectedNodes.length }) }}</span> <FilesListTableHeaderActions :current-view="currentView" @@ -65,6 +69,7 @@ import { translate as t } from '@nextcloud/l10n' import { defineComponent } from 'vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' import { getSummaryFor } from '../utils/fileUtils' import { useSelectionStore } from '../store/selection.js' import { useUserConfigStore } from '../store/userconfig.ts' @@ -78,11 +83,13 @@ import filesListWidthMixin from '../mixins/filesListWidth.ts' import VirtualList from './VirtualList.vue' import logger from '../logger.js' import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' +import FileListFilters from './FileListFilters.vue' export default defineComponent({ name: 'FilesListVirtual', components: { + FileListFilters, FilesListHeader, FilesListTableFooter, FilesListTableHeader, @@ -112,7 +119,12 @@ export default defineComponent({ setup() { const userConfigStore = useUserConfigStore() const selectionStore = useSelectionStore() + const { fileId, openFile } = useRouteParameters() + return { + fileId, + openFile, + userConfigStore, selectionStore, } @@ -133,18 +145,6 @@ export default defineComponent({ return this.userConfigStore.userConfig }, - fileId() { - return Number.parseInt(this.$route.params.fileid ?? '0') || null - }, - - /** - * If the current `fileId` should be opened - * The state of the `openfile` query param - */ - openFile() { - return !!this.$route.query.openfile - }, - summary() { return getSummaryFor(this.nodes) }, @@ -319,10 +319,16 @@ export default defineComponent({ --clickable-area: var(--default-clickable-area); --icon-preview-size: 32px; + --fixed-top-position: var(--default-clickable-area); + overflow: auto; height: 100%; will-change: scroll-position; + &:has(.file-list-filters__active) { + --fixed-top-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small)); + } + & :deep() { // Table head, body and footer tbody { @@ -364,10 +370,21 @@ export default defineComponent({ } } + .files-list__filters { + // Pinned on top when scrolling above table header + position: sticky; + top: 0; + // fix size and background + background-color: var(--color-main-background); + padding-inline: var(--row-height) var(--default-grid-baseline, 4px); + height: var(--fixed-top-position); + width: 100%; + } + .files-list__thead-overlay { // Pinned on top when scrolling position: sticky; - top: 0; + top: var(--fixed-top-position); // Save space for a row checkbox margin-left: var(--row-height); // More than .files-list__thead @@ -396,7 +413,7 @@ export default defineComponent({ // Pinned on top when scrolling position: sticky; z-index: 10; - top: 0; + top: var(--fixed-top-position); } // Table footer diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index 557fb240797..0619f6bc3fd 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -4,7 +4,7 @@ --> <template> <NcAppNavigationItem v-if="storageStats" - :aria-label="t('files', 'Storage informations')" + :aria-description="t('files', 'Storage information')" :class="{ 'app-navigation-entry__settings-quota--not-unlimited': storageStats.quota >= 0}" :loading="loadingStorageStats" :name="storageStatsTitle" @@ -17,6 +17,7 @@ <!-- Progress bar --> <NcProgressBar v-if="storageStats.quota >= 0" slot="extra" + :aria-label="t('files', 'Storage quota')" :error="storageStats.relative > 80" :value="Math.min(storageStats.relative, 100)" /> </NcAppNavigationItem> diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index daf021e8ed5..6206f86cda5 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -9,6 +9,10 @@ <slot name="before" /> </div> + <div class="files-list__filters"> + <slot name="filters" /> + </div> + <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay"> <slot name="header-overlay" /> </div> diff --git a/apps/files/src/composables/useFilenameFilter.ts b/apps/files/src/composables/useFilenameFilter.ts new file mode 100644 index 00000000000..54c16f35384 --- /dev/null +++ b/apps/files/src/composables/useFilenameFilter.ts @@ -0,0 +1,47 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { registerFileListFilter, unregisterFileListFilter } from '@nextcloud/files' +import { watchThrottled } from '@vueuse/core' +import { onMounted, onUnmounted, ref } from 'vue' +import { FilenameFilter } from '../filters/FilenameFilter' + +/** + * This is for the `Navigation` component to provide a filename filter + */ +export function useFilenameFilter() { + const searchQuery = ref('') + const filenameFilter = new FilenameFilter() + + /** + * Updating the search query ref from the filter + * @param event The update:query event + */ + function updateQuery(event: CustomEvent) { + if (event.type === 'update:query') { + searchQuery.value = event.detail + event.stopPropagation() + } + } + + onMounted(() => { + filenameFilter.addEventListener('update:query', updateQuery) + registerFileListFilter(filenameFilter) + }) + onUnmounted(() => { + filenameFilter.removeEventListener('update:query', updateQuery) + unregisterFileListFilter(filenameFilter.id) + }) + + // Update the query on the filter, but throttle to max. every 800ms + // This will debounce the filter refresh + watchThrottled(searchQuery, () => { + filenameFilter.updateQuery(searchQuery.value) + }, { throttle: 800 }) + + return { + searchQuery, + } +} diff --git a/apps/files/src/composables/useRouteParameters.ts b/apps/files/src/composables/useRouteParameters.ts new file mode 100644 index 00000000000..931b6eeefb2 --- /dev/null +++ b/apps/files/src/composables/useRouteParameters.ts @@ -0,0 +1,47 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { computed } from 'vue' +import { useRoute } from 'vue-router/composables' + +/** + * Get information about the current route + */ +export function useRouteParameters() { + + const route = useRoute() + + /** + * Get the path of the current active directory + */ + const directory = computed<string>( + () => String(route.query.dir || '/') + // Remove any trailing slash but leave root slash + .replace(/^(.+)\/$/, '$1') + ) + + /** + * Get the current fileId used on the route + */ + const fileId = computed<number | null>(() => { + const fileId = Number.parseInt(route.params.fileid ?? '0') || null + return Number.isNaN(fileId) ? null : fileId + }) + + /** + * State of `openFile` route param + */ + const openFile = computed<boolean>(() => 'openFile' in route.params && route.params.openFile.toLocaleLowerCase() !== 'false') + + return { + /** Path of currently open directory */ + directory, + + /** Current active fileId */ + fileId, + + /** Should the active node should be opened (`openFile` route param) */ + openFile, + } +} diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts index 8d57d82c034..db90c40eeae 100644 --- a/apps/files/src/eventbus.d.ts +++ b/apps/files/src/eventbus.d.ts @@ -2,14 +2,19 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Node } from '@nextcloud/files' +import type { IFileListFilter, Node } from '@nextcloud/files' declare module '@nextcloud/event-bus' { export interface NextcloudEvents { // mapping of 'event name' => 'event type' + 'files:config:updated': { key: string, value: unknown } + 'files:favorites:removed': Node 'files:favorites:added': Node 'files:node:renamed': Node + + 'files:filter:added': IFileListFilter + 'files:filter:removed': string } } diff --git a/apps/files/src/filters/FilenameFilter.ts b/apps/files/src/filters/FilenameFilter.ts new file mode 100644 index 00000000000..32df078a006 --- /dev/null +++ b/apps/files/src/filters/FilenameFilter.ts @@ -0,0 +1,53 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFileListFilterChip, INode } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' +import { FileListFilter } from '@nextcloud/files' + +/** + * Simple file list filter controlled by the Navigation search box + */ +export class FilenameFilter extends FileListFilter { + + private searchQuery = '' + + constructor() { + super('files:filename', 5) + subscribe('files:navigation:changed', () => this.updateQuery('')) + } + + public filter(nodes: INode[]): INode[] { + const queryParts = this.searchQuery.toLocaleLowerCase().split(' ').filter(Boolean) + return nodes.filter((node) => { + const displayname = node.displayname.toLocaleLowerCase() + return queryParts.every((part) => displayname.includes(part)) + }) + } + + public updateQuery(query: string) { + query = (query || '').trim() + + // Only if the query is different we update the filter to prevent re-computing all nodes + if (query !== this.searchQuery) { + this.searchQuery = query + this.filterUpdated() + + const chips: IFileListFilterChip[] = [] + if (query !== '') { + chips.push({ + text: query, + onclick: () => { + this.updateQuery('') + }, + }) + } + this.updateChips(chips) + // Emit the new query as it might have come not from the Navigation + this.dispatchTypedEvent('update:query', new CustomEvent('update:query', { detail: query })) + } + } + +} diff --git a/apps/files/src/filters/HiddenFilesFilter.ts b/apps/files/src/filters/HiddenFilesFilter.ts new file mode 100644 index 00000000000..51d90b2efb2 --- /dev/null +++ b/apps/files/src/filters/HiddenFilesFilter.ts @@ -0,0 +1,42 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '@nextcloud/files' +import type { UserConfig } from '../types' +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' + +class HiddenFilesFilter extends FileListFilter { + + private showHidden?: boolean + + constructor() { + super('files:hidden', 0) + this.showHidden = loadState<UserConfig>('files', 'config', { show_hidden: false }).show_hidden + + subscribe('files:config:updated', ({ key, value }) => { + if (key === 'show_hidden') { + this.showHidden = Boolean(value) + this.filterUpdated() + } + }) + } + + public filter(nodes: INode[]): INode[] { + if (this.showHidden) { + return nodes + } + return nodes.filter((node) => (node.attributes.hidden !== true && !node.basename.startsWith('.'))) + } + +} + +/** + * Register a file list filter to only show hidden files if enabled by user config + */ +export function registerHiddenFilesFilter() { + registerFileListFilter(new HiddenFilesFilter()) +} diff --git a/apps/files/src/filters/ModifiedFilter.ts b/apps/files/src/filters/ModifiedFilter.ts new file mode 100644 index 00000000000..63563f24510 --- /dev/null +++ b/apps/files/src/filters/ModifiedFilter.ts @@ -0,0 +1,112 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { IFileListFilterChip, INode } from '@nextcloud/files' + +import { subscribe } from '@nextcloud/event-bus' +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import Vue from 'vue' +import FileListFilterModified from '../components/FileListFilter/FileListFilterModified.vue' + +import calendarSvg from '@mdi/svg/svg/calendar.svg?raw' + +export interface ITimePreset { + id: string, + label: string, + filter: (time: number) => boolean +} + +const startOfToday = () => (new Date()).setHours(0, 0, 0, 0) + +/** + * 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 { + + private currentInstance?: Vue + private currentPreset?: ITimePreset + + constructor() { + super('files:modified', 50) + subscribe('files:navigation:changed', () => this.setPreset()) + } + + 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 + } + + return nodes.filter((node) => node.mtime === undefined || this.currentPreset!.filter(node.mtime.getTime())) + } + + public setPreset(preset?: ITimePreset) { + this.currentPreset = preset + this.filterUpdated() + + const chips: IFileListFilterChip[] = [] + if (preset) { + chips.push({ + icon: calendarSvg, + text: preset.label, + onclick: () => this.setPreset(), + }) + } else { + (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter() + } + this.updateChips(chips) + } + +} + +/** + * Register the file list filter by modification date + */ +export function registerModifiedFilter() { + registerFileListFilter(new ModifiedFilter()) +} diff --git a/apps/files/src/filters/TypeFilter.ts b/apps/files/src/filters/TypeFilter.ts new file mode 100644 index 00000000000..d35671fc220 --- /dev/null +++ b/apps/files/src/filters/TypeFilter.ts @@ -0,0 +1,169 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { IFileListFilterChip, INode } from '@nextcloud/files' + +import { subscribe } from '@nextcloud/event-bus' +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import Vue from 'vue' +import FileListFilterType from '../components/FileListFilter/FileListFilterType.vue' + +// TODO: Create a modern replacement for OC.MimeType... +import svgDocument from '@mdi/svg/svg/file-document.svg?raw' +import svgSpreadsheet from '@mdi/svg/svg/file-table-box.svg?raw' +import svgPresentation from '@mdi/svg/svg/file-presentation-box.svg?raw' +import svgPDF from '@mdi/svg/svg/file-pdf-box.svg?raw' +import svgFolder from '@mdi/svg/svg/folder.svg?raw' +import svgAudio from '@mdi/svg/svg/music.svg?raw' +import svgImage from '@mdi/svg/svg/image.svg?raw' +import svgMovie from '@mdi/svg/svg/movie.svg?raw' + +export interface ITypePreset { + id: string + label: string + icon: string + mime: string[] +} + +const colorize = (svg: string, color: string) => { + return svg.replace('<path ', `<path fill="${color}" `) +} + +/** + * Available presets + */ +const getTypePresets = async () => [ + { + 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', + label: t('files', 'Pictures and images'), + icon: svgImage, + mime: ['image'], + }, + { + id: 'video', + label: t('files', 'Videos'), + icon: svgMovie, + mime: ['video'], + }, +] as ITypePreset[] + +class TypeFilter extends FileListFilter { + + private currentInstance?: Vue + private currentPresets?: ITypePreset[] + private allPresets?: ITypePreset[] + + constructor() { + super('files:type', 10) + subscribe('files:navigation:changed', () => this.setPreset()) + } + + 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() + } + + if (this.currentInstance) { + this.currentInstance.$destroy() + } + + const View = Vue.extend(FileListFilterType as never) + this.currentInstance = new View({ + propsData: { + typePresets: this.allPresets!, + }, + el, + }) + .$on('update:preset', this.setPreset.bind(this)) + .$mount() + } + + public filter(nodes: INode[]): INode[] { + if (!this.currentPresets || this.currentPresets.length === 0) { + return nodes + } + + const mimeList = this.currentPresets.reduce((previous: string[], current) => [...previous, ...current.mime], [] as string[]) + return nodes.filter((node) => { + if (!node.mime) { + return false + } + const mime = node.mime.toLowerCase() + + if (mimeList.includes(mime)) { + return true + } else if (mimeList.includes(window.OC.MimeTypeList.aliases[mime])) { + return true + } else if (mimeList.includes(mime.split('/')[0])) { + return true + } + return false + }) + } + + public setPreset(presets?: ITypePreset[]) { + this.currentPresets = presets + this.filterUpdated() + + const chips: IFileListFilterChip[] = [] + if (presets && presets.length > 0) { + for (const preset of presets) { + chips.push({ + icon: preset.icon, + text: preset.label, + onclick: () => this.setPreset(presets.filter(({ id }) => id !== preset.id)), + }) + } + } else { + (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter() + } + this.updateChips(chips) + } + +} + +/** + * Register the file list filter by file type + */ +export function registerTypeFilter() { + registerFileListFilter(new TypeFilter()) +} diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index 25bcc1072f0..4266453a4a3 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -14,6 +14,11 @@ import { action as openInFilesAction } from './actions/openInFilesAction' import { action as renameAction } from './actions/renameAction' import { action as sidebarAction } from './actions/sidebarAction' import { action as viewInFolderAction } from './actions/viewInFolderAction' + +import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts' +import { registerTypeFilter } from './filters/TypeFilter.ts' +import { registerModifiedFilter } from './filters/ModifiedFilter.ts' + import { entry as newFolderEntry } from './newMenu/newFolder.ts' import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts' import { registerTemplateEntries } from './newMenu/newFromTemplate.ts' @@ -49,6 +54,11 @@ registerFilesView() registerRecentView() registerPersonalFilesView() +// Register file list filters +registerHiddenFilesFilter() +registerTypeFilter() +registerModifiedFilter() + // Register preview service worker registerPreviewServiceWorker() diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index 62c4894d1e4..cac0cf25b6d 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -38,7 +38,7 @@ Object.assign(window.OCP.Files, { Router }) Vue.use(PiniaVuePlugin) // Init Navigation Service -// This only works with Vue 2 - with Vue 3 this will not modify the source but return just a oberserver +// This only works with Vue 2 - with Vue 3 this will not modify the source but return just a observer const Navigation = Vue.observable(getNavigation()) Vue.prototype.$navigation = Navigation diff --git a/apps/files/src/store/filters.ts b/apps/files/src/store/filters.ts new file mode 100644 index 00000000000..abc12732fd4 --- /dev/null +++ b/apps/files/src/store/filters.ts @@ -0,0 +1,78 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' +import { getFileListFilters } from '@nextcloud/files' +import { defineStore } from 'pinia' +import logger from '../logger' + +export const useFiltersStore = defineStore('keyboard', { + state: () => ({ + chips: {} as Record<string, IFileListFilterChip[]>, + filters: [] as IFileListFilter[], + filtersChanged: false, + }), + + getters: { + /** + * Currently active filter chips + * @param state Internal state + */ + activeChips(state): IFileListFilterChip[] { + return Object.values(state.chips).flat() + }, + + /** + * Filters sorted by order + * @param state Internal state + */ + sortedFilters(state): IFileListFilter[] { + return state.filters.sort((a, b) => a.order - b.order) + }, + + /** + * All filters that provide a UI for visual controlling the filter state + */ + filtersWithUI(): Required<IFileListFilter>[] { + return this.sortedFilters.filter((filter) => 'mount' in filter) as Required<IFileListFilter>[] + }, + }, + + actions: { + addFilter(filter: IFileListFilter) { + filter.addEventListener('update:chips', this.onFilterUpdateChips) + filter.addEventListener('update:filter', this.onFilterUpdate) + this.filters.push(filter) + logger.debug('New file list filter registered', { id: filter.id }) + }, + + removeFilter(filterId: string) { + const index = this.filters.findIndex(({ id }) => id === filterId) + if (index > -1) { + const [filter] = this.filters.splice(index, 1) + filter.removeEventListener('update:chips', this.onFilterUpdateChips) + filter.removeEventListener('update:filter', this.onFilterUpdate) + logger.debug('Files list filter unregistered', { id: filterId }) + } + }, + + onFilterUpdate() { + this.filtersChanged = true + }, + + onFilterUpdateChips(event: FilterUpdateChipsEvent) { + const id = (event.target as IFileListFilter).id + this.chips = { ...this.chips, [id]: [...event.detail] } + }, + + init() { + subscribe('files:filter:added', this.addFilter) + subscribe('files:filter:removed', this.removeFilter) + for (const filter of getFileListFilters()) { + this.addFilter(filter) + } + }, + }, +}) diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 6d9a6cf60a0..ca868b5d526 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -6,7 +6,7 @@ <NcAppContent :page-heading="pageHeading" data-cy-files-content> <div class="files-list__header"> <!-- Current folder breadcrumbs --> - <BreadCrumbs :path="dir" @reload="fetchContent"> + <BreadCrumbs :path="directory" @reload="fetchContent"> <template #actions> <!-- Sharing button --> <NcButton v-if="canShare && filesListWidth >= 512" @@ -78,7 +78,7 @@ :name="currentView?.emptyTitle || t('files', 'No files in here')" :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" data-cy-files-content-empty> - <template v-if="dir !== '/'" #action> + <template v-if="directory !== '/'" #action> <!-- Uploader --> <UploadPicker v-if="currentFolder && canUpload && !isQuotaExceeded" allow-folders @@ -111,7 +111,7 @@ </template> <script lang="ts"> -import type { ContentsWithRoot } from '@nextcloud/files' +import type { ContentsWithRoot, INode } from '@nextcloud/files' import type { Upload } from '@nextcloud/upload' import type { CancelablePromise } from 'cancelable-promise' import type { ComponentPublicInstance } from 'vue' @@ -142,7 +142,9 @@ import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useFilesStore } from '../store/files.ts' +import { useFiltersStore } from '../store/filters.ts' import { usePathsStore } from '../store/paths.ts' import { useSelectionStore } from '../store/selection.ts' import { useUploaderStore } from '../store/uploader.ts' @@ -154,7 +156,6 @@ import filesListWidthMixin from '../mixins/filesListWidth.ts' import filesSortingMixin from '../mixins/filesSorting.ts' import logger from '../logger.js' import DragAndDropNotice from '../components/DragAndDropNotice.vue' -import debounce from 'debounce' const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined @@ -185,20 +186,26 @@ export default defineComponent({ setup() { const filesStore = useFilesStore() + const filtersStore = useFiltersStore() const pathsStore = usePathsStore() const selectionStore = useSelectionStore() const uploaderStore = useUploaderStore() const userConfigStore = useUserConfigStore() const viewConfigStore = useViewConfigStore() const { currentView } = useNavigation() + const { directory, fileId } = useRouteParameters() const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', []) return { currentView, + directory, + fileId, + t, filesStore, + filtersStore, pathsStore, selectionStore, uploaderStore, @@ -214,30 +221,21 @@ export default defineComponent({ data() { return { - filterText: '', loading: true, promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null, + dirContentsFiltered: [] as INode[], + unsubscribeStoreCallback: () => {}, } }, computed: { /** - * Handle search event from unified search. - */ - onSearch() { - return debounce((searchEvent: { query: string }) => { - console.debug('Files app handling search event from unified search...', searchEvent) - this.filterText = searchEvent.query - }, 500) - }, - - /** * Get a callback function for the uploader to fetch directory contents for conflict resolution */ getContent() { - const view = this.currentView + const view = this.currentView! return async (path?: string) => { // as the path is allowed to be undefined we need to normalize the path ('//' to '/') const normalizedPath = normalize(`${this.currentFolder?.path ?? ''}/${path ?? ''}`) @@ -255,22 +253,6 @@ export default defineComponent({ }, /** - * The current directory query. - */ - dir(): string { - // Remove any trailing slash but leave root slash - return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') - }, - - /** - * The current file id - */ - fileId(): number | null { - const number = Number.parseInt(this.$route?.params.fileid ?? '') - return Number.isNaN(number) ? null : number - }, - - /** * The current folder. */ currentFolder(): Folder | undefined { @@ -278,11 +260,11 @@ export default defineComponent({ return } - if (this.dir === '/') { + if (this.directory === '/') { return this.filesStore.getRoot(this.currentView.id) } - const source = this.pathsStore.getPath(this.currentView.id, this.dir) + const source = this.pathsStore.getPath(this.currentView.id, this.directory) if (source === undefined) { return } @@ -290,6 +272,12 @@ export default defineComponent({ return this.filesStore.getNode(source) as Folder }, + dirContents(): Node[] { + return (this.currentFolder?._children || []) + .map(this.getNode) + .filter((node: Node) => !!node) + }, + /** * The current directory contents. */ @@ -298,25 +286,16 @@ export default defineComponent({ return [] } - let filteredDirContent = [...this.dirContents] - // Filter based on the filterText obtained from nextcloud:unified-search.search event. - if (this.filterText) { - filteredDirContent = filteredDirContent.filter(node => { - return node.basename.toLowerCase().includes(this.filterText.toLowerCase()) - }) - console.debug('Files view filtered', filteredDirContent) - } - const customColumn = (this.currentView?.columns || []) .find(column => column.id === this.sortingMode) // Custom column must provide their own sorting methods if (customColumn?.sort && typeof customColumn.sort === 'function') { - const results = [...this.dirContents].sort(customColumn.sort) + const results = [...this.dirContentsFiltered].sort(customColumn.sort) return this.isAscSorting ? results : results.reverse() } - return sortNodes(filteredDirContent, { + return sortNodes(this.dirContentsFiltered, { sortFavoritesFirst: this.userConfig.sort_favorites_first, sortFoldersFirst: this.userConfig.sort_folders_first, sortingMode: this.sortingMode, @@ -324,19 +303,6 @@ export default defineComponent({ }) }, - dirContents(): Node[] { - const showHidden = this.userConfigStore?.userConfig.show_hidden - return (this.currentFolder?._children || []) - .map(this.getNode) - .filter(file => { - if (!showHidden) { - return file && file?.attributes?.hidden !== true && !file?.basename.startsWith('.') - } - - return !!file - }) - }, - /** * The current directory is empty. */ @@ -359,7 +325,7 @@ export default defineComponent({ * Route to the previous directory. */ toPreviousDir(): Route { - const dir = this.dir.split('/').slice(0, -1).join('/') || '/' + const dir = this.directory.split('/').slice(0, -1).join('/') || '/' return { ...this.$route, query: { dir } } }, @@ -421,6 +387,10 @@ export default defineComponent({ return isSharingEnabled && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0 }, + + filtersChanged() { + return this.filtersStore.filtersChanged + }, }, watch: { @@ -431,15 +401,13 @@ export default defineComponent({ logger.debug('View changed', { newView, oldView }) this.selectionStore.reset() - this.triggerResetSearch() this.fetchContent() }, - dir(newDir, oldDir) { + directory(newDir, oldDir) { logger.debug('Directory changed', { newDir, oldDir }) // TODO: preserve selection on browsing? this.selectionStore.reset() - this.triggerResetSearch() if (window.OCA.Files.Sidebar?.close) { window.OCA.Files.Sidebar.close() } @@ -455,16 +423,24 @@ export default defineComponent({ dirContents(contents) { logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents }) emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents }) + // Also refresh the filtered content + this.filterDirContent() + }, + + filtersChanged() { + if (this.filtersChanged) { + this.filterDirContent() + this.filtersStore.filtersChanged = false + } }, }, mounted() { + this.filtersStore.init() this.fetchContent() subscribe('files:node:deleted', this.onNodeDeleted) subscribe('files:node:updated', this.onUpdatedNode) - subscribe('nextcloud:unified-search:search', this.onSearch) - subscribe('nextcloud:unified-search:reset', this.onResetSearch) // reload on settings change this.unsubscribeStoreCallback = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true }) @@ -473,17 +449,12 @@ export default defineComponent({ unmounted() { unsubscribe('files:node:deleted', this.onNodeDeleted) unsubscribe('files:node:updated', this.onUpdatedNode) - unsubscribe('nextcloud:unified-search:search', this.onSearch) - unsubscribe('nextcloud:unified-search:reset', this.onResetSearch) - this.unsubscribeStoreCallback() }, methods: { - t, - async fetchContent() { this.loading = true - const dir = this.dir + const dir = this.directory const currentView = this.currentView if (!currentView) { @@ -555,10 +526,10 @@ export default defineComponent({ if (node.fileid && node.fileid === this.fileId) { if (node.fileid === this.currentFolder?.fileid) { // Handle the edge case that the current directory is deleted - // in this case we neeed to keept the current view but move to the parent directory + // in this case we need to keep the current view but move to the parent directory window.OCP.Files.Router.goToRoute( null, - { view: this.$route.params.view }, + { view: this.currentView!.id }, { dir: this.currentFolder?.dirname ?? '/' }, ) } else { @@ -645,24 +616,6 @@ export default defineComponent({ } }, - /** - * Handle reset search query event - */ - onResetSearch() { - // Reset debounced calls to not set the query again - this.onSearch.clear() - // Reset filter query - this.filterText = '' - }, - - /** - * Trigger a reset of the local search (part of unified search) - * This is usful to reset the search on directory / view change - */ - triggerResetSearch() { - emit('nextcloud:unified-search:reset') - }, - openSharingSidebar() { if (!this.currentFolder) { logger.debug('No current folder found for opening sharing sidebar') @@ -674,9 +627,18 @@ export default defineComponent({ } sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path) }, + toggleGridView() { this.userConfigStore.update('grid_view', !this.userConfig.grid_view) }, + + filterDirContent() { + let nodes = this.dirContents + for (const filter of this.filtersStore.sortedFilters) { + nodes = filter.filter(nodes) + } + this.dirContentsFiltered = nodes + }, }, }) </script> diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index b69c6d5f7f2..cfd170bd073 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -5,33 +5,43 @@ <template> <NcAppNavigation data-cy-files-navigation :aria-label="t('files', 'Files')"> - <template #list> - <NcAppNavigationItem v-for="view in parentViews" - :key="view.id" - :allow-collapse="true" - :data-cy-files-navigation-item="view.id" - :exact="useExactRouteMatching(view)" - :icon="view.iconClass" - :name="view.name" - :open="isExpanded(view)" - :pinned="view.sticky" - :to="generateToNavigation(view)" - @update:open="onToggleExpand(view)"> - <!-- Sanitized icon as svg if provided --> - <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" /> - - <!-- Child views if any --> - <NcAppNavigationItem v-for="child in childViews[view.id]" - :key="child.id" - :data-cy-files-navigation-item="child.id" - :exact-path="true" - :icon="child.iconClass" - :name="child.name" - :to="generateToNavigation(child)"> + <template #search> + <NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter filenames…')" /> + </template> + <template #default> + <NcAppNavigationList :aria-label="t('files', 'Views')"> + <NcAppNavigationItem v-for="view in parentViews" + :key="view.id" + :allow-collapse="true" + :data-cy-files-navigation-item="view.id" + :exact="useExactRouteMatching(view)" + :icon="view.iconClass" + :name="view.name" + :open="isExpanded(view)" + :pinned="view.sticky" + :to="generateToNavigation(view)" + @update:open="onToggleExpand(view)"> <!-- Sanitized icon as svg if provided --> - <NcIconSvgWrapper v-if="child.icon" slot="icon" :svg="child.icon" /> + <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" /> + + <!-- Child views if any --> + <NcAppNavigationItem v-for="child in childViews[view.id]" + :key="child.id" + :data-cy-files-navigation-item="child.id" + :exact-path="true" + :icon="child.iconClass" + :name="child.name" + :to="generateToNavigation(child)"> + <!-- Sanitized icon as svg if provided --> + <NcIconSvgWrapper v-if="child.icon" slot="icon" :svg="child.icon" /> + </NcAppNavigationItem> </NcAppNavigationItem> - </NcAppNavigationItem> + </NcAppNavigationList> + + <!-- Settings modal--> + <SettingsModal :open="settingsOpened" + data-cy-files-navigation-settings + @close="onSettingsClose" /> </template> <!-- Non-scrollable navigation bottom elements --> @@ -41,19 +51,13 @@ <NavigationQuota /> <!-- Files settings modal toggle--> - <NcAppNavigationItem :aria-label="t('files', 'Open the files app settings')" - :name="t('files', 'Files settings')" + <NcAppNavigationItem :name="t('files', 'Files settings')" data-cy-files-navigation-settings-button @click.prevent.stop="openSettings"> <IconCog slot="icon" :size="20" /> </NcAppNavigationItem> </ul> </template> - - <!-- Settings modal--> - <SettingsModal :open="settingsOpened" - data-cy-files-navigation-settings - @close="onSettingsClose" /> </NcAppNavigation> </template> @@ -61,17 +65,21 @@ import type { View } from '@nextcloud/files' import { emit } from '@nextcloud/event-bus' -import { translate as t } from '@nextcloud/l10n' +import { t } from '@nextcloud/l10n' import { defineComponent } from 'vue' import IconCog from 'vue-material-design-icons/Cog.vue' import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' +import NcAppNavigationList from '@nextcloud/vue/dist/Components/NcAppNavigationList.js' +import NcAppNavigationSearch from '@nextcloud/vue/dist/Components/NcAppNavigationSearch.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import NavigationQuota from '../components/NavigationQuota.vue' import SettingsModal from './Settings.vue' import { useNavigation } from '../composables/useNavigation' +import { useFilenameFilter } from '../composables/useFilenameFilter' +import { useFiltersStore } from '../store/filters.ts' import { useViewConfigStore } from '../store/viewConfig.ts' import logger from '../logger.js' @@ -84,18 +92,25 @@ export default defineComponent({ NavigationQuota, NcAppNavigation, NcAppNavigationItem, + NcAppNavigationList, + NcAppNavigationSearch, NcIconSvgWrapper, SettingsModal, }, setup() { + const filtersStore = useFiltersStore() const viewConfigStore = useViewConfigStore() const { currentView, views } = useNavigation() + const { searchQuery } = useFilenameFilter() return { currentView, + searchQuery, + t, views, + filtersStore, viewConfigStore, } }, @@ -160,8 +175,6 @@ export default defineComponent({ }, methods: { - t, - /** * Only use exact route matching on routes with child views * Because if a view does not have children (like the files view) then multiple routes might be matched for it diff --git a/apps/files_sharing/src/actions/sharingStatusAction.ts b/apps/files_sharing/src/actions/sharingStatusAction.ts index 5efe65d99d6..1e25851fa0f 100644 --- a/apps/files_sharing/src/actions/sharingStatusAction.ts +++ b/apps/files_sharing/src/actions/sharingStatusAction.ts @@ -4,31 +4,19 @@ */ import { Node, View, registerFileAction, FileAction, Permission } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' -import { Type } from '@nextcloud/sharing' +import { ShareType } from '@nextcloud/sharing' import AccountGroupSvg from '@mdi/svg/svg/account-group.svg?raw' import AccountPlusSvg from '@mdi/svg/svg/account-plus.svg?raw' import LinkSvg from '@mdi/svg/svg/link.svg?raw' import CircleSvg from '../../../../core/img/apps/circles.svg?raw' -import { action as sidebarAction } from '../../../files/src/actions/sidebarAction' -import { generateUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import { action as sidebarAction } from '../../../files/src/actions/sidebarAction' +import { generateAvatarSvg } from '../utils/AccountIcon' import './sharingStatusAction.scss' -const isDarkMode = window?.matchMedia?.('(prefers-color-scheme: dark)')?.matches === true - || document.querySelector('[data-themes*=dark]') !== null - -const generateAvatarSvg = (userId: string, isGuest = false) => { - const url = isDarkMode ? '/avatar/{userId}/32/dark' : '/avatar/{userId}/32' - const avatarUrl = generateUrl(isGuest ? url : url + '?guestFallback=true', { userId }) - return `<svg width="32" height="32" viewBox="0 0 32 32" - xmlns="http://www.w3.org/2000/svg" class="sharing-status__avatar"> - <image href="${avatarUrl}" height="32" width="32" /> - </svg>` -} - const isExternal = (node: Node) => { return node.attributes.remote_id !== undefined } @@ -75,19 +63,19 @@ export const action = new FileAction({ } // Link shares - if (shareTypes.includes(Type.SHARE_TYPE_LINK) - || shareTypes.includes(Type.SHARE_TYPE_EMAIL)) { + if (shareTypes.includes(ShareType.Link) + || shareTypes.includes(ShareType.Email)) { return LinkSvg } // Group shares - if (shareTypes.includes(Type.SHARE_TYPE_GROUP) - || shareTypes.includes(Type.SHARE_TYPE_REMOTE_GROUP)) { + if (shareTypes.includes(ShareType.Grup) + || shareTypes.includes(ShareType.RemoteGroup)) { return AccountGroupSvg } // Circle shares - if (shareTypes.includes(Type.SHARE_TYPE_CIRCLE)) { + if (shareTypes.includes(ShareType.Team)) { return CircleSvg } diff --git a/apps/files_sharing/src/components/FileListFilterAccount.vue b/apps/files_sharing/src/components/FileListFilterAccount.vue new file mode 100644 index 00000000000..7a91de6464e --- /dev/null +++ b/apps/files_sharing/src/components/FileListFilterAccount.vue @@ -0,0 +1,116 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcSelect v-model="selectedAccounts" + :aria-label-combobox="t('files_sharing', 'Accounts')" + class="file-list-filter-accounts" + multiple + no-wrap + :options="availableAccounts" + :placeholder="t('files_sharing', 'Accounts')" + user-select /> +</template> + +<script setup lang="ts"> +import type { IAccountData } from '../filters/AccountFilter.ts' + +import { translate as t } from '@nextcloud/l10n' +import { useBrowserLocation } from '@vueuse/core' +import { ref, watch, watchEffect } from 'vue' +import { useNavigation } from '../../../files/src/composables/useNavigation.ts' + +import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' + +interface IUserSelectData { + id: string + user: string + displayName: string +} + +const emit = defineEmits<{ + (event: 'update:accounts', value: IAccountData[]): void +}>() + +const { currentView } = useNavigation() +const currentLocation = useBrowserLocation() +const availableAccounts = ref<IUserSelectData[]>([]) +const selectedAccounts = ref<IUserSelectData[]>([]) + +// 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) +}) + +/** + * Update the accounts owning nodes or have nodes shared to them + * @param path The path inside the current view to load for accounts + */ +async function updateAvailableAccounts(path: string = '/') { + availableAccounts.value = [] + if (!currentView.value) { + return + } + + const { contents } = await currentView.value.getContents(path) + const available = new Map<string, IUserSelectData>() + for (const node of contents) { + const owner = node.owner ?? node.attributes['owner-id'] + if (owner && !available.has(owner)) { + available.set(owner, { + id: owner, + user: owner, + displayName: node.attributes['owner-display-name'] ?? node.owner, + }) + } + + const sharees = node.attributes.sharees?.sharee + if (sharees) { + // ensure sharees is an array (if only one share then it is just an object) + for (const sharee of [sharees].flat()) { + // Skip link shares and other without user + if (sharee.id === '') { + continue + } + // Add if not already added + if (!available.has(sharee.id)) { + available.set(sharee.id, { + id: sharee.id, + user: sharee.id, + displayName: sharee['display-name'], + }) + } + } + } + } + availableAccounts.value = [...available.values()] +} + +/** + * Reset this filter + */ +function resetFilter() { + selectedAccounts.value = [] +} +defineExpose({ resetFilter }) + +// When the current view changes or the current directory, +// then we need to rebuild the available accounts +watchEffect(() => { + if (currentView.value) { + // we have no access to the files router here... + const path = (currentLocation.value.search ?? '?dir=/').match(/(?<=&|\?)dir=([^&#]+)/)?.[1] + selectedAccounts.value = [] + updateAvailableAccounts(decodeURIComponent(path ?? '/')) + } +}) +</script> + +<style scoped lang="scss"> +.file-list-filter-accounts { + max-width: 300px; +} +</style> diff --git a/apps/files_sharing/src/filters/AccountFilter.ts b/apps/files_sharing/src/filters/AccountFilter.ts new file mode 100644 index 00000000000..408e455e17f --- /dev/null +++ b/apps/files_sharing/src/filters/AccountFilter.ts @@ -0,0 +1,79 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { INode } from '@nextcloud/files' + +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import Vue from 'vue' +import FileListFilterAccount from '../components/FileListFilterAccount.vue' + +export interface IAccountData { + uid: string + displayName: string +} + +/** + * File list filter to filter by owner / sharee + */ +class AccountFilter extends FileListFilter { + + private currentInstance?: Vue + private filterAccounts?: IAccountData[] + + constructor() { + super('files_sharing:account', 100) + } + + public mount(el: HTMLElement) { + if (this.currentInstance) { + this.currentInstance.$destroy() + } + + const View = Vue.extend(FileListFilterAccount as never) + this.currentInstance = new View({ + el, + }) + .$on('update:accounts', this.setAccounts.bind(this)) + .$mount() + } + + public filter(nodes: INode[]): INode[] { + if (!this.filterAccounts || this.filterAccounts.length === 0) { + return nodes + } + + 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) => { + const sharees = node.attributes.sharees?.sharee as { id: string }[] | undefined + // If the node provides no information lets keep it + if (!node.owner && !sharees) { + return true + } + // if the owner matches + if (node.owner && userIds.includes(node.owner)) { + return true + } + // Or any of the sharees (if only one share this will be an object, otherwise an array. So using `.flat()` to make it always an array) + if (sharees && [sharees].flat().some(({ id }) => userIds.includes(id))) { + return true + } + // Not a valid node for the current filter + return false + }) + } + + public setAccounts(accounts?: IAccountData[]) { + this.filterAccounts = accounts + this.filterUpdated() + } + +} + +/** + * Register the file list filter by owner or sharees + */ +export function registerAccountFilter() { + registerFileListFilter(new AccountFilter()) +} diff --git a/apps/files_sharing/src/init.ts b/apps/files_sharing/src/init.ts index 4652026861d..26e664324a8 100644 --- a/apps/files_sharing/src/init.ts +++ b/apps/files_sharing/src/init.ts @@ -11,11 +11,15 @@ import './actions/openInFilesAction' import './actions/rejectShareAction' import './actions/restoreShareAction' import './actions/sharingStatusAction' +import { registerAccountFilter } from './filters/AccountFilter' registerSharingViews() addNewFileMenuEntry(newFileRequest) +registerDavProperty('nc:sharees', { nc: 'http://nextcloud.org/ns' }) registerDavProperty('nc:share-attributes', { nc: 'http://nextcloud.org/ns' }) registerDavProperty('oc:share-types', { oc: 'http://owncloud.org/ns' }) registerDavProperty('ocs:share-permissions', { ocs: 'http://open-collaboration-services.org/ns' }) + +registerAccountFilter() diff --git a/apps/files_sharing/src/utils/AccountIcon.ts b/apps/files_sharing/src/utils/AccountIcon.ts new file mode 100644 index 00000000000..ac126fb1b35 --- /dev/null +++ b/apps/files_sharing/src/utils/AccountIcon.ts @@ -0,0 +1,17 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { generateUrl } from '@nextcloud/router' + +const isDarkMode = window?.matchMedia?.('(prefers-color-scheme: dark)')?.matches === true + || document.querySelector('[data-themes*=dark]') !== null + +export const generateAvatarSvg = (userId: string, isGuest = false) => { + const url = isDarkMode ? '/avatar/{userId}/32/dark' : '/avatar/{userId}/32' + const avatarUrl = generateUrl(isGuest ? url : url + '?guestFallback=true', { userId }) + return `<svg width="32" height="32" viewBox="0 0 32 32" + xmlns="http://www.w3.org/2000/svg" class="sharing-status__avatar"> + <image href="${avatarUrl}" height="32" width="32" /> + </svg>` +} |