Skip to content

Commit

Permalink
feat: Get files app config and use it for the file picker
Browse files Browse the repository at this point in the history
* Sort favorties first if configured
* Use sorting order like the files app does
* Show hidden files if configured
* Crop file previews if configured

Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Oct 6, 2023
1 parent 4b55e9f commit e1b23dd
Show file tree
Hide file tree
Showing 10 changed files with 391 additions and 68 deletions.
26 changes: 16 additions & 10 deletions lib/components/FilePicker/FileList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,10 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'
import FileList from './FileList.vue'
import { File, Folder } from '@nextcloud/files'

// mock OC.MimeType
window.OC = {
MimeType: {
getIconUrl: (mime: string) => `icon/${mime}`,
},
} as never
const axios = vi.hoisted(() => ({
get: vi.fn(() => new Promise(() => {})),
}))
vi.mock('axios', () => axios)

const exampleNodes = [
new File({
Expand Down Expand Up @@ -81,6 +79,7 @@ describe('FilePicker FileList', () => {

const wrapper = mount(FileList, {
propsData: {
currentView: 'files',
multiselect: false,
allowPickDirectory: false,
loading: false,
Expand All @@ -98,6 +97,7 @@ describe('FilePicker FileList', () => {
it('header checkbox is not shown if multiselect is `false`', () => {
const wrapper = mount(FileList, {
propsData: {
currentView: 'files',
multiselect: false,
allowPickDirectory: false,
loading: false,
Expand All @@ -112,6 +112,7 @@ describe('FilePicker FileList', () => {
it('header checkbox is shown if multiselect is `true`', () => {
const wrapper = mount(FileList, {
propsData: {
currentView: 'files',
multiselect: true,
allowPickDirectory: false,
loading: false,
Expand All @@ -132,6 +133,7 @@ describe('FilePicker FileList', () => {
const nodes = [...exampleNodes]
const wrapper = mount(FileList, {
propsData: {
currentView: 'files',
multiselect: true,
allowPickDirectory: false,
loading: false,
Expand All @@ -150,6 +152,7 @@ describe('FilePicker FileList', () => {
const nodes = [...exampleNodes]
const wrapper = mount(FileList, {
propsData: {
currentView: 'files',
multiselect: true,
allowPickDirectory: false,
loading: false,
Expand All @@ -164,16 +167,18 @@ describe('FilePicker FileList', () => {
expect(rows.length).toBe(nodes.length)
// folder are sorted first
expect(rows.at(0).attributes('data-filename')).toBe('directory')
// by default favorites are sorted before other files
expect(rows.at(1).attributes('data-filename')).toBe('favorite.txt')
// other files are ascending
expect(rows.at(1).attributes('data-filename')).toBe('a-file.txt')
expect(rows.at(2).attributes('data-filename')).toBe('b-file.txt')
expect(rows.at(3).attributes('data-filename')).toBe('favorite.txt')
expect(rows.at(2).attributes('data-filename')).toBe('a-file.txt')
expect(rows.at(3).attributes('data-filename')).toBe('b-file.txt')
})

it('can sort descending by name', async () => {
const nodes = [...exampleNodes]
const wrapper = mount(FileList, {
propsData: {
currentView: 'files',
multiselect: true,
allowPickDirectory: false,
loading: false,
Expand All @@ -190,8 +195,9 @@ describe('FilePicker FileList', () => {
expect(rows.length).toBe(nodes.length)
// folder are sorted first
expect(rows.at(0).attributes('data-filename')).toBe('directory')
// other files are descending
// by default favorites are sorted before other files
expect(rows.at(1).attributes('data-filename')).toBe('favorite.txt')
// other files are descending
expect(rows.at(2).attributes('data-filename')).toBe('b-file.txt')
expect(rows.at(3).attributes('data-filename')).toBe('a-file.txt')
})
Expand Down
116 changes: 70 additions & 46 deletions lib/components/FilePicker/FileList.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<template>
<div class="file-picker__files" ref="fileContainer">
<div ref="fileContainer" class="file-picker__files">
<table>
<thead>
<tr>
<th class="row-checkbox" v-if="multiselect">
<th v-if="multiselect" class="row-checkbox">
<span class="hidden-visually">
{{ t('Select entry') }}
</span>
Expand All @@ -16,11 +16,10 @@
<th :aria-sort="sortByName" class="row-name">
<div class="header-wrapper">
<span class="file-picker__header-preview" />
<NcButton
:wide="true"
<NcButton :wide="true"
type="tertiary"
data-test="file-picker_sort-name"
@click="toggleSortByName">
@click="toggleSorting('basename')">
<template #icon>
<IconSortAscending v-if="sortByName === 'ascending'" :size="20" />
<IconSortDescending v-else-if="sortByName === 'descending'" :size="20" />
Expand All @@ -31,7 +30,7 @@
</div>
</th>
<th :aria-sort="sortBySize" class="row-size">
<NcButton :wide="true" type="tertiary" @click="toggleSortBySize">
<NcButton :wide="true" type="tertiary" @click="toggleSorting('size')">
<template #icon>
<IconSortAscending v-if="sortBySize === 'ascending'" :size="20" />
<IconSortDescending v-else-if="sortBySize === 'descending'" :size="20" />
Expand All @@ -41,7 +40,7 @@
</NcButton>
</th>
<th :aria-sort="sortByModified" class="row-modified">
<NcButton :wide="true" type="tertiary" @click="toggleSortByModified">
<NcButton :wide="true" type="tertiary" @click="toggleSorting('mtime')">
<template #icon>
<IconSortAscending v-if="sortByModified === 'ascending'" :size="20" />
<IconSortDescending v-else-if="sortByModified === 'descending'" :size="20" />
Expand All @@ -54,7 +53,7 @@
</thead>
<tbody>
<template v-if="loading">
<LoadingTableRow v-for="index in skeletonNumber" :key="index" :show-checkbox="multiselect"/>
<LoadingTableRow v-for="index in skeletonNumber" :key="index" :show-checkbox="multiselect" />
</template>
<template v-else>
<FileListRow v-for="file in sortedFiles"
Expand All @@ -73,20 +72,24 @@
</template>

<script setup lang="ts">
import { FileType, type Node } from '@nextcloud/files'
import type { Node } from '@nextcloud/files'
import type { FileListViews } from '../../composables/filesSettings'
import { FileType } from '@nextcloud/files'
import { getCanonicalLocale } from '@nextcloud/l10n'
import { NcButton, NcCheckboxRadioSwitch } from '@nextcloud/vue'
import { join } from 'path'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useFilesSettings, useFilesViews } from '../../composables/filesSettings'
import { t } from '../../utils/l10n'
import { computed, nextTick, onMounted, onUnmounted, ref, type Ref } from 'vue'
import IconSortAscending from 'vue-material-design-icons/MenuDown.vue'
import IconSortDescending from 'vue-material-design-icons/MenuUp.vue'
import IconSortAscending from 'vue-material-design-icons/MenuUp.vue'
import IconSortDescending from 'vue-material-design-icons/MenuDown.vue'
import LoadingTableRow from './LoadingTableRow.vue'
import FileListRow from './FileListRow.vue'
const props = defineProps<{
currentView: FileListViews,
multiselect: boolean
allowPickDirectory: boolean
loading: boolean
Expand All @@ -100,52 +103,65 @@ const emit = defineEmits<{
(e: 'update:selectedFiles', nodes: Node[]): void
}>()
type ISortingOptions = 'ascending' | 'descending' | undefined
/// sorting related stuff
const sortByName = ref<ISortingOptions>('ascending')
const sortBySize = ref<ISortingOptions>(undefined)
const sortByModified = ref<ISortingOptions>(undefined)
type ISortingAttributes = 'basename' | 'size' | 'mtime'
type ISortingOrder = 'ascending' | 'descending' | 'none'
const ordering = {
ascending: <T>(a: T, b: T, fn: (a: T, b: T) => number) => fn(a, b),
descending: <T>(a: T, b: T, fn: (a: T, b: T) => number) => fn(b, a),
none: <T>(a: T, b: T, fn: (a: T, b: T) => number) => 0,
}
const byName = (a: Node, b: Node) => (a.attributes?.displayName || a.basename).localeCompare(b.attributes?.displayName || b.basename, getCanonicalLocale())
const bySize = (a: Node, b: Node) => (b.size || 0) - (a.size || 0)
const byDate = (a: Node, b: Node) => (a.mtime?.getTime() || 0) - (b.mtime?.getTime() || 0)
/** Override files app sorting */
const customSortingConfig = ref<{ sortBy: ISortingAttributes, order: ISortingOrder }>()
/** The current sorting of the files app */
const { currentConfig: filesAppSorting } = useFilesViews(props.currentView)
/** Wrapper that uses custom sorting, but fallsback to the files app */
const sortingConfig = computed(() => customSortingConfig.value ?? filesAppSorting.value)
const toggleSorting = (variable: Ref<ISortingOptions>) => {
const old = variable.value
// reset
sortByModified.value = sortBySize.value = sortByName.value = undefined
// Some helpers for the template
const sortByName = computed(() => sortingConfig.value.sortBy === 'basename' ? (sortingConfig.value.order === 'none' ? undefined : sortingConfig.value.order) : undefined)
const sortBySize = computed(() => sortingConfig.value.sortBy === 'size' ? (sortingConfig.value.order === 'none' ? undefined : sortingConfig.value.order) : undefined)
const sortByModified = computed(() => sortingConfig.value.sortBy === 'mtime' ? (sortingConfig.value.order === 'none' ? undefined : sortingConfig.value.order) : undefined)
if (old === 'ascending') {
variable.value = 'descending'
const toggleSorting = (sortBy: ISortingAttributes) => {
if (sortingConfig.value.sortBy === sortBy) {
if (sortingConfig.value.order === 'ascending') {
customSortingConfig.value = { sortBy: sortingConfig.value.sortBy, order: 'descending' }
} else {
customSortingConfig.value = { sortBy: sortingConfig.value.sortBy, order: 'ascending' }
}
} else {
variable.value = 'ascending'
customSortingConfig.value = { sortBy, order: 'ascending' }
}
}
const toggleSortByName = () => toggleSorting(sortByName)
const toggleSortBySize = () => toggleSorting(sortBySize)
const toggleSortByModified = () => toggleSorting(sortByModified)
const { sortFavoritesFirst } = useFilesSettings()
/**
* Files sorted by columns
*/
const sortedFiles = computed(() => [...props.files].sort(
const sortedFiles = computed(() => {
const ordering = {
ascending: <T, >(a: T, b: T, fn: (a: T, b: T) => number) => fn(a, b),
descending: <T, >(a: T, b: T, fn: (a: T, b: T) => number) => fn(b, a),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
none: <T, >(_a: T, _b: T, _fn: (a: T, b: T) => number) => 0,
}
const sorting = {
basename: (a: Node, b: Node) => (a.attributes?.displayName || a.basename).localeCompare(b.attributes?.displayName || b.basename, getCanonicalLocale()),
size: (a: Node, b: Node) => (a.size || 0) - (b.size || 0),
// reverted because "young" is smaller than "old"
mtime: (a: Node, b: Node) => (b.mtime?.getTime?.() || 0) - (a.mtime?.getTime?.() || 0),
}
return [...props.files].sort(
(a, b) =>
// Folders always come above the files
(b.type === FileType.Folder ? 1 : 0) - (a.type === FileType.Folder ? 1 : 0) ||
// Favorites above other files
// (b.attributes?.favorite || false) - (a.attributes?.favorite || false) ||
// then sort by name / size / modified
ordering[sortByName.value || 'none'](a, b, byName) ||
ordering[sortBySize.value || 'none'](a, b, bySize) ||
ordering[sortByModified.value || 'none'](a, b, byDate)
// Folders always come above the files
(b.type === FileType.Folder ? 1 : 0) - (a.type === FileType.Folder ? 1 : 0)
// Favorites above other files
|| (sortFavoritesFirst ? ((b.attributes.favorite ? 1 : 0) - (a.attributes.favorite ? 1 : 0)) : 0)
// then sort by name / size / modified
|| ordering[sortingConfig.value.order](a, b, sorting[sortingConfig.value.sortBy]),
)
},
)
/**
Expand All @@ -171,6 +187,10 @@ function onSelectAll() {
}
}
/**
* Handle selecting a node on the files list
* @param file the selected node
*/
function onNodeSelected(file: Node) {
if (props.selectedFiles.includes(file)) {
emit('update:selectedFiles', props.selectedFiles.filter((f) => f.path !== file.path))
Expand All @@ -184,6 +204,10 @@ function onNodeSelected(file: Node) {
}
}
/**
* Emit the new current path
* @param dir The directory that is entered
*/
function onChangeDirectory(dir: Node) {
emit('update:path', join(props.path, dir.basename))
}
Expand All @@ -197,7 +221,7 @@ const fileContainer = ref<HTMLDivElement>()
const resize = () => nextTick(() => {
const nodes = fileContainer.value?.parentElement?.children || []
let height = fileContainer.value?.parentElement?.clientHeight || 450
for(let index = 0; index < nodes.length; index++) {
for (let index = 0; index < nodes.length; index++) {
if (!fileContainer.value?.isSameNode(nodes[index])) {
height -= nodes[index].clientHeight
}
Expand Down Expand Up @@ -276,7 +300,7 @@ const fileContainer = ref<HTMLDivElement>()
}
th :deep(.button-vue__wrapper) {
color: var(--color-text-maxcontrast);
.button-vue__text {
font-weight: normal;
}
Expand Down
7 changes: 0 additions & 7 deletions lib/components/FilePicker/FileListRow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,6 @@ import { File } from '@nextcloud/files'

import FileListRow from './FileListRow.vue'

// Mock OC.MimeType
window.OC = {
MimeType: {
getIconUrl: (mime: string) => `/icon/${mime}`,
},
} as never

describe('FilePicker: FileListRow', () => {
const node = new File({
owner: null,
Expand Down
8 changes: 8 additions & 0 deletions lib/components/FilePicker/FilePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<!-- If loading or files found show file list, otherwise show empty content-->
<FileList v-if="isLoading || filteredFiles.length > 0"
:allow-pick-directory="allowPickDirectory"
:current-view="currentView"
:files="filteredFiles"
:multiselect="multiselect"
:loading="isLoading"
Expand Down Expand Up @@ -61,6 +62,7 @@ import { showError } from '../../toast'
import { useDAVFiles } from '../../composables/dav'
import { useMimeFilter } from '../../composables/mime'
import { t } from '../../utils/l10n'
import { useFilesSettings } from '../../composables/filesSettings'
const props = withDefaults(defineProps<{
/** Buttons to be displayed */
Expand Down Expand Up @@ -207,12 +209,18 @@ const { files, isLoading, loadFiles, getFile, client } = useDAVFiles(currentView
onMounted(() => loadFiles())
const { showHiddenFiles } = useFilesSettings()
/**
* The files list filtered by the current value of the filter input
*/
const filteredFiles = computed(() => {
let filtered = files.value
if (!showHiddenFiles.value) {
// Hide hidden files if not configured otherwise
filtered = filtered.filter((file) => !file.basename.startsWith('.'))
}
if (props.mimetypeFilter.length > 0) {
// filter by mime type but always include folders to navigate
filtered = filtered.filter(file => file.type === 'folder' || (file.mime && isSupportedMimeType(file.mime)))
Expand Down
8 changes: 5 additions & 3 deletions lib/components/FilePicker/FilePreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@

<script setup lang="ts">
import { FileType, type Node } from '@nextcloud/files'
import { usePreviewURL } from '../../usables/preview'
import { computed, ref, toRef, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { getPreviewURL } from '../../composables/preview'
import { useFilesSettings } from '../../composables/filesSettings'
import { t } from '../../utils/l10n'
import IconFile from 'vue-material-design-icons/File.vue'
Expand All @@ -27,7 +28,8 @@ const props = defineProps<{
node: Node
}>()
const { previewURL } = usePreviewURL(toRef(props, 'node'))
const { cropImagePreviews } = useFilesSettings()
const previewURL = computed(() => getPreviewURL(props.node, { cropPreview: cropImagePreviews.value }))
const isFile = computed(() => props.node.type === FileType.File)
const canLoadPreview = ref(false)
Expand Down
Loading

0 comments on commit e1b23dd

Please sign in to comment.