From 3a23d9acd8beb818e918359c4516489bec80f3d9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 8 Jun 2024 00:52:34 +0200 Subject: [PATCH 1/4] fix(FilePicker): Add current folder to button factory as selected if `allowPickDirectory` is true Signed-off-by: Ferdinand Thiessen --- lib/components/FilePicker/FilePicker.vue | 56 ++++++++++++------------ lib/composables/dav.ts | 19 +++++--- 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/lib/components/FilePicker/FilePicker.vue b/lib/components/FilePicker/FilePicker.vue index 86da7f62..d6b7f6e2 100644 --- a/lib/components/FilePicker/FilePicker.vue +++ b/lib/components/FilePicker/FilePicker.vue @@ -18,9 +18,10 @@
+ @create-node="onCreateFolder" + @update:path="navigatedPath = $event" />

{{ viewHeadline }}

@@ -67,7 +68,7 @@ import FilePickerNavigation from './FilePickerNavigation.vue' import { emit as emitOnEventBus } from '@nextcloud/event-bus' import { NcDialog, NcEmptyContent } from '@nextcloud/vue' -import { computed, onMounted, ref, toRef } from 'vue' +import { computed, onMounted, ref, shallowReactive, toRef, watch } from 'vue' import { showError } from '../../toast' import { useDAVFiles } from '../../composables/dav' import { useMimeFilter } from '../../composables/mime' @@ -148,8 +149,9 @@ const isOpen = ref(true) * Map buttons to Dialog buttons by wrapping the callback function to pass the selected files */ const dialogButtons = computed(() => { + const nodes = selectedFiles.length === 0 && props.allowPickDirectory && currentFolder.value ? [currentFolder.value] : selectedFiles const buttons = typeof props.buttons === 'function' - ? props.buttons(selectedFiles.value as Node[], currentPath.value, currentView.value) + ? props.buttons(nodes, currentPath.value, currentView.value) : props.buttons return buttons.map((button) => ({ @@ -157,7 +159,7 @@ const dialogButtons = computed(() => { callback: () => { // lock default close handling isHandlingCallback = true - handleButtonClick(button.callback) + handleButtonClick(button.callback, nodes) }, } as IFilePickerButton)) }) @@ -168,8 +170,7 @@ const dialogButtons = computed(() => { */ let isHandlingCallback = false -const handleButtonClick = async (callback: IFilePickerButton['callback']) => { - const nodes = selectedFiles.value.length === 0 && props.allowPickDirectory ? [await getFile(currentPath.value)] : selectedFiles.value as Node[] +const handleButtonClick = async (callback: IFilePickerButton['callback'], nodes: Node[]) => { callback(nodes) emit('close', nodes) // Unlock close @@ -189,7 +190,7 @@ const viewHeadline = computed(() => currentView.value === 'favorites' ? t('Favor /** * All currently selected files */ -const selectedFiles = ref([]) +const selectedFiles = shallowReactive([]) /** * Last path navigated to using the file picker @@ -200,28 +201,23 @@ const savedPath = ref(window?.sessionStorage.getItem('NC.FilePicker.LastPath') | /** * The path the user manually navigated to using this filepicker instance */ -const navigatedPath = ref() +const navigatedPath = ref('') +// Save the navigated path to the session storage on change +watch([navigatedPath], () => { + if (props.path === undefined && navigatedPath.value) { + window.sessionStorage.setItem('NC.FilePicker.LastPath', navigatedPath.value) + // Reset selected files + selectedFiles.splice(0, selectedFiles.length) + } +}) /** * The current path that should be picked from */ -const currentPath = computed({ +const currentPath = computed(() => // Only use the path for the files view as favorites and recent only works on the root - get: () => currentView.value === 'files' ? navigatedPath.value || props.path || savedPath.value : '/', - /** - * Navigate to the new path and save it to the session storage - * - * @param path The new path - */ - set: (path: string) => { - if (props.path === undefined) { - window.sessionStorage.setItem('NC.FilePicker.LastPath', path) - } - navigatedPath.value = path - // Reset selected files - selectedFiles.value = [] - }, -}) + currentView.value === 'files' ? navigatedPath.value || props.path || savedPath.value : '/', +) /** * A string used to filter files in current view @@ -230,7 +226,13 @@ const filterString = ref('') const { isSupportedMimeType } = useMimeFilter(toRef(props, 'mimetypeFilter')) // vue 3.3 will allow cleaner syntax of toRef(() => props.mimetypeFilter) -const { files, isLoading, loadFiles, getFile, createDirectory } = useDAVFiles(currentView, currentPath, isPublic) +const { + files, + folder: currentFolder, + isLoading, + loadFiles, + createDirectory, +} = useDAVFiles(currentView, currentPath, isPublic) onMounted(() => loadFiles()) @@ -281,7 +283,7 @@ const noFilesDescription = computed(() => { const onCreateFolder = async (name: string) => { try { const folder = await createDirectory(name) - currentPath.value = folder.path + navigatedPath.value = folder.path // emit event bus to force files app to reload that file if needed emitOnEventBus('files:node:created', files.value.filter((file) => file.basename === name)[0]) } catch (error) { diff --git a/lib/composables/dav.ts b/lib/composables/dav.ts index e1417c00..47d8095f 100644 --- a/lib/composables/dav.ts +++ b/lib/composables/dav.ts @@ -26,7 +26,7 @@ import type { FileStat, ResponseDataDetailed, SearchResult } from 'webdav' import { davGetClient, davGetDefaultPropfind, davGetRecentSearch, davRemoteURL, davResultToNode, davRootPath, getFavoriteNodes } from '@nextcloud/files' import { generateRemoteUrl } from '@nextcloud/router' import { join } from 'path' -import { computed, onMounted, ref, watch } from 'vue' +import { computed, onMounted, ref, shallowRef, watch } from 'vue' import { CancelablePromise } from 'cancelable-promise' /** @@ -39,8 +39,8 @@ import { CancelablePromise } from 'cancelable-promise' export const useDAVFiles = function( currentView: Ref<'files'|'recent'|'favorites'> | ComputedRef<'files'|'recent'|'favorites'>, currentPath: Ref | ComputedRef, - isPublicEndpoint: Ref | ComputedRef -): { isLoading: Ref, createDirectory: (name: string) => Promise, files: Ref, loadFiles: () => Promise, getFile: (path: string) => Promise } { + isPublicEndpoint: Ref | ComputedRef, +) { const defaultRootPath = computed(() => isPublicEndpoint.value ? '/' : davRootPath) @@ -114,7 +114,15 @@ export const useDAVFiles = function( /** * All files in current view and path */ - const files = ref([] as Node[]) as Ref + const files = shallowRef([] as Node[]) as Ref + + /** + * The current folder + */ + const folder = shallowRef() + watch([currentPath], async () => { + folder.value = (files.value.find(({ path }) => path === currentPath.value) ?? await getFile(currentPath.value)) as Folder + }, { immediate: true }) /** * Loading state of the files @@ -136,7 +144,7 @@ export const useDAVFiles = function( await client.value.createDirectory(join(defaultRootPath.value, path)) const directory = await getFile(path) as Folder - files.value.push(directory) + files.value = [...files.value, directory] return directory } @@ -191,6 +199,7 @@ export const useDAVFiles = function( return { isLoading, files, + folder, loadFiles: loadDAVFiles, getFile, createDirectory, From 670e4f6f891f8aef7f41d35e4b500ac7beddda2f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 8 Jun 2024 00:53:23 +0200 Subject: [PATCH 2/4] refactor(FilePicker): Correctly assert prop to be defined for TypeScript Signed-off-by: Ferdinand Thiessen --- lib/components/FilePicker/FilePicker.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/components/FilePicker/FilePicker.vue b/lib/components/FilePicker/FilePicker.vue index d6b7f6e2..18a0844b 100644 --- a/lib/components/FilePicker/FilePicker.vue +++ b/lib/components/FilePicker/FilePicker.vue @@ -256,7 +256,7 @@ const filteredFiles = computed(() => { filtered = filtered.filter((file) => file.basename.toLowerCase().includes(filterString.value.toLowerCase())) } if (props.filterFn) { - filtered = filtered.filter((f) => props.filterFn(f as Node)) + filtered = filtered.filter((f) => props.filterFn!(f as Node)) } return filtered }) From 110ddcefaf4be0260ed8b837d7fef32e760cb447 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 8 Jun 2024 00:53:52 +0200 Subject: [PATCH 3/4] fix: Add `disabled` state to button interface Signed-off-by: Ferdinand Thiessen --- lib/components/types.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/components/types.ts b/lib/components/types.ts index a46c480f..c430ce04 100644 --- a/lib/components/types.ts +++ b/lib/components/types.ts @@ -46,6 +46,12 @@ export interface IDialogButton { * @see https://nextcloud-vue-components.netlify.app/#/Components/NcButton */ type?: 'primary' | 'secondary' | 'error' | 'warning' | 'success' + + /** + * Disabled state of the button + * @default false + */ + disabled?: boolean } /** From f43e6126294d6ff2cf7a65bb7046818ce756b78d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 10 Jun 2024 12:23:43 +0200 Subject: [PATCH 4/4] tests: Adjust tests to mock implementation for multiple requests Signed-off-by: Ferdinand Thiessen --- lib/composables/dav.spec.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/composables/dav.spec.ts b/lib/composables/dav.spec.ts index ec9f9c2e..19e96c67 100644 --- a/lib/composables/dav.spec.ts +++ b/lib/composables/dav.spec.ts @@ -21,7 +21,7 @@ */ import type { Ref } from 'vue' -import { describe, it, expect, vi, afterEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { shallowMount } from '@vue/test-utils' import { defineComponent, ref, toRef, nextTick } from 'vue' import { useDAVFiles } from './dav' @@ -65,7 +65,7 @@ const TestComponent = defineComponent({ }) describe('dav composable', () => { - afterEach(() => { vi.resetAllMocks() }) + beforeEach(() => { vi.resetAllMocks() }) it('Sets the inital state correctly', () => { const client = { @@ -175,15 +175,16 @@ describe('dav composable', () => { stat: vi.fn((v) => ({ data: { path: v } })), getDirectoryContents: vi.fn(() => ({ data: [] })), } - nextcloudFiles.davGetClient.mockImplementationOnce(() => client) - nextcloudFiles.davResultToNode.mockImplementationOnce((v) => v) + nextcloudFiles.davGetClient.mockImplementation(() => client) + nextcloudFiles.davResultToNode.mockImplementation((v) => v) const { getFile } = useDAVFiles(ref('files'), ref('/'), ref(false)) - const node = await getFile('/some/path') - expect(node).toEqual({ path: `${nextcloudFiles.davRootPath}/some/path` }) - expect(client.stat).toBeCalledWith(`${nextcloudFiles.davRootPath}/some/path`, { details: true }) - expect(nextcloudFiles.davResultToNode).toBeCalledWith({ path: `${nextcloudFiles.davRootPath}/some/path` }, nextcloudFiles.davRootPath, nextcloudFiles.davRemoteURL) + const node = await getFile('/some/path/file.ext') + expect(node).toEqual({ path: `${nextcloudFiles.davRootPath}/some/path/file.ext` }) + // Check mock usage + expect(client.stat).toBeCalledWith(`${nextcloudFiles.davRootPath}/some/path/file.ext`, { details: true }) + expect(nextcloudFiles.davResultToNode).toBeCalledWith({ path: `${nextcloudFiles.davRootPath}/some/path/file.ext` }, nextcloudFiles.davRootPath, nextcloudFiles.davRemoteURL) }) it('createDirectory works', async () => { @@ -191,8 +192,8 @@ describe('dav composable', () => { stat: vi.fn((v) => ({ data: { path: v } })), createDirectory: vi.fn(() => {}), } - nextcloudFiles.davGetClient.mockImplementationOnce(() => client) - nextcloudFiles.davResultToNode.mockImplementationOnce((v) => v) + nextcloudFiles.davGetClient.mockImplementation(() => client) + nextcloudFiles.davResultToNode.mockImplementation((v) => v) const { createDirectory } = useDAVFiles(ref('files'), ref('/foo/'), ref(false))