Skip to content

Commit 22f16c3

Browse files
authored
Merge pull request #2033 from nextcloud-libraries/artonge/feat/add_canpick_option
feat(FilePicker): Improve pick and navigation logic to match user expectation
2 parents 682071a + c6dd72c commit 22f16c3

File tree

9 files changed

+232
-89
lines changed

9 files changed

+232
-89
lines changed

lib/components/FilePicker/FileList.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
:key="file.fileid || file.path"
6868
:allow-pick-directory="allowPickDirectory"
6969
:show-checkbox="multiselect"
70-
:can-pick="multiselect || selectedFiles.length === 0 || selectedFiles.includes(file)"
70+
:can-pick="(multiselect || selectedFiles.length === 0 || selectedFiles.includes(file)) && (canPick === undefined || canPick(file))"
7171
:selected="selectedFiles.includes(file)"
7272
:node="file"
7373
:crop-image-previews="cropImagePreviews"
@@ -82,6 +82,7 @@
8282
<script setup lang="ts">
8383
import type { FilesSortingMode, INode } from '@nextcloud/files'
8484
import type { FileListViews } from '../../composables/filesSettings.ts'
85+
import type { IFilePickerCanPick } from '../types.ts'
8586
8687
import { FileType, sortNodes } from '@nextcloud/files'
8788
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
@@ -125,6 +126,10 @@ const props = defineProps<{
125126
* Files to show
126127
*/
127128
files: INode[]
129+
/**
130+
* Custom function to decide if a node can be picked
131+
*/
132+
canPick?: IFilePickerCanPick
128133
}>()
129134
130135
/// sorting related stuff

lib/components/FilePicker/FileListRow.spec.ts

Lines changed: 154 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,109 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import { File } from '@nextcloud/files'
6+
import type { VueWrapper } from '@vue/test-utils'
7+
import type { ComponentProps } from 'vue-component-type-helpers'
8+
9+
import { File, Folder, Permission } from '@nextcloud/files'
710
import { shallowMount } from '@vue/test-utils'
811
import { afterEach, describe, expect, it, vi } from 'vitest'
912
import { nextTick } from 'vue'
1013
import FileListRow from './FileListRow.vue'
1114

12-
describe('FilePicker: FileListRow', () => {
13-
const node = new File({
14-
owner: null,
15-
mtime: new Date(),
16-
mime: 'text/plain',
17-
source: 'https://example.com/dav/a.txt',
18-
root: '/',
19-
attributes: { displayName: 'test' },
15+
type SubmitAction = (wrapper: VueWrapper<any>) => Promise<void>
16+
type ElementEvent = { 'update:selected': boolean | undefined, enterDirectory: Folder | undefined }
17+
18+
async function clickCheckboxAction(wrapper: VueWrapper<any>) {
19+
wrapper.find('input[type="checkbox"]').trigger('click')
20+
}
21+
22+
async function clickElementAction(wrapper: VueWrapper<any>) {
23+
wrapper.find('[data-testid="row-name"]').trigger('click')
24+
}
25+
26+
async function pressEnterAction(wrapper: VueWrapper<any>) {
27+
wrapper.element.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
28+
await nextTick()
29+
}
30+
31+
function testSubmitNode(name: string, props: ComponentProps<typeof FileListRow>, eventPayload: ElementEvent, actionCallback: SubmitAction) {
32+
it(name, async () => {
33+
const wrapper = shallowMount(FileListRow, {
34+
props,
35+
global: {
36+
stubs: {
37+
NcCheckboxRadioSwitch: {
38+
template: '<label><input type="checkbox" @click="$emit(\'update:model-value\', true)" ></label>',
39+
},
40+
},
41+
},
42+
})
43+
44+
await actionCallback(wrapper)
45+
46+
for (const [event, payload] of Object.entries(eventPayload)) {
47+
if (payload === undefined) {
48+
expect(wrapper.emitted(event)).toBeUndefined()
49+
} else {
50+
expect(wrapper.emitted(event)).toEqual([[payload]])
51+
}
52+
}
2053
})
54+
}
55+
56+
const node = new File({
57+
owner: 'alice',
58+
mtime: new Date(),
59+
mime: 'text/plain',
60+
source: 'https://example.com/remote.php/dav/alice/a.txt',
61+
root: '/',
62+
attributes: { displayName: 'test' },
63+
})
2164

65+
const folder = new Folder({
66+
owner: 'alice',
67+
mtime: new Date(),
68+
mime: 'httpd/unix-directory',
69+
source: 'https://example.com/remote.php/dav/alice/b',
70+
root: '/',
71+
permissions: Permission.ALL,
72+
attributes: { displayName: 'test folder' },
73+
})
74+
75+
const folderNonReadable = new Folder({
76+
owner: 'alice',
77+
mtime: new Date(),
78+
mime: 'httpd/unix-directory',
79+
source: 'https://example.com/remote.php/dav/alice/b',
80+
root: '/',
81+
permissions: Permission.ALL & ~Permission.READ,
82+
attributes: { displayName: 'test folder' },
83+
})
84+
85+
const defaultOptions = {
86+
selected: false,
87+
cropImagePreviews: true,
88+
canPick: true,
89+
showCheckbox: true,
90+
allowPickDirectory: true,
91+
}
92+
93+
const noEmits = {
94+
'update:selected': undefined,
95+
enterDirectory: undefined,
96+
}
97+
98+
const selectNode = {
99+
'update:selected': true,
100+
enterDirectory: undefined,
101+
}
102+
103+
const navigateToFolder = {
104+
'update:selected': undefined,
105+
enterDirectory: folder,
106+
}
107+
108+
describe('FilePicker: FileListRow', () => {
22109
afterEach(() => {
23110
vi.restoreAllMocks()
24111
})
@@ -64,82 +151,73 @@ describe('FilePicker: FileListRow', () => {
64151
expect(wrapper.find('[data-testid="row-checkbox"]').exists()).toBe(false)
65152
})
66153

67-
it('Click checkbox triggers select', async () => {
68-
const wrapper = shallowMount(FileListRow, {
69-
props: {
70-
allowPickDirectory: false,
71-
selected: false,
72-
showCheckbox: true,
73-
canPick: true,
74-
node,
75-
cropImagePreviews: true,
76-
},
77-
global: {
78-
stubs: {
79-
NcCheckboxRadioSwitch: {
80-
template: '<label><input type="checkbox" @click="$emit(\'update:model-value\', true)" ></label>',
81-
},
82-
},
83-
},
154+
describe('when node is a file', () => {
155+
const fileOptions = {
156+
...defaultOptions,
157+
node,
158+
}
159+
160+
testSubmitNode('Click checkbox triggers select', { ...fileOptions }, selectNode, clickCheckboxAction)
161+
testSubmitNode('Click element triggers select', { ...fileOptions }, selectNode, clickElementAction)
162+
testSubmitNode('Click element without checkbox triggers select', { ...fileOptions, showCheckbox: false }, selectNode, clickElementAction)
163+
testSubmitNode('Enter triggers select', { ...fileOptions, showCheckbox: false }, selectNode, pressEnterAction)
164+
165+
describe('canPick: false', () => {
166+
const options = {
167+
...fileOptions,
168+
canPick: false,
169+
}
170+
171+
testSubmitNode('Click checkbox does not triggers select', options, noEmits, clickCheckboxAction)
172+
testSubmitNode('Click element does not triggers select', options, noEmits, clickElementAction)
173+
testSubmitNode('Click element without checkbox does not triggers select', { ...options, showCheckbox: false }, noEmits, clickElementAction)
174+
testSubmitNode('Enter does not triggers select', { ...options, showCheckbox: false }, noEmits, pressEnterAction)
84175
})
85-
86-
await wrapper.find('input[type="checkbox"]').trigger('click')
87-
88-
// one event with payload `true` is expected
89-
expect(wrapper.emitted('update:selected')).toEqual([[true]])
90176
})
91177

92-
it('Click element triggers select', async () => {
93-
const wrapper = shallowMount(FileListRow, {
94-
props: {
95-
allowPickDirectory: false,
96-
selected: false,
97-
showCheckbox: true,
98-
canPick: true,
99-
node,
100-
cropImagePreviews: true,
101-
},
178+
describe('when node is a folder', () => {
179+
const folderOptions = {
180+
...defaultOptions,
181+
node: folder,
182+
}
183+
184+
testSubmitNode('Click checkbox triggers select', folderOptions, selectNode, clickCheckboxAction)
185+
testSubmitNode('Click element navigates to it', folderOptions, navigateToFolder, clickElementAction)
186+
testSubmitNode('Click element without checkbox navigates to it', { ...folderOptions, showCheckbox: false }, navigateToFolder, clickElementAction)
187+
testSubmitNode('Enter navigates to it', { ...folderOptions, showCheckbox: false }, navigateToFolder, pressEnterAction)
188+
189+
describe('canPick: false', () => {
190+
const options = {
191+
...folderOptions,
192+
canPick: false,
193+
}
194+
195+
testSubmitNode('Click checkbox does not triggers select', options, noEmits, clickCheckboxAction)
196+
testSubmitNode('Click element navigates to it', options, navigateToFolder, clickElementAction)
197+
testSubmitNode('Click element without checkbox navigates to it', { ...options, showCheckbox: false }, navigateToFolder, clickElementAction)
198+
testSubmitNode('Enter navigates to it', { ...options, showCheckbox: false }, navigateToFolder, pressEnterAction)
102199
})
103200

104-
await wrapper.find('[data-testid="row-name"]').trigger('click')
201+
describe('without READ permissions', () => {
202+
const options = {
203+
...folderOptions,
204+
node: folderNonReadable,
205+
}
105206

106-
// one event with payload `true` is expected
107-
expect(wrapper.emitted('update:selected')).toEqual([[true]])
108-
})
109-
110-
it('Click element without checkbox triggers select', async () => {
111-
const wrapper = shallowMount(FileListRow, {
112-
props: {
113-
allowPickDirectory: false,
114-
selected: false,
115-
showCheckbox: false,
116-
canPick: true,
117-
node,
118-
cropImagePreviews: true,
119-
},
207+
testSubmitNode('Click checkbox triggers select', options, selectNode, clickCheckboxAction)
208+
testSubmitNode('Click element does not navigates to it', options, noEmits, clickElementAction)
209+
testSubmitNode('Click element without checkbox does not navigates to it', { ...options, showCheckbox: false }, noEmits, clickElementAction)
210+
testSubmitNode('Enter does not navigates to it', { ...options, showCheckbox: false }, noEmits, pressEnterAction)
120211
})
121212

122-
await wrapper.find('[data-testid="row-name"]').trigger('click')
123-
124-
// one event with payload `true` is expected
125-
expect(wrapper.emitted('update:selected')).toEqual([[true]])
126-
})
127-
128-
it('Enter triggers select', async () => {
129-
const wrapper = shallowMount(FileListRow, {
130-
props: {
213+
describe('allowPickDirectory: false', () => {
214+
const options = {
215+
...folderOptions,
216+
node: folderNonReadable,
131217
allowPickDirectory: false,
132-
selected: false,
133-
showCheckbox: false,
134-
canPick: true,
135-
node,
136-
cropImagePreviews: true,
137-
},
138-
})
218+
}
139219

140-
wrapper.element.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
141-
await nextTick()
142-
143-
expect(wrapper.emitted('update:selected')).toEqual([[true]])
220+
testSubmitNode('Click checkbox does not triggers select', options, noEmits, clickCheckboxAction)
221+
})
144222
})
145223
})

lib/components/FilePicker/FileListRow.vue

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
:class="[
1111
{
1212
'file-picker__row--selected': selected && !showCheckbox,
13+
'file-picker__row--not-navigatable': isDirectory && !isNavigatable,
14+
'file-picker__row--not-pickable': !isPickable,
1315
},
1416
]"
1517
:data-filename="node.basename"
@@ -46,7 +48,7 @@
4648
<script setup lang="ts">
4749
import type { INode } from '@nextcloud/files'
4850
49-
import { FileType, formatFileSize } from '@nextcloud/files'
51+
import { FileType, formatFileSize, Permission } from '@nextcloud/files'
5052
import { computed } from 'vue'
5153
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
5254
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
@@ -97,10 +99,19 @@ const isDirectory = computed(() => props.node.type === FileType.Folder)
9799
*/
98100
const isPickable = computed(() => props.canPick && (props.allowPickDirectory || !isDirectory.value))
99101
102+
/**
103+
* If this node is not readable, then we cannot navigate to it.
104+
*/
105+
const isNavigatable = computed(() => (props.node.permissions & Permission.READ) === Permission.READ)
106+
100107
/**
101108
* Toggle the selection state
102109
*/
103110
function toggleSelected() {
111+
if (!isPickable.value) {
112+
return
113+
}
114+
104115
emit('update:selected', !props.selected)
105116
}
106117
@@ -109,7 +120,9 @@ function toggleSelected() {
109120
*/
110121
function handleClick() {
111122
if (isDirectory.value) {
112-
emit('enterDirectory', props.node)
123+
if (isNavigatable.value) {
124+
emit('enterDirectory', props.node)
125+
}
113126
} else {
114127
toggleSelected()
115128
}
@@ -132,9 +145,27 @@ function handleKeyDown(event: KeyboardEvent) {
132145
133146
.file-picker {
134147
&__row {
148+
* {
149+
cursor: pointer;
150+
}
151+
135152
&--selected {
136153
background-color: var(--color-background-dark);
137154
}
155+
156+
&--not-navigatable {
157+
filter: saturate(0.7);
158+
opacity: 0.7;
159+
}
160+
161+
&--not-navigatable,
162+
&--not-pickable {
163+
164+
* {
165+
cursor: default !important;
166+
}
167+
}
168+
138169
&:hover {
139170
background-color: var(--color-background-hover);
140171
}

0 commit comments

Comments
 (0)