aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2024-07-25 20:12:52 +0200
committerGitHub <noreply@github.com>2024-07-25 20:12:52 +0200
commit97ea95714aeec2d23567ae5d85b28fd6b688b1cc (patch)
treef9c7ca7ff9e6e64487994fc3461830b1b289a220 /apps
parent4f2a29adf95c57bef5d01f27c8b741a9840e82b3 (diff)
parent66f77b562caa7396f1d03509a56af4bea0b3ce89 (diff)
downloadnextcloud-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')
-rw-r--r--apps/files/src/components/FileEntry.vue9
-rw-r--r--apps/files/src/components/FileEntryGrid.vue9
-rw-r--r--apps/files/src/components/FileEntryMixin.ts9
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilter.vue53
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterModified.vue107
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterType.vue110
-rw-r--r--apps/files/src/components/FileListFilters.vue66
-rw-r--r--apps/files/src/components/FilesListVirtual.vue45
-rw-r--r--apps/files/src/components/NavigationQuota.vue3
-rw-r--r--apps/files/src/components/VirtualList.vue4
-rw-r--r--apps/files/src/composables/useFilenameFilter.ts47
-rw-r--r--apps/files/src/composables/useRouteParameters.ts47
-rw-r--r--apps/files/src/eventbus.d.ts7
-rw-r--r--apps/files/src/filters/FilenameFilter.ts53
-rw-r--r--apps/files/src/filters/HiddenFilesFilter.ts42
-rw-r--r--apps/files/src/filters/ModifiedFilter.ts112
-rw-r--r--apps/files/src/filters/TypeFilter.ts169
-rw-r--r--apps/files/src/init.ts10
-rw-r--r--apps/files/src/main.ts2
-rw-r--r--apps/files/src/store/filters.ts78
-rw-r--r--apps/files/src/views/FilesList.vue142
-rw-r--r--apps/files/src/views/Navigation.vue83
-rw-r--r--apps/files_sharing/src/actions/sharingStatusAction.ts28
-rw-r--r--apps/files_sharing/src/components/FileListFilterAccount.vue116
-rw-r--r--apps/files_sharing/src/filters/AccountFilter.ts79
-rw-r--r--apps/files_sharing/src/init.ts4
-rw-r--r--apps/files_sharing/src/utils/AccountIcon.ts17
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>`
+}