Merge pull request #56743 from nextcloud/chore/files-4-0-0

This commit is contained in:
John Molakvoæ 2025-12-11 17:36:51 +01:00 committed by GitHub
commit 52e3762045
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
437 changed files with 3484 additions and 1873 deletions

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { Folder, View } from '@nextcloud/files'
import { File, FileAction, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
@ -26,15 +26,41 @@ describe('Inline unread comments action display name tests', () => {
attributes: {
'comments-unread': 1,
},
root: '/files/admin',
})
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('comments-unread')
expect(action.displayName([file], view)).toBe('')
expect(action.title!([file], view)).toBe('1 new comment')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.enabled!([file], view)).toBe(true)
expect(action.inline!(file, view)).toBe(true)
expect(action.displayName({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('')
expect(action.title!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('1 new comment')
expect(action.iconSvgInline({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
expect(action.inline!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
expect(action.default).toBeUndefined()
expect(action.order).toBe(-140)
})
@ -49,10 +75,21 @@ describe('Inline unread comments action display name tests', () => {
attributes: {
'comments-unread': 2,
},
root: '/files/admin',
})
expect(action.displayName([file], view)).toBe('')
expect(action.title!([file], view)).toBe('2 new comments')
expect(action.displayName({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('')
expect(action.title!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('2 new comments')
})
})
@ -64,10 +101,16 @@ describe('Inline unread comments action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
attributes: { },
attributes: {},
root: '/files/admin',
})
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Action is disabled when file does not have unread comments', () => {
@ -80,9 +123,15 @@ describe('Inline unread comments action enabled tests', () => {
attributes: {
'comments-unread': 0,
},
root: '/files/admin',
})
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Action is enabled when file has a single unread comment', () => {
@ -95,9 +144,15 @@ describe('Inline unread comments action enabled tests', () => {
attributes: {
'comments-unread': 1,
},
root: '/files/admin',
})
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Action is enabled when file has a two unread comments', () => {
@ -110,9 +165,15 @@ describe('Inline unread comments action enabled tests', () => {
attributes: {
'comments-unread': 2,
},
root: '/files/admin',
})
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
})
@ -139,9 +200,15 @@ describe('Inline unread comments action execute tests', () => {
attributes: {
'comments-unread': 1,
},
root: '/files/admin',
})
const result = await action.exec!(file, view, '/')
const result = await action.exec!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(result).toBe(null)
expect(setActiveTabMock).toBeCalledWith('comments')
@ -173,9 +240,15 @@ describe('Inline unread comments action execute tests', () => {
attributes: {
'comments-unread': 1,
},
root: '/files/admin',
})
const result = await action.exec!(file, view, '/')
const result = await action.exec!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(result).toBe(false)
expect(setActiveTabMock).toBeCalledWith('comments')

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import CommentProcessingSvg from '@mdi/svg/svg/comment-processing.svg?raw'
import { FileAction } from '@nextcloud/files'
import { n, t } from '@nextcloud/l10n'
@ -13,9 +10,9 @@ import logger from '../logger.js'
export const action = new FileAction({
id: 'comments-unread',
title(nodes: Node[]) {
const unread = nodes[0].attributes['comments-unread'] as number
if (unread >= 0) {
title({ nodes }) {
const unread = nodes[0]?.attributes['comments-unread'] as number | undefined
if (typeof unread === 'number' && unread >= 0) {
return n('comments', '1 new comment', '{unread} new comments', unread, { unread })
}
return t('comments', 'Comment')
@ -26,15 +23,19 @@ export const action = new FileAction({
iconSvgInline: () => CommentProcessingSvg,
enabled(nodes: Node[]) {
const unread = nodes[0].attributes['comments-unread'] as number | undefined
enabled({ nodes }) {
const unread = nodes[0]?.attributes?.['comments-unread'] as number | undefined
return typeof unread === 'number' && unread > 0
},
async exec(node: Node) {
async exec({ nodes }) {
if (nodes.length !== 1 || !nodes[0]) {
return false
}
try {
window.OCA.Files.Sidebar.setActiveTab('comments')
await window.OCA.Files.Sidebar.open(node.path)
await window.OCA.Files.Sidebar.open(nodes[0].path)
return null
} catch (error) {
logger.error('Error while opening sidebar', { error })

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import AutoRenewSvg from '@mdi/svg/svg/autorenew.svg?raw'
import { getCapabilities } from '@nextcloud/capabilities'
import { FileAction, registerFileAction } from '@nextcloud/files'
@ -31,20 +28,24 @@ export function registerConvertActions() {
id: `convert-${from}-${to}`,
displayName: () => t('files', 'Save as {displayName}', { displayName }),
iconSvgInline: () => generateIconSvg(to),
enabled: (nodes: Node[]) => {
enabled: ({ nodes }) => {
// Check that all nodes have the same mime type
return nodes.every((node) => from === node.mime)
},
async exec(node: Node) {
async exec({ nodes }) {
if (!nodes[0]) {
return false
}
// If we're here, we know that the node has a fileid
convertFile(node.fileid as number, to)
convertFile(nodes[0].fileid as number, to)
// Silently terminate, we'll handle the UI in the background
return null
},
async execBatch(nodes: Node[]) {
async execBatch({ nodes }) {
const fileIds = nodes.map((node) => node.fileid).filter(Boolean) as number[]
convertFiles(fileIds, to)
@ -61,8 +62,8 @@ export function registerConvertActions() {
id: ACTION_CONVERT,
displayName: () => t('files', 'Save as …'),
iconSvgInline: () => AutoRenewSvg,
enabled: (nodes: Node[], view: View) => {
return actions.some((action) => action.enabled!(nodes, view))
enabled: (context) => {
return actions.some((action) => action.enabled!(context))
},
async exec() {
return null

View file

@ -38,6 +38,7 @@ describe('Delete action conditions tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/test',
})
const file2 = new File({
@ -50,6 +51,7 @@ describe('Delete action conditions tests', () => {
'is-mount-root': true,
'mount-type': 'shared',
},
root: '/files/admin',
})
const folder = new Folder({
@ -58,6 +60,7 @@ describe('Delete action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
const folder2 = new Folder({
@ -70,6 +73,7 @@ describe('Delete action conditions tests', () => {
'is-mount-root': true,
'mount-type': 'shared',
},
root: '/files/admin',
})
const folder3 = new Folder({
@ -82,23 +86,44 @@ describe('Delete action conditions tests', () => {
'is-mount-root': true,
'mount-type': 'external',
},
root: '/files/admin',
})
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('delete')
expect(action.displayName([file], view)).toBe('Delete file')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('Delete file')
expect(action.iconSvgInline({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(100)
})
test('Default folder displayName', () => {
expect(action.displayName([folder], view)).toBe('Delete folder')
expect(action.displayName({
nodes: [folder],
view,
folder: {} as Folder,
contents: [],
})).toBe('Delete folder')
})
test('Default trashbin view displayName', () => {
expect(action.displayName([file], trashbinView)).toBe('Delete permanently')
expect(action.displayName({
nodes: [file],
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toBe('Delete permanently')
})
test('Trashbin disabled displayName', () => {
@ -107,23 +132,58 @@ describe('Delete action conditions tests', () => {
files: {},
}
})
expect(action.displayName([file], view)).toBe('Delete permanently')
expect(action.displayName({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('Delete permanently')
expect(capabilities.getCapabilities).toBeCalledTimes(1)
})
test('Shared root node displayName', () => {
expect(action.displayName([file2], view)).toBe('Leave this share')
expect(action.displayName([folder2], view)).toBe('Leave this share')
expect(action.displayName([file2, folder2], view)).toBe('Leave these shares')
expect(action.displayName({
nodes: [file2],
view,
folder: {} as Folder,
contents: [],
})).toBe('Leave this share')
expect(action.displayName({
nodes: [folder2],
view,
folder: {} as Folder,
contents: [],
})).toBe('Leave this share')
expect(action.displayName({
nodes: [file2, folder2],
view,
folder: {} as Folder,
contents: [],
})).toBe('Leave these shares')
})
test('External storage root node displayName', () => {
expect(action.displayName([folder3], view)).toBe('Disconnect storage')
expect(action.displayName([folder3, folder3], view)).toBe('Disconnect storages')
expect(action.displayName({
nodes: [folder3],
view,
folder: {} as Folder,
contents: [],
})).toBe('Disconnect storage')
expect(action.displayName({
nodes: [folder3, folder3],
view,
folder: {} as Folder,
contents: [],
})).toBe('Disconnect storages')
})
test('Shared and owned nodes displayName', () => {
expect(action.displayName([file, file2], view)).toBe('Delete and unshare')
expect(action.displayName({
nodes: [file, file2],
view,
folder: {} as Folder,
contents: [],
})).toBe('Delete and unshare')
})
})
@ -151,10 +211,16 @@ describe('Delete action enabled tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/test',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled without DELETE permissions', () => {
@ -164,15 +230,26 @@ describe('Delete action enabled tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/test',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled without nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled if not all nodes can be deleted', () => {
@ -181,18 +258,35 @@ describe('Delete action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/test/Foo/',
owner: 'test',
permissions: Permission.DELETE,
root: '/files/test',
})
const folder2 = new Folder({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/test/Bar/',
owner: 'test',
permissions: Permission.READ,
root: '/files/test',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([folder1], view)).toBe(true)
expect(action.enabled!([folder2], view)).toBe(false)
expect(action.enabled!([folder1, folder2], view)).toBe(false)
expect(action.enabled!({
nodes: [folder1],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
expect(action.enabled!({
nodes: [folder2],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
expect(action.enabled!({
nodes: [folder1, folder2],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled if not allowed', () => {
@ -201,7 +295,12 @@ describe('Delete action enabled tests', () => {
})))
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -219,9 +318,15 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
expect(axios.delete).toBeCalledTimes(1)
@ -244,6 +349,7 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const file2 = new File({
@ -252,9 +358,15 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const exec = await action.execBatch!([file1, file2], view, '/')
const exec = await action.execBatch!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})
// Not enough nodes to trigger a confirmation dialog
expect(confirmMock).toBeCalledTimes(0)
@ -283,6 +395,7 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const file2 = new File({
@ -291,6 +404,7 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const file3 = new File({
@ -299,6 +413,7 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const file4 = new File({
@ -307,6 +422,7 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const file5 = new File({
@ -315,9 +431,15 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const exec = await action.execBatch!([file1, file2, file3, file4, file5], view, '/')
const exec = await action.execBatch!({
nodes: [file1, file2, file3, file4, file5],
view,
folder: {} as Folder,
contents: [],
})
// Enough nodes to trigger a confirmation dialog
expect(confirmMock).toBeCalledTimes(1)
@ -361,6 +483,7 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const file2 = new File({
@ -369,9 +492,15 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const exec = await action.execBatch!([file1, file2], view, '/')
const exec = await action.execBatch!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})
// Will trigger a confirmation dialog because trashbin app is disabled
expect(confirmMock).toBeCalledTimes(1)
@ -401,9 +530,15 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
expect(axios.delete).toBeCalledTimes(1)
@ -435,9 +570,15 @@ describe('Delete action execute tests', () => {
owner: 'test',
mime: 'text/plain',
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
root: '/files/test',
})
const exec = await action.execBatch!([file1], view, '/')
const exec = await action.execBatch!({
nodes: [file1],
view,
folder: {} as Folder,
contents: [],
})
expect(confirmMock).toBeCalledTimes(1)

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import CloseSvg from '@mdi/svg/svg/close.svg?raw'
import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw'
import TrashCanSvg from '@mdi/svg/svg/trash-can-outline.svg?raw'
@ -26,7 +23,7 @@ export const ACTION_DELETE = 'delete'
export const action = new FileAction({
id: ACTION_DELETE,
displayName,
iconSvgInline: (nodes: Node[]) => {
iconSvgInline: ({ nodes }) => {
if (canUnshareOnly(nodes)) {
return CloseSvg
}
@ -38,7 +35,7 @@ export const action = new FileAction({
return TrashCanSvg
},
enabled(nodes: Node[], view: View): boolean {
enabled({ nodes, view }) {
if (view.id === TRASHBIN_VIEW_ID) {
const config = loadState('files_trashbin', 'config', { allow_delete: true })
if (config.allow_delete === false) {
@ -51,7 +48,7 @@ export const action = new FileAction({
.every((permission) => (permission & Permission.DELETE) !== 0)
},
async exec(node: Node, view: View) {
async exec({ nodes, view }) {
try {
let confirm = true
@ -62,7 +59,7 @@ export const action = new FileAction({
const isCalledFromEventListener = callStack.toLocaleLowerCase().includes('keydown')
if (shouldAskForConfirmation() || isCalledFromEventListener) {
confirm = await askConfirmation([node], view)
confirm = await askConfirmation([nodes[0]], view)
}
// If the user cancels the deletion, we don't want to do anything
@ -70,16 +67,16 @@ export const action = new FileAction({
return null
}
await deleteNode(node)
await deleteNode(nodes[0])
return true
} catch (error) {
logger.error('Error while deleting a file', { error, source: node.source, node })
logger.error('Error while deleting a file', { error, source: nodes[0].source, node: nodes[0] })
return false
}
},
async execBatch(nodes: Node[], view: View): Promise<(boolean | null)[]> {
async execBatch({ nodes, view }) {
let confirm = true
if (shouldAskForConfirmation()) {

View file

@ -66,10 +66,11 @@ export function isAllFolders(nodes: Node[]) {
/**
*
* @param nodes
* @param view
* @param root0
* @param root0.nodes
* @param root0.view
*/
export function displayName(nodes: Node[], view: View) {
export function displayName({ nodes, view }: { nodes: Node[], view: View }) {
/**
* If those nodes are all the root node of a
* share, we can only unshare them.
@ -154,7 +155,7 @@ export async function askConfirmation(nodes: Node[], view: View) {
t('files', 'Confirm deletion'),
{
type: window.OC.dialogs.YES_NO_BUTTONS,
confirm: displayName(nodes, view),
confirm: displayName({ nodes, view }),
confirmClasses: 'error',
cancel: t('files', 'Cancel'),
},

View file

@ -30,8 +30,18 @@ describe('Download action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('download')
expect(action.displayName([], view)).toBe('Download')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe('Download')
expect(action.iconSvgInline({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBe(DefaultType.DEFAULT)
expect(action.order).toBe(30)
})
@ -45,10 +55,16 @@ describe('Download action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled without READ permissions', () => {
@ -58,10 +74,16 @@ describe('Download action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.NONE,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled if not all nodes have READ permissions', () => {
@ -70,23 +92,45 @@ describe('Download action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
permissions: Permission.READ,
root: '/files/admin',
})
const folder2 = new Folder({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/',
owner: 'admin',
permissions: Permission.NONE,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([folder1], view)).toBe(true)
expect(action.enabled!([folder2], view)).toBe(false)
expect(action.enabled!([folder1, folder2], view)).toBe(false)
expect(action.enabled!({
nodes: [folder1],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
expect(action.enabled!({
nodes: [folder2],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
expect(action.enabled!({
nodes: [folder1, folder2],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled without nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -107,9 +151,15 @@ describe('Download action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
@ -125,9 +175,15 @@ describe('Download action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
const exec = await action.execBatch!([file], view, '/')
const exec = await action.execBatch!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toStrictEqual([null])
@ -144,9 +200,15 @@ describe('Download action execute tests', () => {
mime: 'text/plain',
displayname: 'baz.txt',
permissions: Permission.READ,
root: '/files/admin',
})
const exec = await action.execBatch!([file], view, '/')
const exec = await action.execBatch!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toStrictEqual([null])
@ -161,9 +223,15 @@ describe('Download action execute tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
owner: 'admin',
permissions: Permission.READ,
root: '/files/admin',
})
const exec = await action.exec(folder, view, '/')
const exec = await action.exec({
nodes: [folder],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
@ -179,6 +247,7 @@ describe('Download action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
const file2 = new File({
id: 1,
@ -186,9 +255,15 @@ describe('Download action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
const exec = await action.execBatch!([file1, file2], view, '/Dir')
const exec = await action.execBatch!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toStrictEqual([null, null])
@ -204,11 +279,17 @@ describe('Download action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
vi.spyOn(axios, 'head').mockRejectedValue(new Error('File not found'))
const errorSpy = vi.spyOn(dialogs, 'showError')
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(null)
expect(errorSpy).toHaveBeenCalledWith('The requested file is not available.')
expect(link.click).not.toHaveBeenCalled()
@ -221,6 +302,7 @@ describe('Download action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
const file2 = new File({
id: 2,
@ -228,12 +310,18 @@ describe('Download action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
vi.spyOn(axios, 'head').mockRejectedValue(new Error('File not found'))
vi.spyOn(eventBus, 'emit').mockImplementation(() => {})
const errorSpy = vi.spyOn(dialogs, 'showError')
const exec = await action.execBatch!([file1, file2], view, '/')
const exec = await action.execBatch!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toStrictEqual([null, null])
expect(errorSpy).toHaveBeenCalledWith('The requested files are not available.')
expect(link.click).not.toHaveBeenCalled()

View file

@ -67,6 +67,10 @@ function longestCommonPath(first: string, second: string): string {
async function downloadNodes(nodes: Node[]) {
let url: URL
if (!nodes[0]) {
throw new Error('No nodes to download')
}
if (nodes.length === 1) {
if (nodes[0].type === FileType.File) {
await triggerDownload(nodes[0].encodedSource, nodes[0].displayname)
@ -125,7 +129,7 @@ export const action = new FileAction({
displayName: () => t('files', 'Download'),
iconSvgInline: () => ArrowDownSvg,
enabled(nodes: Node[], view: View) {
enabled({ nodes, view }): boolean {
if (nodes.length === 0) {
return false
}
@ -143,25 +147,25 @@ export const action = new FileAction({
return nodes.every(isDownloadable)
},
async exec(node: Node) {
async exec({ nodes }) {
try {
await downloadNodes([node])
await downloadNodes(nodes)
} catch (error) {
showError(t('files', 'The requested file is not available.'))
logger.error('The requested file is not available.', { error })
emit('files:node:deleted', node)
emit('files:node:deleted', nodes[0])
}
return null
},
async execBatch(nodes: Node[], view: View, dir: string) {
async execBatch({ nodes, view, folder }) {
try {
await downloadNodes(nodes)
} catch (error) {
showError(t('files', 'The requested files are not available.'))
logger.error('The requested files are not available.', { error })
// Try to reload the current directory to update the view
const directory = getCurrentDirectory(view, dir)!
const directory = getCurrentDirectory(view, folder.path)!
emit('files:node:updated', directory)
}
return new Array(nodes.length).fill(null)

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { Folder, View } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import * as eventBus from '@nextcloud/event-bus'
@ -43,12 +43,23 @@ describe('Favorite action conditions tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('favorite')
expect(action.displayName([file], view)).toBe('Add to favorites')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('Add to favorites')
expect(action.iconSvgInline({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(-50)
})
@ -62,9 +73,15 @@ describe('Favorite action conditions tests', () => {
attributes: {
favorite: 1,
},
root: '/files/admin',
})
expect(action.displayName([file], view)).toBe('Remove from favorites')
expect(action.displayName({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('Remove from favorites')
})
test('Display name for multiple state files', () => {
@ -77,6 +94,7 @@ describe('Favorite action conditions tests', () => {
attributes: {
favorite: 1,
},
root: '/files/admin',
})
const file2 = new File({
id: 1,
@ -87,6 +105,7 @@ describe('Favorite action conditions tests', () => {
attributes: {
favorite: 0,
},
root: '/files/admin',
})
const file3 = new File({
id: 1,
@ -97,12 +116,33 @@ describe('Favorite action conditions tests', () => {
attributes: {
favorite: 1,
},
root: '/files/admin',
})
expect(action.displayName([file1, file2, file3], view)).toBe('Add to favorites')
expect(action.displayName([file1, file2], view)).toBe('Add to favorites')
expect(action.displayName([file2, file3], view)).toBe('Add to favorites')
expect(action.displayName([file1, file3], view)).toBe('Remove from favorites')
expect(action.displayName({
nodes: [file1, file2, file3],
view,
folder: {} as Folder,
contents: [],
})).toBe('Add to favorites')
expect(action.displayName({
nodes: [file2, file3],
view,
folder: {} as Folder,
contents: [],
})).toBe('Add to favorites')
expect(action.displayName({
nodes: [file2, file3],
view,
folder: {} as Folder,
contents: [],
})).toBe('Add to favorites')
expect(action.displayName({
nodes: [file1, file3],
view,
folder: {} as Folder,
contents: [],
})).toBe('Remove from favorites')
})
})
@ -114,10 +154,16 @@ describe('Favorite action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled for non-dav ressources', () => {
@ -126,10 +172,16 @@ describe('Favorite action enabled tests', () => {
source: 'https://domain.com/data/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -147,9 +199,15 @@ describe('Favorite action execute tests', () => {
source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
@ -175,9 +233,15 @@ describe('Favorite action execute tests', () => {
attributes: {
favorite: 1,
},
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
@ -203,9 +267,15 @@ describe('Favorite action execute tests', () => {
attributes: {
favorite: 1,
},
root: '/files/admin',
})
const exec = await action.exec(file, favoriteView, '/')
const exec = await action.exec({
nodes: [file],
view: favoriteView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
@ -235,7 +305,12 @@ describe('Favorite action execute tests', () => {
},
})
const exec = await action.exec(file, favoriteView, '/')
const exec = await action.exec({
nodes: [file],
view: favoriteView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
@ -264,9 +339,15 @@ describe('Favorite action execute tests', () => {
attributes: {
favorite: 0,
},
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
@ -296,9 +377,15 @@ describe('Favorite action execute tests', () => {
attributes: {
favorite: 1,
},
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
@ -332,6 +419,7 @@ describe('Favorite action batch execute tests', () => {
attributes: {
favorite: 1,
},
root: '/files/admin',
})
const file2 = new File({
id: 1,
@ -342,10 +430,16 @@ describe('Favorite action batch execute tests', () => {
attributes: {
favorite: 0,
},
root: '/files/admin',
})
// Mixed states triggers favorite action
const exec = await action.execBatch!([file1, file2], view, '/')
const exec = await action.execBatch!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toStrictEqual([true, true])
expect([file1, file2].every((file) => file.attributes.favorite === 1)).toBe(true)
@ -367,6 +461,7 @@ describe('Favorite action batch execute tests', () => {
attributes: {
favorite: 1,
},
root: '/files/admin',
})
const file2 = new File({
id: 1,
@ -377,10 +472,16 @@ describe('Favorite action batch execute tests', () => {
attributes: {
favorite: 1,
},
root: '/files/admin',
})
// Mixed states triggers favorite action
const exec = await action.execBatch!([file1, file2], view, '/')
const exec = await action.execBatch!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toStrictEqual([true, true])
expect([file1, file2].every((file) => file.attributes.favorite === 0)).toBe(true)

View file

@ -73,18 +73,18 @@ export async function favoriteNode(node: Node, view: View, willFavorite: boolean
export const action = new FileAction({
id: ACTION_FAVORITE,
displayName(nodes: Node[]) {
displayName({ nodes }) {
return shouldFavorite(nodes)
? t('files', 'Add to favorites')
: t('files', 'Remove from favorites')
},
iconSvgInline: (nodes: Node[]) => {
iconSvgInline: ({ nodes }) => {
return shouldFavorite(nodes)
? StarOutlineSvg
: StarSvg
},
enabled(nodes: Node[]) {
enabled({ nodes }) {
// Not enabled for public shares
if (isPublicShare()) {
return false
@ -96,11 +96,11 @@ export const action = new FileAction({
&& nodes.every((node) => node.permissions !== Permission.NONE)
},
async exec(node: Node, view: View) {
const willFavorite = shouldFavorite([node])
return await favoriteNode(node, view, willFavorite)
async exec({ nodes, view }): Promise<boolean> {
const willFavorite = shouldFavorite([nodes[0]])
return await favoriteNode(nodes[0], view, willFavorite)
},
async execBatch(nodes: Node[], view: View) {
async execBatch({ nodes, view }): Promise<boolean[]> {
const willFavorite = shouldFavorite(nodes)
// Map each node to a promise that resolves with the result of exec(node)

View file

@ -2,9 +2,8 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFilePickerButton } from '@nextcloud/dialogs'
import type { Folder, Node, View } from '@nextcloud/files'
import type { Folder, Node } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
import type { MoveCopyResult } from './moveOrCopyActionUtils.ts'
@ -306,7 +305,7 @@ async function openFilePickerForAction(
export const ACTION_COPY_MOVE = 'move-copy'
export const action = new FileAction({
id: ACTION_COPY_MOVE,
displayName(nodes: Node[]) {
displayName({ nodes }) {
switch (getActionForNodes(nodes)) {
case MoveCopyAction.MOVE:
return t('files', 'Move')
@ -317,7 +316,7 @@ export const action = new FileAction({
}
},
iconSvgInline: () => FolderMoveSvg,
enabled(nodes: Node[], view: View) {
enabled({ nodes, view }): boolean {
// We can not copy or move in single file shares
if (view.id === 'public-file-share') {
return false
@ -329,11 +328,11 @@ export const action = new FileAction({
return nodes.length > 0 && (canMove(nodes) || canCopy(nodes))
},
async exec(node: Node, view: View, dir: string) {
const action = getActionForNodes([node])
async exec({ nodes, folder }) {
const action = getActionForNodes([nodes[0]])
let result
try {
result = await openFilePickerForAction(action, dir, [node])
result = await openFilePickerForAction(action, folder.path, [nodes[0]])
} catch (e) {
logger.error(e as Error)
return false
@ -343,7 +342,7 @@ export const action = new FileAction({
}
try {
await handleCopyMoveNodeTo(node, result.destination, result.action)
await handleCopyMoveNodeTo(nodes[0], result.destination, result.action)
return true
} catch (error) {
if (error instanceof Error && !!error.message) {
@ -355,9 +354,9 @@ export const action = new FileAction({
}
},
async execBatch(nodes: Node[], view: View, dir: string) {
async execBatch({ nodes, folder }) {
const action = getActionForNodes(nodes)
const result = await openFilePickerForAction(action, dir, nodes)
const result = await openFilePickerForAction(action, folder.path, nodes)
// Handle cancellation silently
if (result === false) {
return nodes.map(() => null)

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import type { View } from '@nextcloud/files'
import { DefaultType, File, FileAction, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
@ -21,12 +21,23 @@ describe('Open folder action conditions tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
owner: 'admin',
permissions: Permission.READ,
root: '/files/admin',
})
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('open-folder')
expect(action.displayName([folder], view)).toBe('Open folder FooBar')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [folder],
view,
folder: {} as Folder,
contents: [],
})).toBe('Open folder FooBar')
expect(action.iconSvgInline({
nodes: [folder],
view,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBe(DefaultType.HIDDEN)
expect(action.order).toBe(-100)
})
@ -39,10 +50,16 @@ describe('Open folder action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
owner: 'admin',
permissions: Permission.READ,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([folder], view)).toBe(true)
expect(action.enabled!({
nodes: [folder],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled for non-dav ressources', () => {
@ -51,10 +68,16 @@ describe('Open folder action enabled tests', () => {
source: 'https://domain.com/data/FooBar/',
owner: 'admin',
permissions: Permission.NONE,
root: '/',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([folder], view)).toBe(false)
expect(action.enabled!({
nodes: [folder],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled if more than one node', () => {
@ -63,16 +86,23 @@ describe('Open folder action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
permissions: Permission.READ,
root: '/files/admin',
})
const folder2 = new Folder({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/',
owner: 'admin',
permissions: Permission.READ,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([folder1, folder2], view)).toBe(false)
expect(action.enabled!({
nodes: [folder1, folder2],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled for files', () => {
@ -81,10 +111,16 @@ describe('Open folder action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled without READ permissions', () => {
@ -93,17 +129,22 @@ describe('Open folder action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
permissions: Permission.NONE,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([folder], view)).toBe(false)
expect(action.enabled!({
nodes: [folder],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
describe('Open folder action execute tests', () => {
test('Open folder', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const folder = new Folder({
@ -111,9 +152,22 @@ describe('Open folder action execute tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
owner: 'admin',
permissions: Permission.READ,
root: '/files/admin',
})
const exec = await action.exec(folder, view, '/')
const root = new Folder({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/',
owner: 'admin',
root: '/files/admin',
})
const exec = await action.exec({
nodes: [folder],
view,
folder: root,
contents: [],
})
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
@ -122,17 +176,21 @@ describe('Open folder action execute tests', () => {
test('Open folder fails without node', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const exec = await action.exec(null as unknown as Node, view, '/')
const exec = await action.exec({
// @ts-expect-error We want to test without node
nodes: [],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
expect(goToRouteMock).toBeCalledTimes(0)
})
test('Open folder fails without Folder', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new File({
@ -140,9 +198,22 @@ describe('Open folder action execute tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const root = new Folder({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/',
owner: 'admin',
root: '/files/admin',
})
const exec = await action.exec({
nodes: [file],
view,
folder: root,
contents: [],
})
expect(exec).toBe(false)
expect(goToRouteMock).toBeCalledTimes(0)
})

View file

@ -2,30 +2,31 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
import { DefaultType, FileAction, FileType, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
export const action = new FileAction({
id: 'open-folder',
displayName(files: Node[]) {
displayName({ nodes }) {
if (nodes.length !== 1 || !nodes[0]) {
return t('files', 'Open folder')
}
// Only works on single node
const displayName = files[0].displayname
const displayName = nodes[0].displayname
return t('files', 'Open folder {displayName}', { displayName })
},
iconSvgInline: () => FolderSvg,
enabled(nodes: Node[]) {
enabled({ nodes }) {
// Only works on single node
if (nodes.length !== 1) {
if (nodes.length !== 1 || !nodes[0]) {
return false
}
const node = nodes[0]
if (!node.isDavRessource) {
if (!node.isDavResource) {
return false
}
@ -33,7 +34,8 @@ export const action = new FileAction({
&& (node.permissions & Permission.READ) !== 0
},
async exec(node: Node, view: View) {
async exec({ nodes, view }) {
const node = nodes[0]
if (!node || node.type !== FileType.Folder) {
return false
}

View file

@ -23,8 +23,18 @@ describe('Open in files action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('open-in-files')
expect(action.displayName([], recentView)).toBe('Open in Files')
expect(action.iconSvgInline([], recentView)).toBe('')
expect(action.displayName({
nodes: [],
view: recentView,
folder: {} as Folder,
contents: [],
})).toBe('Open in Files')
expect(action.iconSvgInline({
nodes: [],
view: recentView,
folder: {} as Folder,
contents: [],
})).toBe('')
expect(action.default).toBe(DefaultType.HIDDEN)
expect(action.order).toBe(-1000)
expect(action.inline).toBeUndefined()
@ -34,12 +44,22 @@ describe('Open in files action conditions tests', () => {
describe('Open in files action enabled tests', () => {
test('Enabled with on valid view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], recentView)).toBe(true)
expect(action.enabled!({
nodes: [],
view: recentView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled on wrong view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -57,7 +77,12 @@ describe('Open in files action execute tests', () => {
permissions: Permission.ALL,
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
@ -77,7 +102,12 @@ describe('Open in files action execute tests', () => {
permissions: Permission.ALL,
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import { DefaultType, FileAction, FileType } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search.ts'
@ -14,19 +11,23 @@ export const action = new FileAction({
displayName: () => t('files', 'Open in Files'),
iconSvgInline: () => '',
enabled(nodes, view) {
enabled({ view }) {
return view.id === 'recent' || view.id === SEARCH_VIEW_ID
},
async exec(node: Node) {
let dir = node.dirname
if (node.type === FileType.Folder) {
dir = dir + '/' + node.basename
async exec({ nodes }) {
if (!nodes[0]) {
return false
}
let dir = nodes[0].dirname
if (nodes[0].type === FileType.Folder) {
dir = dir + '/' + nodes[0].basename
}
window.OCP.Files.Router.goToRoute(
null, // use default route
{ view: 'files', fileid: String(node.fileid) },
{ view: 'files', fileid: String(nodes[0].fileid) },
{ dir, openfile: 'true' },
)
return null

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { Folder, View } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import * as nextcloudDialogs from '@nextcloud/dialogs'
@ -30,8 +30,18 @@ describe('Open locally action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('edit-locally')
expect(action.displayName([], view)).toBe('Open locally')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [],
view,
folder: {} as any,
contents: [],
})).toBe('Open locally')
expect(action.iconSvgInline({
nodes: [],
view,
folder: {} as any,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(25)
})
@ -45,10 +55,16 @@ describe('Open locally action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as any,
contents: [],
})).toBe(true)
})
test('Disabled for non-dav resources', () => {
@ -57,10 +73,16 @@ describe('Open locally action enabled tests', () => {
source: 'https://domain.com/data/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as any,
contents: [],
})).toBe(false)
})
test('Disabled if more than one node', () => {
@ -70,6 +92,7 @@ describe('Open locally action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
const file2 = new File({
id: 1,
@ -77,10 +100,16 @@ describe('Open locally action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file1, file2], view)).toBe(false)
expect(action.enabled!({
nodes: [file1, file2],
view,
folder: {} as any,
contents: [],
})).toBe(false)
})
test('Disabled for files', () => {
@ -89,10 +118,16 @@ describe('Open locally action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as any,
contents: [],
})).toBe(false)
})
test('Disabled without UPDATE permissions', () => {
@ -102,10 +137,16 @@ describe('Open locally action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as any,
contents: [],
})).toBe(false)
})
})
@ -130,9 +171,15 @@ describe('Open locally action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.UPDATE,
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(spyShowDialog).toBeCalled()
@ -154,9 +201,15 @@ describe('Open locally action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.UPDATE,
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(spyShowDialog).toBeCalled()

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw'
import IconWeb from '@mdi/svg/svg/web.svg?raw'
import { getCurrentUser } from '@nextcloud/auth'
@ -24,9 +21,9 @@ export const action = new FileAction({
iconSvgInline: () => LaptopSvg,
// Only works on single files
enabled(nodes: Node[]) {
enabled({ nodes }) {
// Only works on single node
if (nodes.length !== 1) {
if (nodes.length !== 1 || !nodes[0]) {
return false
}
@ -38,8 +35,8 @@ export const action = new FileAction({
return isSyncable(nodes[0])
},
async exec(node: Node) {
await attemptOpenLocalClient(node.path)
async exec({ nodes }) {
await attemptOpenLocalClient(nodes[0].path)
return null
},
@ -68,7 +65,7 @@ async function attemptOpenLocalClient(path: string) {
/**
* Try to open a file in the Nextcloud client.
* There is no way to get notified if this action was successfull.
* There is no way to get notified if this action was successful.
*
* @param path - Path to open
*/

View file

@ -18,7 +18,13 @@ const view = {
} as View
beforeEach(() => {
const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE })
const root = new Folder({
owner: 'test',
source: 'https://cloud.domain.com/remote.php/dav/files/admin/',
id: 1,
permissions: Permission.CREATE,
root: '/files/admin',
})
const files = useFilesStore(getPinia())
files.setRoot({ service: 'files', root })
})
@ -27,8 +33,18 @@ describe('Rename action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('rename')
expect(action.displayName([], view)).toBe('Rename')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe('Rename')
expect(action.iconSvgInline({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(10)
})
@ -42,10 +58,16 @@ describe('Rename action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.UPDATE | Permission.DELETE,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled for node without DELETE permission', () => {
@ -55,10 +77,16 @@ describe('Rename action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled if more than one node', () => {
@ -70,16 +98,23 @@ describe('Rename action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
const file2 = new File({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file1, file2], view)).toBe(false)
expect(action.enabled!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -92,9 +127,15 @@ describe('Rename action exec tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)

View file

@ -2,10 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { Node } from '@nextcloud/files'
import PencilSvg from '@mdi/svg/svg/pencil-outline.svg?raw'
import { emit } from '@nextcloud/event-bus'
import { FileAction, Permission } from '@nextcloud/files'
@ -21,8 +17,8 @@ export const action = new FileAction({
displayName: () => t('files', 'Rename'),
iconSvgInline: () => PencilSvg,
enabled: (nodes: Node[], view: View) => {
if (nodes.length === 0) {
enabled: ({ nodes, view }) => {
if (nodes.length === 0 || !nodes[0]) {
return false
}
@ -44,9 +40,9 @@ export const action = new FileAction({
&& Boolean(parentPermissions & Permission.CREATE)
},
async exec(node: Node) {
async exec({ nodes }) {
// Renaming is a built-in feature of the files app
emit('files:node:rename', node)
emit('files:node:rename', nodes[0])
return null
},

View file

@ -19,8 +19,18 @@ describe('Open sidebar action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('details')
expect(action.displayName([], view)).toBe('Details')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe('Details')
expect(action.iconSvgInline({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(-50)
})
@ -37,10 +47,16 @@ describe('Open sidebar action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled without permissions', () => {
@ -53,10 +69,16 @@ describe('Open sidebar action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.NONE,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled if more than one node', () => {
@ -68,16 +90,23 @@ describe('Open sidebar action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
const file2 = new File({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file1, file2], view)).toBe(false)
expect(action.enabled!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled if no Sidebar', () => {
@ -89,10 +118,16 @@ describe('Open sidebar action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled for non-dav ressources', () => {
@ -104,10 +139,16 @@ describe('Open sidebar action enabled tests', () => {
source: 'https://domain.com/documents/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/documents/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -126,9 +167,22 @@ describe('Open sidebar action exec tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const folder = new Folder({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/',
owner: 'admin',
root: '/files/admin',
})
const exec = await action.exec({
nodes: [file],
view,
folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
expect(openMock).toBeCalledWith('/foobar.txt')
@ -155,9 +209,22 @@ describe('Open sidebar action exec tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar',
owner: 'admin',
mime: 'httpd/unix-directory',
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const folder = new Folder({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/',
owner: 'admin',
root: '/files/admin',
})
const exec = await action.exec({
nodes: [file],
view,
folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
expect(openMock).toBeCalledWith('/foobar')
@ -184,9 +251,15 @@ describe('Open sidebar action exec tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
expect(openMock).toBeCalledTimes(1)
expect(logger.error).toBeCalledTimes(1)

View file

@ -2,8 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import InformationSvg from '@mdi/svg/svg/information-outline.svg?raw'
import { FileAction, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
@ -18,7 +16,7 @@ export const action = new FileAction({
iconSvgInline: () => InformationSvg,
// Sidebar currently supports user folder only, /files/USER
enabled: (nodes: Node[]) => {
enabled: ({ nodes }) => {
if (isPublicShare()) {
return false
}
@ -40,7 +38,8 @@ export const action = new FileAction({
return (nodes[0].root?.startsWith('/files/') && nodes[0].permissions !== Permission.NONE) ?? false
},
async exec(node: Node, view: View, dir: string) {
async exec({ nodes, view, folder }) {
const node = nodes[0]
try {
// If the sidebar is already open for the current file, do nothing
if (window.OCA.Files?.Sidebar?.file === node.path) {
@ -57,7 +56,7 @@ export const action = new FileAction({
window.OCP?.Files?.Router?.goToRoute(
null,
{ view: view.id, fileid: String(node.fileid) },
{ ...window.OCP.Files.Router.query, dir, opendetails: 'true' },
{ ...window.OCP.Files.Router.query, dir: folder.path, opendetails: 'true' },
true,
)

View file

@ -2,7 +2,7 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import type { View } from '@nextcloud/files'
import { File, FileAction, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
@ -22,8 +22,18 @@ describe('View in folder action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('view-in-folder')
expect(action.displayName([], view)).toBe('View in folder')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe('View in folder')
expect(action.iconSvgInline({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(80)
expect(action.enabled).toBeDefined()
@ -38,10 +48,16 @@ describe('View in folder action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled for files', () => {
@ -51,10 +67,16 @@ describe('View in folder action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], viewFiles)).toBe(false)
expect(action.enabled!({
nodes: [file],
view: viewFiles,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled without permissions', () => {
@ -64,10 +86,16 @@ describe('View in folder action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.NONE,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled for non-dav ressources', () => {
@ -76,10 +104,16 @@ describe('View in folder action enabled tests', () => {
source: 'https://domain.com/foobar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled if more than one node', () => {
@ -88,16 +122,23 @@ describe('View in folder action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
const file2 = new File({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
owner: 'admin',
mime: 'text/plain',
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file1, file2], view)).toBe(false)
expect(action.enabled!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled for folders', () => {
@ -106,10 +147,16 @@ describe('View in folder action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
owner: 'admin',
permissions: Permission.READ,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([folder], view)).toBe(false)
expect(action.enabled!({
nodes: [folder],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled for files outside the user root folder', () => {
@ -118,10 +165,16 @@ describe('View in folder action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/trashbin/admin/trash/image.jpg.d1731053878',
owner: 'admin',
permissions: Permission.READ,
root: '/trashbin/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -136,9 +189,15 @@ describe('View in folder action execute tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
root: '/files/admin',
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
@ -158,7 +217,12 @@ describe('View in folder action execute tests', () => {
permissions: Permission.READ,
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
@ -169,7 +233,13 @@ describe('View in folder action execute tests', () => {
const goToRouteMock = vi.fn()
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const exec = await action.exec(null as unknown as Node, view, '/')
const exec = await action.exec({
// @ts-expect-error We want to test without node
nodes: [],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
expect(goToRouteMock).toBeCalledTimes(0)
})
@ -182,9 +252,15 @@ describe('View in folder action execute tests', () => {
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
root: '/files/admin',
})
const exec = await action.exec(folder, view, '/')
const exec = await action.exec({
nodes: [folder],
view,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
expect(goToRouteMock).toBeCalledTimes(0)
})

View file

@ -2,8 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw'
import { FileAction, FileType, Permission } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
@ -16,7 +14,7 @@ export const action = new FileAction({
},
iconSvgInline: () => FolderMoveSvg,
enabled(nodes: Node[], view: View) {
enabled({ nodes, view }) {
// Not enabled for public shares
if (isPublicShare()) {
return false
@ -28,13 +26,12 @@ export const action = new FileAction({
}
// Only works on single node
if (nodes.length !== 1) {
if (nodes.length !== 1 || !nodes[0]) {
return false
}
const node = nodes[0]
if (!node.isDavRessource) {
if (!node.isDavResource) {
return false
}
@ -50,15 +47,15 @@ export const action = new FileAction({
return node.type === FileType.File
},
async exec(node: Node) {
if (!node || node.type !== FileType.File) {
async exec({ nodes }) {
if (!nodes[0] || nodes[0].type !== FileType.File) {
return false
}
window.OCP.Files.Router.goToRoute(
null,
{ view: 'files', fileid: String(node.fileid) },
{ dir: node.dirname },
{ view: 'files', fileid: String(nodes[0].fileid) },
{ dir: nodes[0].dirname },
)
return null
},

View file

@ -7,6 +7,11 @@
</template>
<script lang="ts">
import type { FileAction, Folder, Node, View } from '@nextcloud/files'
import type { PropType } from 'vue'
type RenderFunction = typeof FileAction.prototype.renderInline
/**
* This component is used to render custom
* elements provided by an API. Vue doesn't allow
@ -17,17 +22,22 @@ export default {
name: 'CustomElementRender',
props: {
source: {
type: Object,
type: Object as PropType<Node>,
required: true,
},
currentView: {
type: Object,
activeView: {
type: Object as PropType<View>,
required: true,
},
activeFolder: {
type: Object as PropType<Folder>,
required: true,
},
render: {
type: Function,
type: Function as PropType<RenderFunction>,
required: true,
},
},
@ -48,7 +58,13 @@ export default {
methods: {
async updateRootElement() {
const element = await this.render(this.source, this.currentView)
const element = await this.render!({
nodes: [this.source],
view: this.activeView,
folder: this.activeFolder,
contents: [],
})
if (element) {
this.$el.replaceChildren(element)
} else {

View file

@ -92,12 +92,13 @@
<td
v-for="column in columns"
:key="column.id"
:class="`files-list__row-${currentView.id}-${column.id}`"
:class="`files-list__row-${activeView.id}-${column.id}`"
class="files-list__row-column-custom"
:data-cy-files-list-row-column-custom="column.id"
@click="openDetailsIfAvailable">
<CustomElementRender
:current-view="currentView"
:active-folder="activeFolder"
:active-view="activeView"
:render="column.render"
:source="source" />
</td>
@ -116,9 +117,9 @@ import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useNavigation } from '../composables/useNavigation.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useActiveStore } from '../store/active.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
@ -160,24 +161,27 @@ export default defineComponent({
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
const filesListWidth = useFileListWidth()
// The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
const { currentView } = useNavigation(true)
const {
directory: currentDir,
fileId: currentFileId,
fileId: currentRouteFileId,
} = useRouteParameters()
const {
activeFolder,
activeNode,
activeView,
} = useActiveStore()
return {
actionsMenuStore,
activeFolder,
activeNode,
activeView,
currentRouteFileId,
draggingStore,
filesListWidth,
filesStore,
renamingStore,
selectionStore,
currentDir,
currentFileId,
currentView,
filesListWidth,
}
},
@ -208,7 +212,7 @@ export default defineComponent({
if (this.filesListWidth < 512 || this.compact) {
return []
}
return this.currentView.columns || []
return this.activeView.columns || []
},
mime() {
@ -281,7 +285,12 @@ export default defineComponent({
return
}
this.defaultFileAction?.exec(this.source, this.currentView, this.currentDir)
this.defaultFileAction?.exec({
nodes: [this.source],
folder: this.activeFolder!,
contents: this.nodes,
view: this.activeView!,
})
},
},
})

View file

@ -11,7 +11,8 @@
v-for="action in enabledRenderActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
:current-view="currentView"
:active-folder="activeStore.activeFolder"
:active-view="activeStore.activeView"
:render="action.renderInline"
:source="source"
class="files-list__row-action--inline" />
@ -43,15 +44,15 @@
:close-after-click="!isValidMenu(action)"
:data-cy-files-list-row-action="action.id"
:is-menu="isValidMenu(action)"
:aria-label="action.title?.([source], currentView)"
:title="action.title?.([source], currentView)"
:aria-label="action.title?.(actionContext)"
:title="action.title?.(actionContext)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper
v-else
class="files-list__row-action-icon"
:svg="action.iconSvgInline([source], currentView)" />
:svg="action.iconSvgInline(actionContext)" />
</template>
{{ actionDisplayName(action) }}
</NcActionButton>
@ -72,15 +73,15 @@
:close-after-click="!isValidMenu(action)"
:data-cy-files-list-row-action="action.id"
:is-menu="isValidMenu(action)"
:aria-label="action.title?.([source], currentView)"
:title="action.title?.([source], currentView)"
:aria-label="action.title?.(actionContext)"
:title="action.title?.(actionContext)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper
v-else
class="files-list__row-action-icon"
:svg="action.iconSvgInline([source], currentView)" />
:svg="action.iconSvgInline(actionContext)" />
</template>
{{ actionDisplayName(action) }}
</NcActionButton>
@ -105,12 +106,12 @@
class="files-list__row-action--submenu"
close-after-click
:data-cy-files-list-row-action="action.id"
:aria-label="action.title?.([source], currentView)"
:title="action.title?.([source], currentView)"
:aria-label="action.title?.(actionContext)"
:title="action.title?.(actionContext)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline(actionContext)" />
</template>
{{ actionDisplayName(action) }}
</NcActionButton>
@ -120,7 +121,7 @@
</template>
<script lang="ts">
import type { FileAction, Node } from '@nextcloud/files'
import type { ActionContextSingle, FileAction, Node } from '@nextcloud/files'
import type { PropType } from 'vue'
import { DefaultType, NodeStatus } from '@nextcloud/files'
@ -135,8 +136,6 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import CustomElementRender from '../CustomElementRender.vue'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
import { useNavigation } from '../../composables/useNavigation.ts'
import { useRouteParameters } from '../../composables/useRouteParameters.ts'
import logger from '../../logger.ts'
import actionsMixins from '../../mixins/actionsMixin.ts'
import { useActiveStore } from '../../store/active.ts'
@ -175,17 +174,12 @@ export default defineComponent({
},
setup() {
// The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
const { currentView } = useNavigation(true)
const { directory: currentDir } = useRouteParameters()
// The file list is guaranteed to be shown with active view - thus we can set the `loaded` flag
const activeStore = useActiveStore()
const filesListWidth = useFileListWidth()
const enabledFileActions = inject<FileAction[]>('enabledFileActions', [])
return {
activeStore,
currentDir,
currentView,
enabledFileActions,
filesListWidth,
t,
@ -194,13 +188,22 @@ export default defineComponent({
computed: {
isActive() {
return this.activeStore?.activeNode?.source === this.source.source
return this.activeStore.activeNode?.source === this.source.source
},
isLoading() {
return this.source.status === NodeStatus.LOADING
},
actionContext(): ActionContextSingle {
return {
nodes: [this.source],
view: this.activeStore.activeView!,
folder: this.activeStore.activeFolder!,
contents: [],
}
},
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768 || this.gridMode) {
@ -208,7 +211,7 @@ export default defineComponent({
}
return this.enabledFileActions.filter((action) => {
try {
return action?.inline?.(this.source, this.currentView)
return action?.inline?.(this.actionContext) === true
} catch (error) {
logger.error('Error while checking if action is inline', { action, error })
return false
@ -302,12 +305,12 @@ export default defineComponent({
if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') {
// if an inline action is rendered in the menu for
// lack of space we use the title first if defined
const title = action.title([this.source], this.currentView)
const title = action.title(this.actionContext)
if (title) {
return title
}
}
return action.displayName([this.source], this.currentView)
return action.displayName(this.actionContext)
} catch (error) {
logger.error('Error while getting action display name', { action, error })
// Not ideal, but better than nothing
@ -319,7 +322,7 @@ export default defineComponent({
if (!this.isActive) {
return false
}
return this.activeStore?.activeAction?.id === action.id
return this.activeStore.activeAction?.id === action.id
},
async onActionClick(action) {

View file

@ -48,9 +48,8 @@ import { translate as t } from '@nextcloud/l10n'
import { defineComponent, inject } from 'vue'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
import { useNavigation } from '../../composables/useNavigation.ts'
import { useRouteParameters } from '../../composables/useRouteParameters.ts'
import logger from '../../logger.ts'
import { useActiveStore } from '../../store/active.ts'
import { useRenamingStore } from '../../store/renaming.ts'
import { useUserConfigStore } from '../../store/userconfig.ts'
import { getFilenameValidity } from '../../utils/filenameValidity.ts'
@ -97,20 +96,18 @@ export default defineComponent({
setup() {
// The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
const { currentView } = useNavigation(true)
const { directory } = useRouteParameters()
const filesListWidth = useFileListWidth()
const renamingStore = useRenamingStore()
const userConfigStore = useUserConfigStore()
const { activeFolder, activeView } = useActiveStore()
const defaultFileAction = inject<FileAction | undefined>('defaultFileAction')
return {
currentView,
activeFolder,
activeView,
defaultFileAction,
directory,
filesListWidth,
renamingStore,
userConfigStore,
}
@ -154,7 +151,12 @@ export default defineComponent({
}
if (this.defaultFileAction) {
const displayName = this.defaultFileAction.displayName([this.source], this.currentView)
const displayName = this.defaultFileAction.displayName({
nodes: [this.source],
view: this.activeView,
folder: this.activeFolder,
contents: [],
})
return {
is: 'button',
params: {

View file

@ -65,9 +65,9 @@
<!-- Actions -->
<FileEntryActions
ref="actions"
:opened.sync="openedMenu"
:class="`files-list__row-actions-${uniqueId}`"
:grid-mode="true"
:opened.sync="openedMenu"
:source="source" />
</tr>
</template>
@ -79,9 +79,10 @@ import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
import { useNavigation } from '../composables/useNavigation.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useActiveStore } from '../store/active.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
@ -105,29 +106,35 @@ export default defineComponent({
inheritAttrs: false,
// keep in sync with FileEntry.vue
setup() {
const actionsMenuStore = useActionsMenuStore()
const draggingStore = useDragAndDropStore()
const filesStore = useFilesStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
// The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
const { currentView } = useNavigation(true)
const filesListWidth = useFileListWidth()
const {
directory: currentDir,
fileId: currentFileId,
fileId: currentRouteFileId,
} = useRouteParameters()
const {
activeFolder,
activeNode,
activeView,
} = useActiveStore()
return {
actionsMenuStore,
activeFolder,
activeNode,
activeView,
currentRouteFileId,
draggingStore,
filesListWidth,
filesStore,
renamingStore,
selectionStore,
currentDir,
currentFileId,
currentView,
}
},

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { FileAction } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
@ -122,7 +123,9 @@ export default defineComponent({
},
isActive() {
return String(this.fileid) === String(this.currentFileId)
// Not using activeNode here because we want to
// be reactive to the url change directly
return String(this.fileid) === String(this.currentRouteFileId)
},
/**
@ -235,7 +238,7 @@ export default defineComponent({
}
return actions
.filter((action) => {
.filter((action: FileAction) => {
if (!action.enabled) {
return true
}
@ -243,7 +246,12 @@ export default defineComponent({
// In case something goes wrong, since we don't want to break
// the entire list, we filter out actions that throw an error.
try {
return action.enabled([this.source], this.currentView)
return action.enabled({
nodes: [this.source],
view: this.activeView,
folder: this.activeFolder!,
contents: this.nodes,
})
} catch (error) {
logger.error('Error while checking action', { action, error })
return false
@ -253,7 +261,7 @@ export default defineComponent({
},
defaultFileAction() {
return this.enabledFileActions.find((action) => action.default !== undefined)
return this.enabledFileActions.find((action: FileAction) => action.default !== undefined)
},
},
@ -378,14 +386,29 @@ export default defineComponent({
event.preventDefault()
event.stopPropagation()
// Execute the first default action if any
this.defaultFileAction.exec(this.source, this.currentView, this.currentDir)
this.defaultFileAction.exec({
nodes: [this.source],
folder: this.activeFolder!,
contents: this.nodes,
view: this.activeView!,
})
},
openDetailsIfAvailable(event) {
event.preventDefault()
event.stopPropagation()
if (sidebarAction?.enabled?.([this.source], this.currentView)) {
sidebarAction.exec(this.source, this.currentView, this.currentDir)
if (sidebarAction?.enabled?.({
nodes: [this.source],
folder: this.activeFolder!,
contents: this.nodes,
view: this.activeView!,
})) {
sidebarAction.exec({
nodes: [this.source],
folder: this.activeFolder!,
contents: this.nodes,
view: this.activeView!,
})
}
},
@ -468,7 +491,7 @@ export default defineComponent({
const fileTree = await dataTransferToFileTree(items)
// We might not have the target directory fetched yet
const contents = await this.currentView?.getContents(this.source.path)
const contents = await this.activeView?.getContents(this.source.path)
const folder = contents?.folder
if (!folder) {
showError(this.t('files', 'Target folder does not exist any more'))

View file

@ -26,14 +26,14 @@
:close-after-click="!isValidMenu(action)"
:data-cy-files-list-selection-action="action.id"
:is-menu="isValidMenu(action)"
:aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
:title="action.title?.(nodes, currentView)"
:aria-label="action.displayName(actionContext) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
:title="action.title?.(actionContext)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline(actionContext)" />
</template>
{{ action.displayName(nodes, currentView) }}
{{ action.displayName(actionContext) }}
</NcActionButton>
<!-- Submenu actions list-->
@ -55,14 +55,14 @@
class="files-list__row-actions-batch--submenu"
close-after-click
:data-cy-files-list-selection-action="action.id"
:aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
:title="action.title?.(nodes, currentView)"
:aria-label="action.displayName(actionContext) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
:title="action.title?.(actionContext)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline(actionContext)" />
</template>
{{ action.displayName(nodes, currentView) }}
{{ action.displayName(actionContext) }}
</NcActionButton>
</template>
</NcActions>
@ -70,7 +70,7 @@
</template>
<script lang="ts">
import type { FileAction, Node, View } from '@nextcloud/files'
import type { ActionContext, FileAction, Node, View } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
@ -84,10 +84,10 @@ import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import logger from '../logger.ts'
import actionsMixins from '../mixins/actionsMixin.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useActiveStore } from '../store/active.ts'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
@ -120,19 +120,18 @@ export default defineComponent({
},
setup() {
const { activeFolder } = useActiveStore()
const actionsMenuStore = useActionsMenuStore()
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
const fileListWidth = useFileListWidth()
const { directory } = useRouteParameters()
const boundariesElement = document.getElementById('app-content-vue')
return {
directory,
fileListWidth,
actionsMenuStore,
activeFolder,
fileListWidth,
filesStore,
selectionStore,
@ -157,7 +156,7 @@ export default defineComponent({
// but children actions always need to have it
.filter((action) => action.execBatch || !action.parent)
// We filter out actions that are not enabled for the current selection
.filter((action) => !action.enabled || action.enabled(this.nodes, this.currentView))
.filter((action) => !action.enabled || action.enabled(this.actionContext))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
@ -227,6 +226,15 @@ export default defineComponent({
return [...this.enabledInlineActions, ...menuActions]
},
actionContext(): ActionContext {
return {
nodes: this.nodes,
view: this.currentView,
folder: this.activeFolder!,
contents: this.nodes,
}
},
nodes() {
return this.selectedNodes
.map((source) => this.getNode(source))
@ -294,7 +302,7 @@ export default defineComponent({
})
// Dispatch action execution
const results = await action.execBatch(this.nodes, this.currentView, this.directory)
const results = await action.execBatch(this.actionContext)
// Check if all actions returned null
if (!results.some((result) => result !== null)) {

View file

@ -273,7 +273,7 @@ export default defineComponent({
subscribe('files:sidebar:closed', this.onSidebarClosed)
},
beforeDestroy() {
beforeUnmount() {
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.removeEventListener('dragover', this.onDragOver)
unsubscribe('files:sidebar:closed', this.onSidebarClosed)
@ -311,9 +311,19 @@ export default defineComponent({
// Open the sidebar for the given URL fileid
// iif we just loaded the app.
const node = this.nodes.find((n) => n.fileid === fileId) as NcNode
if (node && sidebarAction?.enabled?.([node], this.currentView)) {
if (node && sidebarAction?.enabled?.({
nodes: [node],
folder: this.currentFolder,
view: this.currentView,
contents: this.nodes,
})) {
logger.debug('Opening sidebar on file ' + node.path, { node })
sidebarAction.exec(node, this.currentView, this.currentFolder.path)
sidebarAction.exec({
nodes: [node],
folder: this.currentFolder,
view: this.currentView,
contents: this.nodes,
})
return
}
logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
@ -382,7 +392,12 @@ export default defineComponent({
// Get only default actions (visible and hidden)
.filter((action) => !!action?.default)
// Find actions that are either always enabled or enabled for the current node
.filter((action) => !action.enabled || action.enabled([node], this.currentView))
.filter((action) => (!action.enabled || action.enabled({
nodes: [node],
view: this.currentView,
folder: this.currentFolder,
contents: this.nodes,
})))
.filter((action) => action.id !== 'download')
// Sort enabled default actions by order
.sort((a, b) => (a.order || 0) - (b.order || 0))
@ -393,7 +408,12 @@ export default defineComponent({
// So if there is an enabled default action, so execute it
if (defaultAction) {
logger.debug('Opening file ' + node.path, { node })
return await defaultAction.exec(node, this.currentView, this.currentFolder.path)
return await defaultAction.exec({
nodes: [node],
view: this.currentView,
folder: this.currentFolder,
contents: this.nodes,
})
}
}
// The file is either a folder or has no default action other than downloading
@ -449,6 +469,7 @@ export default defineComponent({
if (nextNode && nextNode?.fileid) {
this.setActiveNode(nextNode)
return
}
}
@ -464,6 +485,7 @@ export default defineComponent({
if (nextNode && nextNode?.fileid) {
this.setActiveNode(nextNode)
return
}
}
},

View file

@ -81,18 +81,27 @@ describe('HotKeysService testing', () => {
file = new File({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
root: '/files/admin',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
})
const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE })
const root = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/',
root: '/files/admin',
owner: 'admin',
permissions: Permission.CREATE,
})
const files = useFilesStore(getPinia())
files.setRoot({ service: 'files', root })
// Setting the view first as it reset the active node
activeStore.activeView = view
activeStore.activeNode = file
activeStore.activeFolder = root
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: { async open() {}, setActiveTab: () => {} } } }
@ -148,7 +157,7 @@ describe('HotKeysService testing', () => {
})
it('Pressing Delete should delete the file', async () => {
// @ts-expect-error unit testing
// @ts-expect-error unit testing - private method access
vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true)
dispatchEvent({ key: 'Delete', code: 'Delete' })

View file

@ -19,6 +19,8 @@ export function useNavigation<T extends boolean>(_loaded?: T) {
type MaybeView = T extends true ? View : (View | null)
const navigation = getNavigation()
const views: ShallowRef<View[]> = shallowRef(navigation.views)
/** @deprecated use activeStore.activeView instead */
const currentView: ShallowRef<MaybeView> = shallowRef(navigation.active as MaybeView)
/**

View file

@ -3,7 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { addNewFileMenuEntry, registerDavProperty, registerFileAction } from '@nextcloud/files'
import { addNewFileMenuEntry, registerFileAction } from '@nextcloud/files'
import { registerDavProperty } from '@nextcloud/files/dav'
import { isPublicShare } from '@nextcloud/sharing/public'
import { registerConvertActions } from './actions/convertAction.ts'
import { action as deleteAction } from './actions/deleteAction.ts'

View file

@ -8,7 +8,8 @@ import type { Upload } from '@nextcloud/upload'
import type { RootDirectory } from './DropServiceUtils.ts'
import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
import { davRootPath, NodeStatus } from '@nextcloud/files'
import { NodeStatus } from '@nextcloud/files'
import { getRootPath } from '@nextcloud/files/dav'
import { translate as t } from '@nextcloud/l10n'
import { joinPaths } from '@nextcloud/paths'
import { getUploader, hasConflict } from '@nextcloud/upload'
@ -125,7 +126,7 @@ export async function onDropExternalFiles(root: RootDirectory, destination: Fold
// If the file is a directory, we need to create it first
// then browse its tree and upload its contents.
if (file instanceof Directory) {
const absolutePath = joinPaths(davRootPath, destination.path, relativePath)
const absolutePath = joinPaths(getRootPath(), destination.path, relativePath)
try {
logger.debug('Processing directory', { relativePath })
await createDirectoryIfNotExists(absolutePath)

View file

@ -7,7 +7,7 @@ import type { FileStat, ResponseDataDetailed } from 'webdav'
import { showInfo, showWarning } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { davGetClient, davGetDefaultPropfind, davResultToNode } from '@nextcloud/files'
import { getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav'
import { translate as t } from '@nextcloud/l10n'
import { openConflictPicker } from '@nextcloud/upload'
import logger from '../logger.ts'
@ -135,13 +135,13 @@ function readDirectory(directory: FileSystemDirectoryEntry): Promise<FileSystemE
* @param absolutePath
*/
export async function createDirectoryIfNotExists(absolutePath: string) {
const davClient = davGetClient()
const davClient = getClient()
const dirExists = await davClient.exists(absolutePath)
if (!dirExists) {
logger.debug('Directory does not exist, creating it', { absolutePath })
await davClient.createDirectory(absolutePath, { recursive: true })
const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat>
emit('files:node:created', davResultToNode(stat.data))
const stat = await davClient.stat(absolutePath, { details: true, data: getDefaultPropfind() }) as ResponseDataDetailed<FileStat>
emit('files:node:created', resultToNode(stat.data))
}
}

View file

@ -5,7 +5,8 @@
import type { ContentsWithRoot } from '@nextcloud/files'
import { getCurrentUser } from '@nextcloud/auth'
import { davRemoteURL, davRootPath, Folder, getFavoriteNodes, Permission } from '@nextcloud/files'
import { Folder, Permission } from '@nextcloud/files'
import { getFavoriteNodes, getRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { CancelablePromise } from 'cancelable-promise'
import { getContents as filesContents } from './Files.ts'
import { client } from './WebdavClient.ts'
@ -32,8 +33,8 @@ export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> {
contents,
folder: new Folder({
id: 0,
source: `${davRemoteURL}${davRootPath}`,
root: davRootPath,
source: `${getRemoteURL()}${getRootPath()}`,
root: getRootPath(),
owner: getCurrentUser()?.uid || null,
permissions: Permission.READ,
}),

View file

@ -2,10 +2,10 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ContentsWithRoot, File, Folder, Node } from '@nextcloud/files'
import type { ContentsWithRoot, File, Folder } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed } from 'webdav'
import { resultToNode as davResultToNode, defaultRootPath, getDefaultPropfind } from '@nextcloud/files/dav'
import { getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav'
import { CancelablePromise } from 'cancelable-promise'
import { join } from 'path'
import logger from '../logger.ts'
@ -14,12 +14,6 @@ import { getPinia } from '../store/index.ts'
import { useSearchStore } from '../store/search.ts'
import { client } from './WebdavClient.ts'
import { searchNodes } from './WebDavSearch.ts'
/**
* Slim wrapper over `@nextcloud/files` `davResultToNode` to allow using the function with `Array.map`
*
* @param stat The result returned by the webdav library
*/
export const resultToNode = (stat: FileStat): Node => davResultToNode(stat)
/**
* Get contents implementation for the files view.
@ -49,7 +43,7 @@ export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> {
* @param path - The path to get the contents
*/
export function defaultGetContents(path: string): CancelablePromise<ContentsWithRoot> {
path = join(defaultRootPath, path)
path = join(getRootPath(), path)
const controller = new AbortController()
const propfindPayload = getDefaultPropfind()
@ -66,7 +60,7 @@ export function defaultGetContents(path: string): CancelablePromise<ContentsWith
const root = contentsResponse.data[0]
const contents = contentsResponse.data.slice(1)
if (root.filename !== path && `${root.filename}/` !== path) {
if (root?.filename !== path && `${root?.filename}/` !== path) {
logger.debug(`Exepected "${path}" but got filename "${root.filename}" instead.`)
throw new Error('Root node does not match requested path')
}
@ -99,7 +93,7 @@ async function getLocalSearch(path: string, query: string, signal: AbortSignal):
const filesStore = useFilesStore(getPinia())
let folder = filesStore.getDirectoryByPath('files', path)
if (!folder) {
const rootPath = join(defaultRootPath, path)
const rootPath = join(getRootPath(), path)
const stat = await client.stat(rootPath, { details: true }) as ResponseDataDetailed<FileStat>
folder = resultToNode(stat.data) as Folder
}

View file

@ -8,7 +8,7 @@ import type { CancelablePromise } from 'cancelable-promise'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { davRemoteURL } from '@nextcloud/files'
import { getRemoteURL } from '@nextcloud/files/dav'
import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
import { dirname, encodePath, joinPaths } from '@nextcloud/paths'
import { generateOcsUrl } from '@nextcloud/router'
@ -34,7 +34,7 @@ export interface TreeNode {
export const folderTreeId = 'folders'
export const sourceRoot = `${davRemoteURL}/files/${getCurrentUser()?.uid}`
export const sourceRoot = `${getRemoteURL()}/files/${getCurrentUser()?.uid}`
const collator = Intl.Collator(
[getLanguage(), getCanonicalLocale()],

View file

@ -4,7 +4,7 @@
*/
import type { Node } from '@nextcloud/files'
import { registerDavProperty } from '@nextcloud/files'
import { registerDavProperty } from '@nextcloud/files/dav'
/**
*

View file

@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ContentsWithRoot, Node } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed, SearchResult } from 'webdav'
import type { ResponseDataDetailed, SearchResult } from 'webdav'
import { getCurrentUser } from '@nextcloud/auth'
import { davGetRecentSearch, davRemoteURL, davResultToNode, davRootPath, Folder, Permission } from '@nextcloud/files'
import { getBaseUrl } from '@nextcloud/router'
import { Folder, Permission } from '@nextcloud/files'
import { getRecentSearch, getRemoteURL, getRootPath, resultToNode } from '@nextcloud/files/dav'
import { CancelablePromise } from 'cancelable-promise'
import { getPinia } from '../store/index.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
@ -15,14 +15,6 @@ import { client } from './WebdavClient.ts'
const lastTwoWeeksTimestamp = Math.round((Date.now() / 1000) - (60 * 60 * 24 * 14))
/**
* Helper to map a WebDAV result to a Nextcloud node
* The search endpoint already includes the dav remote URL so we must not include it in the source
*
* @param stat the WebDAV result
*/
const resultToNode = (stat: FileStat) => davResultToNode(stat, davRootPath, getBaseUrl())
/**
* Get recently changed nodes
*
@ -48,18 +40,22 @@ export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> {
const contentsResponse = await client.search('/', {
signal: controller.signal,
details: true,
data: davGetRecentSearch(lastTwoWeeksTimestamp),
data: getRecentSearch(lastTwoWeeksTimestamp),
}) as ResponseDataDetailed<SearchResult>
const contents = contentsResponse.data.results
.map(resultToNode)
.map((stat) => {
// The search endpoint already includes the dav remote URL so we must not include it in the source
stat.filename = stat.filename.replace('/remote.php/dav', '')
return resultToNode(stat)
})
.filter(filterHidden)
return {
folder: new Folder({
id: 0,
source: `${davRemoteURL}${davRootPath}`,
root: davRootPath,
source: `${getRemoteURL()}${getRootPath()}`,
root: getRootPath(),
owner: getCurrentUser()?.uid || null,
permissions: Permission.READ,
}),

View file

@ -13,7 +13,11 @@ vi.mock('./WebDavSearch.ts', () => ({ searchNodes }))
vi.mock('@nextcloud/auth')
describe('Search service', () => {
const fakeFolder = new Folder({ owner: 'owner', source: 'https://cloud.example.com/remote.php/dav/files/owner/folder', root: '/files/owner' })
const fakeFolder = new Folder({
owner: 'owner',
source: 'https://cloud.example.com/remote.php/dav/files/owner/folder',
root: '/files/owner',
})
beforeAll(() => {
window.OCP ??= {}

View file

@ -7,7 +7,7 @@ import type { ContentsWithRoot } from '@nextcloud/files'
import { getCurrentUser } from '@nextcloud/auth'
import { Folder, Permission } from '@nextcloud/files'
import { defaultRemoteURL } from '@nextcloud/files/dav'
import { defaultRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { CancelablePromise } from 'cancelable-promise'
import logger from '../logger.ts'
import { getPinia } from '../store/index.ts'
@ -30,9 +30,10 @@ export function getContents(): CancelablePromise<ContentsWithRoot> {
contents,
folder: new Folder({
id: 0,
source: `${defaultRemoteURL}#search`,
source: `${defaultRemoteURL}${getRootPath()}}#search`,
owner: getCurrentUser()!.uid,
permissions: Permission.READ,
root: getRootPath(),
}),
})
} catch (error) {

View file

@ -18,7 +18,12 @@ describe('Path store', () => {
beforeEach(() => {
setActivePinia(createPinia())
root = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/', id: 1 })
root = new Folder({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/',
id: 1,
root: '/files/test',
})
files = useFilesStore()
files.setRoot({ service: 'files', root })
@ -30,7 +35,12 @@ describe('Path store', () => {
expect(store.paths).toEqual({})
// create the folder
const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
const node = new Folder({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/folder',
id: 2,
root: '/files/test',
})
emit('files:node:created', node)
// see that the path is added
@ -45,7 +55,13 @@ describe('Path store', () => {
expect(store.paths).toEqual({})
// create the file
const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
const node = new File({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/file.txt',
id: 2,
mime: 'text/plain',
root: '/files/test',
})
emit('files:node:created', node)
// see that there are still no paths
@ -60,7 +76,13 @@ describe('Path store', () => {
expect(store.paths).toEqual({})
// create the file
const node1 = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
const node1 = new File({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/file.txt',
id: 2,
mime: 'text/plain',
root: '/files/test',
})
emit('files:node:created', node1)
// see that there are still no paths
@ -70,7 +92,13 @@ describe('Path store', () => {
expect(root._children).toEqual([node1.source])
// create the same named file again
const node2 = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
const node2 = new File({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/file.txt',
id: 2,
mime: 'text/plain',
root: '/files/test',
})
emit('files:node:created', node2)
// see that there are still no paths and the children are not duplicated
@ -83,7 +111,12 @@ describe('Path store', () => {
expect(store.paths).toEqual({})
// create the file
const node1 = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
const node1 = new Folder({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/folder',
id: 2,
root: '/files/test',
})
emit('files:node:created', node1)
// see the path is added
@ -93,7 +126,12 @@ describe('Path store', () => {
expect(root._children).toEqual([node1.source])
// create the same named file again
const node2 = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
const node2 = new Folder({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/folder',
id: 2,
root: '/files/test',
})
emit('files:node:created', node2)
// see that there is still only one paths and the children are not duplicated
@ -102,7 +140,12 @@ describe('Path store', () => {
})
test('Folder is deleted', () => {
const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
const node = new Folder({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/folder',
id: 2,
root: '/files/test',
})
emit('files:node:created', node)
// see that the path is added and the children are set-up
expect(store.paths).toEqual({ files: { [node.path]: node.source } })
@ -116,7 +159,13 @@ describe('Path store', () => {
})
test('File is deleted', () => {
const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
const node = new File({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/file.txt',
id: 2,
mime: 'text/plain',
root: '/files/test',
})
emit('files:node:created', node)
// see that the children are set-up
expect(root._children).toEqual([node.source])
@ -127,7 +176,12 @@ describe('Path store', () => {
})
test('Folder is moved', () => {
const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
const node = new Folder({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/folder',
id: 2,
root: '/files/test',
})
emit('files:node:created', node)
// see that the path is added and the children are set-up
expect(store.paths).toEqual({ files: { [node.path]: node.source } })
@ -141,13 +195,21 @@ describe('Path store', () => {
emit('files:node:moved', { node: renamedNode, oldSource: node.source })
// See the path is updated
expect(store.paths).toEqual({ files: { [renamedNode.path]: renamedNode.source } })
expect(store.paths).toEqual({
files: { [renamedNode.path]: renamedNode.source },
})
// See the child is updated
expect(root._children).toEqual([renamedNode.source])
})
test('File is moved', () => {
const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
const node = new File({
owner: 'test',
source: 'http://example.com/remote.php/dav/files/test/file.txt',
id: 2,
mime: 'text/plain',
root: '/files/test',
})
emit('files:node:created', node)
// see that the children are set-up
expect(root._children).toEqual([node.source])

View file

@ -112,7 +112,12 @@ export function usePathsStore(...args) {
}
// Dummy simple clone of the renamed node from a previous state
const oldNode = new File({ source: oldSource, owner: node.owner, mime: node.mime })
const oldNode = new File({
source: oldSource,
owner: node.owner,
mime: node.mime,
root: node.root,
})
this.deleteNodeFromParentChildren(oldNode)
this.addNodeToParentChildren(node)

View file

@ -2,7 +2,7 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { FileAction } from '@nextcloud/files'
import type { ActionContextSingle, FileAction } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { NodeStatus } from '@nextcloud/files'
@ -10,7 +10,6 @@ import { t } from '@nextcloud/l10n'
import Vue from 'vue'
import logger from '../logger.ts'
import { useActiveStore } from '../store/active.ts'
import { getPinia } from '../store/index.ts'
/**
* Execute an action on the current active node
@ -18,13 +17,13 @@ import { getPinia } from '../store/index.ts'
* @param action The action to execute
*/
export async function executeAction(action: FileAction) {
const activeStore = useActiveStore(getPinia())
const currentDir = (window?.OCP?.Files?.Router?.query?.dir || '/') as string
const activeStore = useActiveStore()
const currentFolder = activeStore.activeFolder
const currentNode = activeStore.activeNode
const currentView = activeStore.activeView
if (!currentNode || !currentView) {
logger.error('No active node or view', { node: currentNode, view: currentView })
if (!currentFolder || !currentNode || !currentView) {
logger.error('No active folder, node or view', { folder: currentFolder, node: currentNode, view: currentView })
return
}
@ -33,14 +32,23 @@ export async function executeAction(action: FileAction) {
return
}
if (!action.enabled!([currentNode], currentView)) {
// @ts-expect-error _children is private
const contents = currentFolder?._children || []
const context = {
nodes: [currentNode],
view: currentView,
folder: currentFolder,
contents,
} as ActionContextSingle
if (!action.enabled!(context)) {
logger.debug('Action is not not available for the current context', { action, node: currentNode, view: currentView })
return
}
let displayName = action.id
try {
displayName = action.displayName([currentNode], currentView)
displayName = action.displayName(context)
} catch (error) {
logger.error('Error while getting action display name', { action, error })
}
@ -50,7 +58,7 @@ export async function executeAction(action: FileAction) {
Vue.set(currentNode, 'status', NodeStatus.LOADING)
activeStore.activeAction = action
const success = await action.exec(currentNode, currentView, currentDir)
const success = await action.exec(context)
// If the action returns null, we stay silent
if (success === null || success === undefined) {

View file

@ -499,11 +499,11 @@ export default defineComponent({
if (action.enabled === undefined) {
return true
}
return action.enabled(
this.currentView!,
this.dirContents,
this.currentFolder as Folder,
)
return action.enabled({
view: this.currentView!,
folder: this.currentFolder!,
contents: this.dirContents,
})
})
.toSorted((a, b) => a.order - b.order)
return enabledActions
@ -793,7 +793,12 @@ export default defineComponent({
if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
window.OCA.Files.Sidebar.setActiveTab('sharing')
}
sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path)
sidebarAction.exec({
nodes: [this.source],
view: this.currentView,
folder: this.currentFolder,
contents: this.dirContents,
})
},
toggleGridView() {
@ -823,7 +828,12 @@ export default defineComponent({
const displayName = this.actionDisplayName(action)
try {
const success = await action.exec(this.source, this.dirContents, this.currentDir)
const success = await action.exec({
nodes: [this.source],
view: this.currentView,
folder: this.currentFolder,
contents: this.dirContents,
})
// If the action returns null, we stay silent
if (success === null || success === undefined) {
return

View file

@ -112,7 +112,8 @@ import axios from '@nextcloud/axios'
import { getCapabilities } from '@nextcloud/capabilities'
import { showError } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { davRemoteURL, davRootPath, File, Folder, formatFileSize } from '@nextcloud/files'
import { File, Folder, formatFileSize } from '@nextcloud/files'
import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { encodePath } from '@nextcloud/paths'
import { generateUrl } from '@nextcloud/router'
import { ShareType } from '@nextcloud/sharing'
@ -210,7 +211,7 @@ export default defineComponent({
* @return {string}
*/
davPath() {
return `${davRemoteURL}${davRootPath}${encodePath(this.file)}`
return `${getRemoteURL()}${getRootPath()}${encodePath(this.file)}`
},
/**
@ -342,7 +343,7 @@ export default defineComponent({
this.handleWindowResize()
},
beforeDestroy() {
beforeUnmount() {
unsubscribe('file:node:deleted', this.onNodeDeleted)
window.removeEventListener('resize', this.handleWindowResize)
},
@ -451,9 +452,10 @@ export default defineComponent({
const isDir = this.fileInfo.type === 'dir'
const Node = isDir ? Folder : File
const node = new Node({
fileid: this.fileInfo.id,
source: `${davRemoteURL}${davRootPath}${this.file}`,
root: davRootPath,
id: this.fileInfo.id,
source: `${getRemoteURL()}${getRootPath()}${this.file}`,
root: getRootPath(),
owner: null,
mime: isDir ? undefined : this.fileInfo.mimetype,
attributes: {
favorite: 1,

View file

@ -157,10 +157,16 @@ describe('Dynamic update of favorite folders', () => {
id: 1,
source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
owner: 'admin',
root: '/files/admin',
})
// Exec the action
await action.exec(folder, favoritesView, '/')
await action.exec({
nodes: [folder],
view: favoritesView,
folder: {} as CFolder,
contents: [],
})
expect(eventBus.emit).toHaveBeenCalledTimes(1)
expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder)
@ -202,7 +208,12 @@ describe('Dynamic update of favorite folders', () => {
eventBus.subscribe('files:favorites:removed', fo)
// Exec the action
await action.exec(folder, favoritesView, '/')
await action.exec({
nodes: [folder],
view: favoritesView,
folder: {} as CFolder,
contents: [],
})
expect(eventBus.emit).toHaveBeenCalledTimes(1)
expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder)
@ -238,10 +249,16 @@ describe('Dynamic update of favorite folders', () => {
id: 1,
source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
owner: 'admin',
root: '/files/admin',
})
// Exec the action
await action.exec(folder, favoritesView, '/')
await action.exec({
nodes: [folder],
view: favoritesView,
folder: {} as CFolder,
contents: [],
})
expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder)
// Create a folder with the same id but renamed
@ -249,6 +266,7 @@ describe('Dynamic update of favorite folders', () => {
id: 1,
source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar.renamed',
owner: 'admin',
root: '/files/admin',
})
// Exec the rename action

View file

@ -38,11 +38,26 @@ describe('Enter credentials action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('credentials-external-storage')
expect(action.displayName([storage], externalStorageView)).toBe('Enter missing credentials')
expect(action.iconSvgInline([storage], externalStorageView)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
view: externalStorageView,
nodes: [storage],
folder: {} as Folder,
contents: [],
})).toBe('Enter missing credentials')
expect(action.iconSvgInline({
view: externalStorageView,
nodes: [storage],
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBe(DefaultType.DEFAULT)
expect(action.order).toBe(-1000)
expect(action.inline!(storage, externalStorageView)).toBe(true)
expect(action.inline!({
view: externalStorageView,
nodes: [storage],
folder: {} as Folder,
contents: [],
})).toBe(true)
})
})
@ -119,31 +134,61 @@ describe('Enter credentials action enabled tests', () => {
test('Disabled with on success storage', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([storage], externalStorageView)).toBe(false)
expect(action.enabled!({
nodes: [storage],
view: externalStorageView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled for multiple nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([storage, storage], view)).toBe(false)
expect(action.enabled!({
nodes: [storage, storage],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Enabled for missing user auth storage', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([userProvidedStorage], view)).toBe(true)
expect(action.enabled!({
nodes: [userProvidedStorage],
view: externalStorageView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Enabled for missing global user auth storage', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([globalAuthUserStorage], view)).toBe(true)
expect(action.enabled!({
nodes: [globalAuthUserStorage],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled for missing config', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([missingConfig], view)).toBe(false)
expect(action.enabled!({
nodes: [missingConfig],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled for normal nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([notAStorage], view)).toBe(false)
expect(action.enabled!({
nodes: [notAStorage],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})

View file

@ -66,9 +66,9 @@ export const action = new FileAction({
displayName: () => t('files', 'Enter missing credentials'),
iconSvgInline: () => LoginSvg,
enabled: (nodes: Node[]) => {
enabled: ({ nodes }) => {
// Only works on single node
if (nodes.length !== 1) {
if (nodes.length !== 1 || !nodes[0]) {
return false
}
@ -85,7 +85,7 @@ export const action = new FileAction({
return false
},
async exec(node: Node) {
async exec({ nodes }) {
const { login, password } = await new Promise<CredentialResponse>((resolve) => spawnDialog(
defineAsyncComponent(() => import('../views/CredentialsDialog.vue')),
{},
@ -96,7 +96,7 @@ export const action = new FileAction({
if (login && password) {
try {
await setCredentials(node, login, password)
await setCredentials(nodes[0], login, password)
showSuccess(t('files_external', 'Credentials successfully set'))
} catch (error) {
showError(t('files_external', 'Error while setting credentials: {error}', {

View file

@ -4,7 +4,6 @@
*/
import type { AxiosError } from '@nextcloud/axios'
import type { Node } from '@nextcloud/files'
import type { StorageConfig } from '../services/externalStorage.ts'
import AlertSvg from '@mdi/svg/svg/alert-circle.svg?raw'
@ -23,7 +22,7 @@ export const action = new FileAction({
displayName: () => '',
iconSvgInline: () => '',
enabled: (nodes: Node[]) => {
enabled: ({ nodes }) => {
return nodes.every((node) => isNodeExternalStorage(node) === true)
},
exec: async () => null,
@ -34,7 +33,12 @@ export const action = new FileAction({
*
* @param node The node to render inline
*/
async renderInline(node: Node) {
async renderInline({ nodes }) {
if (nodes.length !== 1 || !nodes[0]) {
return null
}
const node = nodes[0]
const span = document.createElement('span')
span.className = 'files-list__row-status'
span.innerHTML = t('files_external', 'Checking storage …')

View file

@ -38,8 +38,18 @@ describe('Open in files action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('open-in-files-external-storage')
expect(action.displayName([storage], externalStorageView)).toBe('Open in Files')
expect(action.iconSvgInline([storage], externalStorageView)).toBe('')
expect(action.displayName({
nodes: [storage],
view: externalStorageView,
folder: {} as Folder,
contents: [],
})).toBe('Open in Files')
expect(action.iconSvgInline({
nodes: [storage],
view: externalStorageView,
folder: {} as Folder,
contents: [],
})).toBe('')
expect(action.default).toBe(DefaultType.HIDDEN)
expect(action.order).toBe(-1000)
expect(action.inline).toBeUndefined()
@ -58,19 +68,34 @@ describe('Open in files action conditions tests', () => {
} as StorageConfig,
},
})
expect(action.displayName([failingStorage], externalStorageView)).toBe('Examine this faulty external storage configuration')
expect(action.displayName({
nodes: [failingStorage],
view: externalStorageView,
folder: {} as Folder,
contents: [],
})).toBe('Examine this faulty external storage configuration')
})
})
describe('Open in files action enabled tests', () => {
test('Enabled with on valid view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], externalStorageView)).toBe(true)
expect(action.enabled!({
nodes: [],
view: externalStorageView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled on wrong view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -92,7 +117,12 @@ describe('Open in files action execute tests', () => {
},
})
const exec = await action.exec(storage, externalStorageView, '/')
const exec = await action.exec({
nodes: [storage],
view: externalStorageView,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
@ -116,7 +146,12 @@ describe('Open in files action execute tests', () => {
},
})
const exec = await action.exec(storage, externalStorageView, '/')
const exec = await action.exec({
nodes: [storage],
view: externalStorageView,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
expect(confirmMock).toBeCalledTimes(1)

View file

@ -2,7 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import type { StorageConfig } from '../services/externalStorage.ts'
import { getCurrentUser } from '@nextcloud/auth'
@ -13,7 +12,7 @@ import { STORAGE_STATUS } from '../utils/credentialsUtils.ts'
export const action = new FileAction({
id: 'open-in-files-external-storage',
displayName: (nodes: Node[]) => {
displayName: ({ nodes }) => {
const config = nodes?.[0]?.attributes?.config as StorageConfig || { status: STORAGE_STATUS.INDETERMINATE }
if (config.status !== STORAGE_STATUS.SUCCESS) {
return t('files_external', 'Examine this faulty external storage configuration')
@ -22,10 +21,10 @@ export const action = new FileAction({
},
iconSvgInline: () => '',
enabled: (nodes: Node[], view) => view.id === 'extstoragemounts',
enabled: ({ view }) => view.id === 'extstoragemounts',
async exec(node: Node) {
const config = node.attributes.config as StorageConfig
async exec({ nodes }) {
const config = nodes[0]?.attributes?.config as StorageConfig
if (config?.status !== STORAGE_STATUS.SUCCESS) {
window.OC.dialogs.confirm(
t('files_external', 'There was an error with this external storage. Do you want to review this mount point config in the settings page?'),
@ -45,7 +44,7 @@ export const action = new FileAction({
window.OCP.Files.Router.goToRoute(
null, // use default route
{ view: 'files' },
{ dir: node.path },
{ dir: nodes[0].path },
)
return null
},

View file

@ -18,6 +18,7 @@ describe('Is node an external storage', () => {
scope: 'personal',
backend: 'SFTP',
},
root: '/files/admin',
})
expect(isNodeExternalStorage(folder)).toBe(true)
})
@ -29,6 +30,7 @@ describe('Is node an external storage', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(isNodeExternalStorage(file)).toBe(false)
})
@ -42,6 +44,7 @@ describe('Is node an external storage', () => {
attributes: {
scope: 'personal',
},
root: '/files/admin',
})
expect(isNodeExternalStorage(folder)).toBe(false)
})
@ -55,6 +58,7 @@ describe('Is node an external storage', () => {
attributes: {
backend: 'SFTP',
},
root: '/files/admin',
})
expect(isNodeExternalStorage(folder)).toBe(false)
})
@ -69,6 +73,7 @@ describe('Is node an external storage', () => {
scope: 'null',
backend: 'SFTP',
},
root: '/files/admin',
})
expect(isNodeExternalStorage(folder)).toBe(false)
})

View file

@ -9,6 +9,14 @@ import { Folder } from '@nextcloud/files'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { action } from './clearReminderAction.ts'
const view = {} as unknown as View
const root = new Folder({
owner: 'user',
source: 'https://example.com/remote.php/dav/files/user/',
root: '/files/user',
})
describe('clearReminderAction', () => {
const folder = new Folder({
owner: 'user',
@ -16,25 +24,46 @@ describe('clearReminderAction', () => {
attributes: {
'reminder-due-date': '2024-12-25T10:00:00Z',
},
root: '/files/user',
})
beforeEach(() => vi.resetAllMocks())
it('should be enabled for one node with due date', () => {
expect(action.enabled!([folder], {} as unknown as View)).toBe(true)
expect(action.enabled!({
nodes: [folder],
view,
folder: root,
contents: [],
})).toBe(true)
})
it('should be disabled with more than one node', () => {
expect(action.enabled!([folder, folder], {} as unknown as View)).toBe(false)
expect(action.enabled!({
nodes: [folder, folder],
view,
folder: root,
contents: [],
})).toBe(false)
})
it('should be disabled if no due date', () => {
const node = folder.clone()
delete node.attributes['reminder-due-date']
expect(action.enabled!([node], {} as unknown as View)).toBe(false)
expect(action.enabled!({
nodes: [node],
view,
folder: root,
contents: [],
})).toBe(false)
})
it('should have title based on due date', () => {
expect(action.title!([folder], {} as unknown as View)).toMatchInlineSnapshot('"Clear reminder Wednesday, December 25, 2024 at 10:00 AM"')
expect(action.title!({
nodes: [folder],
view,
folder: root,
contents: [],
})).toMatchInlineSnapshot('"Clear reminder Wednesday, December 25, 2024 at 10:00 AM"')
})
})

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
import AlarmOffSvg from '@mdi/svg/svg/alarm-off.svg?raw'
import { emit } from '@nextcloud/event-bus'
import { FileAction } from '@nextcloud/files'
@ -17,7 +14,7 @@ export const action = new FileAction({
displayName: () => t('files_reminders', 'Clear reminder'),
title: (nodes: INode[]) => {
title: ({ nodes }) => {
const node = nodes.at(0)!
const dueDate = new Date(node.attributes['reminder-due-date'])
return `${t('files_reminders', 'Clear reminder')} ${getVerboseDateString(dueDate)}`
@ -25,7 +22,7 @@ export const action = new FileAction({
iconSvgInline: () => AlarmOffSvg,
enabled: (nodes: INode[]) => {
enabled: ({ nodes }) => {
// Only allow on a single node
if (nodes.length !== 1) {
return false
@ -35,7 +32,8 @@ export const action = new FileAction({
return Boolean(dueDate)
},
async exec(node: INode) {
async exec({ nodes }) {
const node = nodes.at(0)!
if (node.fileid) {
try {
await clearReminder(node.fileid)

View file

@ -9,6 +9,14 @@ import { Folder } from '@nextcloud/files'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { action } from './reminderStatusAction.ts'
const view = {} as unknown as View
const root = new Folder({
owner: 'user',
source: 'https://example.com/remote.php/dav/files/user/',
root: '/files/user',
})
describe('reminderStatusAction', () => {
const folder = new Folder({
owner: 'user',
@ -16,25 +24,46 @@ describe('reminderStatusAction', () => {
attributes: {
'reminder-due-date': '2024-12-25T10:00:00Z',
},
root: '/files/user',
})
beforeEach(() => vi.resetAllMocks())
it('should be enabled for one node with due date', () => {
expect(action.enabled!([folder], {} as unknown as View)).toBe(true)
expect(action.enabled!({
nodes: [folder],
view,
folder: root,
contents: [],
})).toBe(true)
})
it('should be disabled with more than one node', () => {
expect(action.enabled!([folder, folder], {} as unknown as View)).toBe(false)
expect(action.enabled!({
nodes: [folder, folder],
view,
folder: root,
contents: [],
})).toBe(false)
})
it('should be disabled if no due date', () => {
const node = folder.clone()
delete node.attributes['reminder-due-date']
expect(action.enabled!([node], {} as unknown as View)).toBe(false)
expect(action.enabled!({
nodes: [node],
view,
folder: root,
contents: [],
})).toBe(false)
})
it('should have title based on due date', () => {
expect(action.title!([folder], {} as unknown as View)).toMatchInlineSnapshot('"Reminder set Wednesday, December 25, 2024 at 10:00 AM"')
expect(action.title!({
nodes: [folder],
view,
folder: root,
contents: [],
})).toMatchInlineSnapshot('"Reminder set Wednesday, December 25, 2024 at 10:00 AM"')
})
})

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
import AlarmSvg from '@mdi/svg/svg/alarm.svg?raw'
import { FileAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
@ -18,7 +15,7 @@ export const action = new FileAction({
displayName: () => '',
title: (nodes: INode[]) => {
title: ({ nodes }) => {
const node = nodes.at(0)!
const dueDate = new Date(node.attributes['reminder-due-date'])
return `${t('files_reminders', 'Reminder set')} ${getVerboseDateString(dueDate)}`
@ -26,17 +23,19 @@ export const action = new FileAction({
iconSvgInline: () => AlarmSvg,
enabled: (nodes: INode[]) => {
enabled: ({ nodes }) => {
// Only allow on a single node
if (nodes.length !== 1) {
return false
}
const node = nodes.at(0)!
const dueDate = node.attributes['reminder-due-date']
return Boolean(dueDate)
},
async exec(node: INode) {
async exec({ nodes }) {
const node = nodes.at(0)!
await pickCustomDate(node)
return null
},

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode, View } from '@nextcloud/files'
import CalendarClockSvg from '@mdi/svg/svg/calendar-clock.svg?raw'
import { FileAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
@ -17,7 +14,7 @@ export const action = new FileAction({
title: () => t('files_reminders', 'Reminder at custom date & time'),
iconSvgInline: () => CalendarClockSvg,
enabled: (nodes: INode[], view: View) => {
enabled: ({ nodes, view }) => {
if (view.id === 'trashbin') {
return false
}
@ -32,8 +29,9 @@ export const action = new FileAction({
parent: SET_REMINDER_MENU_ID,
async exec(file: INode) {
pickCustomDate(file)
async exec({ nodes }) {
const node = nodes.at(0)!
pickCustomDate(node)
return null
},

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode, View } from '@nextcloud/files'
import AlarmSvg from '@mdi/svg/svg/alarm.svg?raw'
import { FileAction } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
@ -16,7 +13,7 @@ export const action = new FileAction({
displayName: () => t('files_reminders', 'Set reminder'),
iconSvgInline: () => AlarmSvg,
enabled: (nodes: INode[], view: View) => {
enabled: ({ nodes, view }) => {
if (view.id === 'trashbin') {
return false
}

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode, View } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { FileAction } from '@nextcloud/files'
@ -72,7 +69,7 @@ function generateFileAction(option: ReminderOption): FileAction | null {
// Empty svg to hide the icon
iconSvgInline: () => '<svg></svg>',
enabled: (nodes: INode[], view: View) => {
enabled: ({ nodes, view }) => {
if (view.id === 'trashbin') {
return false
}
@ -87,8 +84,9 @@ function generateFileAction(option: ReminderOption): FileAction | null {
parent: SET_REMINDER_MENU_ID,
async exec(node: INode) {
async exec({ nodes }) {
// Can't really happen, but just in case™
const node = nodes.at(0)!
if (!node.fileid) {
logger.error('Failed to set reminder, missing file id')
showError(t('files_reminders', 'Failed to set reminder'))

View file

@ -0,0 +1,20 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { registerFileAction } from '@nextcloud/files'
import { registerDavProperty } from '@nextcloud/files/dav'
import { action as clearAction } from './files_actions/clearReminderAction.ts'
import { action as statusAction } from './files_actions/reminderStatusAction.ts'
import { action as customAction } from './files_actions/setReminderCustomAction.ts'
import { action as menuAction } from './files_actions/setReminderMenuAction.ts'
import { actions as suggestionActions } from './files_actions/setReminderSuggestionActions.ts'
registerDavProperty('nc:reminder-due-date', { nc: 'http://nextcloud.org/ns' })
registerFileAction(statusAction)
registerFileAction(clearAction)
registerFileAction(menuAction)
registerFileAction(customAction)
suggestionActions.forEach((action) => registerFileAction(action))

View file

@ -1,4 +1,4 @@
import type { View } from '@nextcloud/files'
import type { Folder, View } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import * as eventBus from '@nextcloud/event-bus'
@ -38,16 +38,32 @@ describe('Accept share action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('accept-share')
expect(action.displayName([file], pendingShareView)).toBe('Accept share')
expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe('Accept share')
expect(action.iconSvgInline({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(1)
expect(action.inline).toBeDefined()
expect(action.inline!(file, pendingShareView)).toBe(true)
expect(action.inline!({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Default values for multiple files', () => {
@ -57,6 +73,7 @@ describe('Accept share action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
const file2 = new File({
id: 2,
@ -64,9 +81,15 @@ describe('Accept share action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.displayName([file1, file2], pendingShareView)).toBe('Accept shares')
expect(action.displayName({
nodes: [file1, file2],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe('Accept shares')
})
})
@ -78,20 +101,36 @@ describe('Accept share action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], pendingShareView)).toBe(true)
expect(action.enabled!({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled on wrong view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled without nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], pendingShareView)).toBe(false)
expect(action.enabled!({
nodes: [],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -114,9 +153,15 @@ describe('Accept share action execute tests', () => {
id: 123,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.exec(file, pendingShareView, '/')
const exec = await action.exec({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
expect(axios.post).toBeCalledTimes(1)
@ -141,9 +186,15 @@ describe('Accept share action execute tests', () => {
remote: 3,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.exec(file, pendingShareView, '/')
const exec = await action.exec({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
expect(axios.post).toBeCalledTimes(1)
@ -167,6 +218,7 @@ describe('Accept share action execute tests', () => {
id: 123,
share_type: ShareType.User,
},
root: '/files/admin',
})
const file2 = new File({
@ -179,9 +231,15 @@ describe('Accept share action execute tests', () => {
id: 456,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.execBatch!([file1, file2], pendingShareView, '/')
const exec = await action.execBatch!({
nodes: [file1, file2],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toStrictEqual([true, true])
expect(axios.post).toBeCalledTimes(2)
@ -208,9 +266,15 @@ describe('Accept share action execute tests', () => {
id: 123,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.exec(file, pendingShareView, '/')
const exec = await action.exec({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
expect(axios.post).toBeCalledTimes(1)

View file

@ -2,8 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import CheckSvg from '@mdi/svg/svg/check.svg?raw'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
@ -14,13 +12,14 @@ import { pendingSharesViewId } from '../files_views/shares.ts'
export const action = new FileAction({
id: 'accept-share',
displayName: (nodes: Node[]) => n('files_sharing', 'Accept share', 'Accept shares', nodes.length),
displayName: ({ nodes }) => n('files_sharing', 'Accept share', 'Accept shares', nodes.length),
iconSvgInline: () => CheckSvg,
enabled: (nodes, view) => nodes.length > 0 && view.id === pendingSharesViewId,
enabled: ({ nodes, view }) => nodes.length > 0 && view.id === pendingSharesViewId,
async exec(node: Node) {
async exec({ nodes }) {
try {
const node = nodes[0]
const isRemote = !!node.attributes.remote
const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/pending/{id}', {
shareBase: isRemote ? 'remote_shares' : 'shares',
@ -36,8 +35,13 @@ export const action = new FileAction({
return false
}
},
async execBatch(nodes: Node[], view: View, dir: string) {
return Promise.all(nodes.map((node) => this.exec(node, view, dir)))
async execBatch({ nodes, view, folder, contents }) {
return Promise.all(nodes.map((node) => this.exec({
nodes: [node],
view,
folder,
contents,
})))
},
order: 1,

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { Folder, View } from '@nextcloud/files'
import { DefaultType, File, FileAction, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
@ -33,8 +33,18 @@ describe('Open in files action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('files_sharing:open-in-files')
expect(action.displayName([], validViews[0])).toBe('Open in Files')
expect(action.iconSvgInline([], validViews[0])).toBe('')
expect(action.displayName({
nodes: [],
view: validViews[0]!,
folder: {} as Folder,
contents: [],
})).toBe('Open in Files')
expect(action.iconSvgInline({
nodes: [],
view: validViews[0]!,
folder: {} as Folder,
contents: [],
})).toBe('')
expect(action.default).toBe(DefaultType.HIDDEN)
expect(action.order).toBe(-1000)
expect(action.inline).toBeUndefined()
@ -45,14 +55,24 @@ describe('Open in files action enabled tests', () => {
test('Enabled with on valid view', () => {
validViews.forEach((view) => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(true)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
})
test('Disabled on wrong view', () => {
invalidViews.forEach((view) => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
})
@ -71,7 +91,12 @@ describe('Open in files action execute tests', () => {
permissions: Permission.READ,
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import { DefaultType, FileAction, FileType, registerFileAction } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../files_views/shares.ts'
@ -14,7 +11,7 @@ export const action = new FileAction({
displayName: () => t('files_sharing', 'Open in Files'),
iconSvgInline: () => '',
enabled: (nodes, view) => [
enabled: ({ view }) => [
sharesViewId,
sharedWithYouViewId,
sharedWithOthersViewId,
@ -23,18 +20,18 @@ export const action = new FileAction({
// accessible in the files app.
].includes(view.id),
async exec(node: Node) {
const isFolder = node.type === FileType.Folder
async exec({ nodes }) {
const isFolder = nodes[0].type === FileType.Folder
window.OCP.Files.Router.goToRoute(
null, // use default route
{
view: 'files',
fileid: String(node.fileid),
fileid: String(nodes[0].fileid),
},
{
// If this node is a folder open the folder in files
dir: isFolder ? node.path : node.dirname,
dir: isFolder ? nodes[0].path : nodes[0].dirname,
// otherwise if this is a file, we should open it
openfile: isFolder ? undefined : 'true',
},

View file

@ -39,16 +39,32 @@ describe('Reject share action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('reject-share')
expect(action.displayName([file], pendingShareView)).toBe('Reject share')
expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe('Reject share')
expect(action.iconSvgInline({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(2)
expect(action.inline).toBeDefined()
expect(action.inline!(file, pendingShareView)).toBe(true)
expect(action.inline!({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Default values for multiple files', () => {
@ -58,6 +74,7 @@ describe('Reject share action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
const file2 = new File({
id: 2,
@ -65,9 +82,15 @@ describe('Reject share action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.displayName([file1, file2], pendingShareView)).toBe('Reject shares')
expect(action.displayName({
nodes: [file1, file2],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe('Reject shares')
})
})
@ -79,20 +102,36 @@ describe('Reject share action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], pendingShareView)).toBe(true)
expect(action.enabled!({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled on wrong view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled without nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], pendingShareView)).toBe(false)
expect(action.enabled!({
nodes: [],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled if some nodes are remote group shares', () => {
@ -104,6 +143,7 @@ describe('Reject share action enabled tests', () => {
attributes: {
share_type: ShareType.User,
},
root: '/files/admin',
})
const folder2 = new Folder({
id: 2,
@ -114,12 +154,28 @@ describe('Reject share action enabled tests', () => {
remote_id: 1,
share_type: ShareType.RemoteGroup,
},
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([folder1], pendingShareView)).toBe(true)
expect(action.enabled!([folder2], pendingShareView)).toBe(false)
expect(action.enabled!([folder1, folder2], pendingShareView)).toBe(false)
expect(action.enabled!({
nodes: [folder1],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe(true)
expect(action.enabled!({
nodes: [folder2],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe(false)
expect(action.enabled!({
nodes: [folder1, folder2],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -142,9 +198,15 @@ describe('Reject share action execute tests', () => {
id: 123,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.exec(file, pendingShareView, '/')
const exec = await action.exec({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
expect(axios.delete).toBeCalledTimes(1)
@ -169,9 +231,15 @@ describe('Reject share action execute tests', () => {
remote: 3,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.exec(file, pendingShareView, '/')
const exec = await action.exec({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
expect(axios.delete).toBeCalledTimes(1)
@ -195,6 +263,7 @@ describe('Reject share action execute tests', () => {
id: 123,
share_type: ShareType.User,
},
root: '/files/admin',
})
const file2 = new File({
@ -207,9 +276,15 @@ describe('Reject share action execute tests', () => {
id: 456,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.execBatch!([file1, file2], pendingShareView, '/')
const exec = await action.execBatch!({
nodes: [file1, file2],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toStrictEqual([true, true])
expect(axios.delete).toBeCalledTimes(2)
@ -236,9 +311,15 @@ describe('Reject share action execute tests', () => {
id: 123,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.exec(file, pendingShareView, '/')
const exec = await action.exec({
nodes: [file],
view: pendingShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
expect(axios.delete).toBeCalledTimes(1)

View file

@ -2,8 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import CloseSvg from '@mdi/svg/svg/close.svg?raw'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
@ -15,10 +13,10 @@ import { pendingSharesViewId } from '../files_views/shares.ts'
export const action = new FileAction({
id: 'reject-share',
displayName: (nodes: Node[]) => n('files_sharing', 'Reject share', 'Reject shares', nodes.length),
displayName: ({ nodes }) => n('files_sharing', 'Reject share', 'Reject shares', nodes.length),
iconSvgInline: () => CloseSvg,
enabled: (nodes, view) => {
enabled: ({ nodes, view }) => {
if (view.id !== pendingSharesViewId) {
return false
}
@ -37,8 +35,9 @@ export const action = new FileAction({
return true
},
async exec(node: Node) {
async exec({ nodes }) {
try {
const node = nodes[0]
const isRemote = !!node.attributes.remote
const shareBase = isRemote ? 'remote_shares' : 'shares'
const id = node.attributes.id
@ -64,8 +63,8 @@ export const action = new FileAction({
return false
}
},
async execBatch(nodes: Node[], view: View, dir: string) {
return Promise.all(nodes.map((node) => this.exec(node, view, dir)))
async execBatch({ nodes, view, folder, contents }) {
return Promise.all(nodes.map((node) => this.exec({ nodes: [node], view, folder, contents })))
},
order: 2,

View file

@ -2,7 +2,7 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { Folder, View } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import * as eventBus from '@nextcloud/event-bus'
@ -39,16 +39,32 @@ describe('Restore share action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('restore-share')
expect(action.displayName([file], deletedShareView)).toBe('Restore share')
expect(action.iconSvgInline([file], deletedShareView)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
view: deletedShareView,
nodes: [file],
folder: {} as Folder,
contents: [],
})).toBe('Restore share')
expect(action.iconSvgInline({
view: deletedShareView,
nodes: [file],
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(1)
expect(action.inline).toBeDefined()
expect(action.inline!(file, deletedShareView)).toBe(true)
expect(action.inline!({
view: deletedShareView,
nodes: [file],
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Default values for multiple files', () => {
@ -58,6 +74,7 @@ describe('Restore share action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
const file2 = new File({
id: 2,
@ -65,9 +82,15 @@ describe('Restore share action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.displayName([file1, file2], deletedShareView)).toBe('Restore shares')
expect(action.displayName({
view: deletedShareView,
nodes: [file1, file2],
folder: {} as Folder,
contents: [],
})).toBe('Restore shares')
})
})
@ -79,20 +102,36 @@ describe('Restore share action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], deletedShareView)).toBe(true)
expect(action.enabled!({
nodes: [file],
view: deletedShareView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled on wrong view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled without nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], deletedShareView)).toBe(false)
expect(action.enabled!({
nodes: [],
view: deletedShareView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
@ -115,9 +154,15 @@ describe('Restore share action execute tests', () => {
id: 123,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.exec(file, deletedShareView, '/')
const exec = await action.exec({
nodes: [file],
view: deletedShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(true)
expect(axios.post).toBeCalledTimes(1)
@ -141,6 +186,7 @@ describe('Restore share action execute tests', () => {
id: 123,
share_type: ShareType.User,
},
root: '/files/admin',
})
const file2 = new File({
@ -153,9 +199,15 @@ describe('Restore share action execute tests', () => {
id: 456,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.execBatch!([file1, file2], deletedShareView, '/')
const exec = await action.execBatch!({
nodes: [file1, file2],
view: deletedShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toStrictEqual([true, true])
expect(axios.post).toBeCalledTimes(2)
@ -181,9 +233,15 @@ describe('Restore share action execute tests', () => {
id: 123,
share_type: ShareType.User,
},
root: '/files/admin',
})
const exec = await action.exec(file, deletedShareView, '/')
const exec = await action.exec({
nodes: [file],
view: deletedShareView,
folder: {} as Folder,
contents: [],
})
expect(exec).toBe(false)
expect(axios.post).toBeCalledTimes(1)

View file

@ -2,8 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import ArrowULeftTopSvg from '@mdi/svg/svg/arrow-u-left-top.svg?raw'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
@ -14,14 +12,15 @@ import { deletedSharesViewId } from '../files_views/shares.ts'
export const action = new FileAction({
id: 'restore-share',
displayName: (nodes: Node[]) => n('files_sharing', 'Restore share', 'Restore shares', nodes.length),
displayName: ({ nodes }) => n('files_sharing', 'Restore share', 'Restore shares', nodes.length),
iconSvgInline: () => ArrowULeftTopSvg,
enabled: (nodes, view) => nodes.length > 0 && view.id === deletedSharesViewId,
enabled: ({ nodes, view }) => nodes.length > 0 && view.id === deletedSharesViewId,
async exec(node: Node) {
async exec({ nodes }) {
try {
const node = nodes[0]
const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares/{id}', {
id: node.attributes.id,
})
@ -35,8 +34,8 @@ export const action = new FileAction({
return false
}
},
async execBatch(nodes: Node[], view: View, dir: string) {
return Promise.all(nodes.map((node) => this.exec(node, view, dir)))
async execBatch({ nodes, view, folder, contents }) {
return Promise.all(nodes.map((node) => this.exec({ nodes: [node], view, folder, contents })))
},
order: 1,

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import type { Node } from '@nextcloud/files'
import AccountGroupSvg from '@mdi/svg/svg/account-group-outline.svg?raw'
import AccountPlusSvg from '@mdi/svg/svg/account-plus-outline.svg?raw'
@ -31,8 +31,8 @@ function isExternal(node: Node) {
export const ACTION_SHARING_STATUS = 'sharing-status'
export const action = new FileAction({
id: ACTION_SHARING_STATUS,
displayName(nodes: Node[]) {
const node = nodes[0]
displayName({ nodes }) {
const node = nodes[0]!
const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
if (shareTypes.length > 0
@ -43,9 +43,8 @@ export const action = new FileAction({
return ''
},
title(nodes: Node[]) {
const node = nodes[0]
title({ nodes }) {
const node = nodes[0]!
if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) {
const ownerDisplayName = node?.attributes?.['owner-display-name']
return t('files_sharing', 'Shared by {ownerDisplayName}', { ownerDisplayName })
@ -63,7 +62,7 @@ export const action = new FileAction({
}
const sharee = [sharees].flat()[0] // the property is sometimes weirdly normalized, so we need to compensate
switch (sharee.type) {
switch (sharee?.type) {
case ShareType.User:
return t('files_sharing', 'Shared with {user}', { user: sharee['display-name'] })
case ShareType.Group:
@ -73,8 +72,8 @@ export const action = new FileAction({
}
},
iconSvgInline(nodes: Node[]) {
const node = nodes[0]
iconSvgInline({ nodes }) {
const node = nodes[0]!
const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
// Mixed share types
@ -106,7 +105,7 @@ export const action = new FileAction({
return AccountPlusSvg
},
enabled(nodes: Node[]) {
enabled({ nodes }) {
if (nodes.length !== 1) {
return false
}
@ -116,7 +115,7 @@ export const action = new FileAction({
return false
}
const node = nodes[0]
const node = nodes[0]!
const shareTypes = node.attributes?.['share-types']
const isMixed = Array.isArray(shareTypes) && shareTypes.length > 0
@ -137,11 +136,12 @@ export const action = new FileAction({
&& (node.permissions & Permission.READ) !== 0
},
async exec(node: Node, view: View, dir: string) {
async exec({ nodes, view, folder, contents }) {
// You need read permissions to see the sidebar
const node = nodes[0]
if ((node.permissions & Permission.READ) !== 0) {
window.OCA?.Files?.Sidebar?.setActiveTab?.('sharing')
sidebarAction.exec(node, view, dir)
sidebarAction.exec({ nodes, view, folder, contents })
return null
}

View file

@ -6,7 +6,8 @@
import type { FileStat, ResponseDataDetailed } from 'webdav'
import LinkSvg from '@mdi/svg/svg/link.svg?raw'
import { davGetDefaultPropfind, davRemoteURL, davResultToNode, davRootPath, Folder, getNavigation, Permission, View } from '@nextcloud/files'
import { Folder, getNavigation, Permission, View } from '@nextcloud/files'
import { getDefaultPropfind, getRemoteURL, getRootPath, resultToNode } from '@nextcloud/files/dav'
import { translate as t } from '@nextcloud/l10n'
import { CancelablePromise } from 'cancelable-promise'
import { client } from '../../../files/src/services/WebdavClient.ts'
@ -30,9 +31,9 @@ export default () => {
onCancel(() => abort.abort())
try {
const node = await client.stat(
davRootPath,
getRootPath(),
{
data: davGetDefaultPropfind(),
data: getDefaultPropfind(),
details: true,
signal: abort.signal,
},
@ -40,12 +41,12 @@ export default () => {
resolve({
// We only have one file as the content
contents: [davResultToNode(node.data)],
contents: [resultToNode(node.data)],
// Fake a readonly folder as root
folder: new Folder({
id: 0,
source: `${davRemoteURL}${davRootPath}`,
root: davRootPath,
source: `${getRemoteURL()}${getRootPath()}`,
root: getRootPath(),
owner: null,
permissions: Permission.READ,
attributes: {

View file

@ -12,7 +12,8 @@ import type { ShareAttribute } from '../sharing.d.ts'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { davRemoteURL, davRootPath, File, Folder, Permission } from '@nextcloud/files'
import { File, Folder, Permission } from '@nextcloud/files'
import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { generateOcsUrl } from '@nextcloud/router'
import logger from './logger.ts'
@ -67,7 +68,7 @@ async function ocsEntryToNode(ocsEntry: any): Promise<Folder | File | null> {
// Generate path and strip double slashes
const path = ocsEntry.path || ocsEntry.file_target || ocsEntry.name
const source = `${davRemoteURL}${davRootPath}/${path.replace(/^\/+/, '')}`
const source = `${getRemoteURL()}${getRootPath()}/${path.replace(/^\/+/, '')}`
let mtime = ocsEntry.item_mtime ? new Date((ocsEntry.item_mtime) * 1000) : undefined
// Prefer share time if more recent than item mtime
@ -94,7 +95,7 @@ async function ocsEntryToNode(ocsEntry: any): Promise<Folder | File | null> {
mtime,
size: ocsEntry?.item_size,
permissions: ocsEntry?.item_permissions || ocsEntry?.permissions,
root: davRootPath,
root: getRootPath(),
attributes: {
...ocsEntry,
'has-preview': hasPreview,
@ -272,8 +273,9 @@ export async function getContents(sharedWithYou = true, sharedWithOthers = true,
return {
folder: new Folder({
id: 0,
source: `${davRemoteURL}${davRootPath}`,
source: `${getRemoteURL()}${getRootPath()}`,
owner: getCurrentUser()?.uid || null,
root: getRootPath(),
}),
contents,
}

View file

@ -18,7 +18,7 @@ export const PERMISSION_ALL = 31
const axiosMock = vi.hoisted(() => ({
request: vi.fn(),
}))
vi.mock('@nextcloud/axios', async (origial) => ({ ...(await origial()), default: axiosMock }))
vi.mock('@nextcloud/axios', async (original) => ({ ...(await original()), default: axiosMock }))
vi.mock('@nextcloud/auth')
const errorSpy = vi.spyOn(window.console, 'error').mockImplementation(() => {})
@ -40,19 +40,34 @@ describe('files_trashbin: file actions - restore action', () => {
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' })
expect(restoreAction.inline).toBeTypeOf('function')
expect(restoreAction.inline!(node, trashbinView)).toBe(true)
expect(restoreAction.inline!({
nodes: [node],
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
it('has the display name set', () => {
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' })
expect(restoreAction.displayName([node], trashbinView)).toBe('Restore')
expect(restoreAction.displayName({
nodes: [node],
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toBe('Restore')
})
it('has an icon set', () => {
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' })
const icon = restoreAction.iconSvgInline([node], trashbinView)
const icon = restoreAction.iconSvgInline({
nodes: [node],
view: trashbinView,
folder: {} as Folder,
contents: [],
})
expect(icon).toBeTypeOf('string')
expect(isSvg(icon)).toBe(true)
})
@ -63,7 +78,12 @@ describe('files_trashbin: file actions - restore action', () => {
]
expect(restoreAction.enabled).toBeTypeOf('function')
expect(restoreAction.enabled!(nodes, trashbinView)).toBe(true)
expect(restoreAction.enabled!({
nodes,
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
it('is not enabled when permissions are missing', () => {
@ -72,12 +92,22 @@ describe('files_trashbin: file actions - restore action', () => {
]
expect(restoreAction.enabled).toBeTypeOf('function')
expect(restoreAction.enabled!(nodes, trashbinView)).toBe(false)
expect(restoreAction.enabled!({
nodes,
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
it('is not enabled when no nodes are selected', () => {
expect(restoreAction.enabled).toBeTypeOf('function')
expect(restoreAction.enabled!([], trashbinView)).toBe(false)
expect(restoreAction.enabled!({
nodes: [],
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
it('is not enabled for other views', () => {
@ -95,7 +125,12 @@ describe('files_trashbin: file actions - restore action', () => {
})
expect(restoreAction.enabled).toBeTypeOf('function')
expect(restoreAction.enabled!(nodes, otherView)).toBe(false)
expect(restoreAction.enabled!({
nodes,
view: otherView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
describe('execute', () => {
@ -106,7 +141,12 @@ describe('files_trashbin: file actions - restore action', () => {
it('send restore request', async () => {
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL })
expect(await restoreAction.exec(node, trashbinView, '/')).toBe(true)
expect(await restoreAction.exec({
nodes: [node],
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toBe(true)
expect(axiosMock.request).toBeCalled()
expect(axiosMock.request.mock.calls[0]![0].method).toBe('MOVE')
expect(axiosMock.request.mock.calls[0]![0].url).toBe(node.encodedSource)
@ -118,7 +158,12 @@ describe('files_trashbin: file actions - restore action', () => {
const emitSpy = vi.spyOn(ncEventBus, 'emit')
expect(await restoreAction.exec(node, trashbinView, '/')).toBe(true)
expect(await restoreAction.exec({
nodes: [node],
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toBe(true)
expect(axiosMock.request).toBeCalled()
expect(emitSpy).toBeCalled()
expect(emitSpy).toBeCalledWith('files:node:deleted', node)
@ -132,7 +177,12 @@ describe('files_trashbin: file actions - restore action', () => {
})
const emitSpy = vi.spyOn(ncEventBus, 'emit')
expect(await restoreAction.exec(node, trashbinView, '/')).toBe(false)
expect(await restoreAction.exec({
nodes: [node],
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toBe(false)
expect(axiosMock.request).toBeCalled()
expect(emitSpy).not.toBeCalled()
expect(errorSpy).toBeCalled()
@ -141,7 +191,12 @@ describe('files_trashbin: file actions - restore action', () => {
it('batch: only returns success if all requests worked', async () => {
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL })
expect(await restoreAction.execBatch!([node, node], trashbinView, '/')).toStrictEqual([true, true])
expect(await restoreAction.execBatch!({
nodes: [node, node],
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toStrictEqual([true, true])
expect(axiosMock.request).toBeCalledTimes(2)
})
@ -151,7 +206,12 @@ describe('files_trashbin: file actions - restore action', () => {
axiosMock.request.mockImplementationOnce(() => {
throw new Error()
})
expect(await restoreAction.execBatch!([node, node], trashbinView, '/')).toStrictEqual([false, true])
expect(await restoreAction.execBatch!({
nodes: [node, node],
view: trashbinView,
folder: {} as Folder,
contents: [],
})).toStrictEqual([false, true])
expect(axiosMock.request).toBeCalledTimes(2)
expect(errorSpy).toBeCalled()
})

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import svgHistory from '@mdi/svg/svg/history.svg?raw'
import { getCurrentUser } from '@nextcloud/auth'
import axios, { isAxiosError } from '@nextcloud/axios'
@ -26,7 +23,7 @@ export const restoreAction = new FileAction({
iconSvgInline: () => svgHistory,
enabled(nodes: Node[], view) {
enabled({ nodes, view }) {
// Only available in the trashbin view
if (view.id !== TRASHBIN_VIEW_ID) {
return false
@ -39,7 +36,8 @@ export const restoreAction = new FileAction({
.every((permission) => Boolean(permission & Permission.READ))
},
async exec(node: Node) {
async exec({ nodes }) {
const node = nodes[0]
try {
const destination = generateRemoteUrl(encodePath(`dav/trashbin/${getCurrentUser()!.uid}/restore/${node.basename}`))
await axios.request({
@ -63,8 +61,8 @@ export const restoreAction = new FileAction({
}
},
async execBatch(nodes: Node[], view: View, dir: string) {
return Promise.all(nodes.map((node) => this.exec(node, view, dir)))
async execBatch({ nodes, view, folder, contents }) {
return Promise.all(nodes.map((node) => this.exec({ nodes: [node], view, folder, contents })))
},
order: 1,

View file

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import * as ncDialogs from '@nextcloud/dialogs'
import * as ncEventBus from '@nextcloud/event-bus'
import { Folder } from '@nextcloud/files'
@ -12,9 +14,11 @@ import * as api from '../services/api.ts'
import { emptyTrashAction } from './emptyTrashAction.ts'
const loadState = vi.hoisted(() => vi.fn((app, key, fallback) => {
if (fallback) {
if (fallback !== undefined) {
return fallback
}
console.error('Unexpected loadState call without fallback', { app, key })
throw new Error()
}))
@ -30,7 +34,7 @@ describe('files_trashbin: file list actions - empty trashbin', () => {
})
it('has display name set', () => {
expect(emptyTrashAction.displayName(trashbinView)).toBe('Empty deleted files')
expect(emptyTrashAction.displayName({ view: trashbinView })).toBe('Empty deleted files')
})
it('has order set', () => {
@ -41,13 +45,17 @@ describe('files_trashbin: file list actions - empty trashbin', () => {
it('is enabled on trashbin view', () => {
loadState.mockImplementation(() => ({ allow_delete: true }))
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const nodes = [
const folder = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const contents = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
expect(emptyTrashAction.enabled).toBeTypeOf('function')
expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(true)
expect(emptyTrashAction.enabled!({
view: trashbinView,
folder,
contents,
})).toBe(true)
expect(loadState).toHaveBeenCalled()
expect(loadState).toHaveBeenCalledWith('files_trashbin', 'config')
})
@ -55,10 +63,10 @@ describe('files_trashbin: file list actions - empty trashbin', () => {
it('is not enabled on another view enabled', () => {
loadState.mockImplementation(() => ({ allow_delete: true }))
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const nodes = [
const folder = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const contents = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
] as Node[]
const otherView = new Proxy(trashbinView, {
get(target, p) {
@ -70,37 +78,49 @@ describe('files_trashbin: file list actions - empty trashbin', () => {
})
expect(emptyTrashAction.enabled).toBeTypeOf('function')
expect(emptyTrashAction.enabled!(otherView, nodes, root)).toBe(false)
expect(emptyTrashAction.enabled!({
view: otherView,
folder,
contents,
})).toBe(false)
})
it('is not enabled when deletion is forbidden', () => {
loadState.mockImplementation(() => ({ allow_delete: false }))
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const nodes = [
const folder = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const contents = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
expect(emptyTrashAction.enabled).toBeTypeOf('function')
expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(false)
expect(emptyTrashAction.enabled!({
view: trashbinView,
folder,
contents,
})).toBe(false)
expect(loadState).toHaveBeenCalled()
expect(loadState).toHaveBeenCalledWith('files_trashbin', 'config')
})
it('is not enabled when not in trashbin root', () => {
loadState.mockImplementation(() => ({ allow_delete: true }))
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/other-folder', root: '/trashbin/test/' })
const nodes = [
const folder = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/other-folder', root: '/trashbin/test/' })
const contents = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
expect(emptyTrashAction.enabled).toBeTypeOf('function')
expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(false)
expect(emptyTrashAction.enabled!({
view: trashbinView,
folder,
contents,
})).toBe(false)
})
describe('execute', () => {
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const nodes = [
const folder = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const contents = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
@ -129,7 +149,11 @@ describe('files_trashbin: file list actions - empty trashbin', () => {
const apiSpy = vi.spyOn(api, 'emptyTrash')
dialogBuilder.build.mockImplementationOnce(() => ({ show: async () => false }))
expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null)
expect(await emptyTrashAction.exec({
view: trashbinView,
folder,
contents,
})).toBe(null)
expect(apiSpy).not.toBeCalled()
})
@ -143,7 +167,11 @@ describe('files_trashbin: file list actions - empty trashbin', () => {
await cancel.callback()
},
}))
expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null)
expect(await emptyTrashAction.exec({
view: trashbinView,
folder,
contents,
})).toBe(null)
expect(apiSpy).not.toBeCalled()
})
@ -159,10 +187,14 @@ describe('files_trashbin: file list actions - empty trashbin', () => {
await cancel.callback()
},
}))
expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null)
expect(await emptyTrashAction.exec({
view: trashbinView,
folder,
contents,
})).toBe(null)
expect(apiSpy).toBeCalled()
expect(dialogSpy).not.toBeCalled()
expect(eventBusSpy).toBeCalledWith('files:node:deleted', nodes[0])
expect(eventBusSpy).toBeCalledWith('files:node:deleted', contents[0])
})
it('will not emit files deleted event if API request failed', async () => {
@ -177,7 +209,11 @@ describe('files_trashbin: file list actions - empty trashbin', () => {
await cancel.callback()
},
}))
expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null)
expect(await emptyTrashAction.exec({
view: trashbinView,
folder,
contents,
})).toBe(null)
expect(apiSpy).toBeCalled()
expect(dialogSpy).not.toBeCalled()
expect(eventBusSpy).not.toBeCalled()

View file

@ -2,9 +2,6 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Folder, Node, View } from '@nextcloud/files'
import { getDialogBuilder } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { FileListAction } from '@nextcloud/files'
@ -23,7 +20,7 @@ export const emptyTrashAction = new FileListAction({
displayName: () => t('files_trashbin', 'Empty deleted files'),
order: 0,
enabled(view: View, nodes: Node[], folder: Folder) {
enabled({ view, folder, contents }) {
if (view.id !== TRASHBIN_VIEW_ID) {
return false
}
@ -33,10 +30,10 @@ export const emptyTrashAction = new FileListAction({
return false
}
return nodes.length > 0 && folder.path === '/'
return contents.length > 0 && folder.path === '/'
},
async exec(view: View, nodes: Node[]): Promise<null> {
async exec({ contents }): Promise<null> {
const askConfirmation = new Promise<boolean>((resolve) => {
const dialog = getDialogBuilder(t('files_trashbin', 'Confirm permanent deletion'))
.setSeverity('warning')
@ -63,7 +60,7 @@ export const emptyTrashAction = new FileListAction({
const result = await askConfirmation
if (result === true) {
if (await emptyTrash()) {
nodes.forEach((node) => emit('files:node:deleted', node))
contents.forEach((node) => emit('files:node:deleted', node))
}
return null
}

View file

@ -26,8 +26,20 @@ describe('files_trashbin: file list columns', () => {
})
it('correctly sorts nodes by original location', () => {
const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'z-folder/a.txt' } })
const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'folder/b.txt' } })
const nodeA = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-original-location': 'z-folder/a.txt' },
root: '/files/test',
})
const nodeB = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/b.txt',
mime: 'text/plain',
attributes: { 'trashbin-original-location': 'folder/b.txt' },
root: '/files/test',
})
expect(originalLocation.sort).toBeTypeOf('function')
expect(originalLocation.sort!(nodeA, nodeB)).toBeGreaterThan(0)
@ -35,7 +47,13 @@ describe('files_trashbin: file list columns', () => {
})
it('renders a node with original location', () => {
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'folder/a.txt' } })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-original-location': 'folder/a.txt' },
root: '/files/test',
})
const el: HTMLElement = originalLocation.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent).toBe('folder')
@ -43,7 +61,12 @@ describe('files_trashbin: file list columns', () => {
})
it('renders a node when original location is missing', () => {
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
root: '/files/test',
})
const el: HTMLElement = originalLocation.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent).toBe('Unknown')
@ -51,7 +74,13 @@ describe('files_trashbin: file list columns', () => {
})
it('renders a node when original location is the root', () => {
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'a.txt' } })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-original-location': 'a.txt' },
root: '/files/test',
})
const el: HTMLElement = originalLocation.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent).toBe('All files')
@ -69,8 +98,20 @@ describe('files_trashbin: file list columns', () => {
})
it('correctly sorts nodes by deleted time', () => {
const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': 1741684522 } })
const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': 1741684422 } })
const nodeA = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-deletion-time': 1741684522 },
root: '/files/test',
})
const nodeB = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/b.txt',
mime: 'text/plain',
attributes: { 'trashbin-deletion-time': 1741684422 },
root: '/files/test',
})
expect(deleted.sort).toBeTypeOf('function')
expect(deleted.sort!(nodeA, nodeB)).toBeLessThan(0)
@ -78,8 +119,20 @@ describe('files_trashbin: file list columns', () => {
})
it('correctly sorts nodes by deleted time and falls back to mtime', () => {
const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': 1741684522 } })
const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', mtime: new Date(1741684422000) })
const nodeA = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-deletion-time': 1741684522 },
root: '/files/test',
})
const nodeB = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/b.txt',
mime: 'text/plain',
mtime: new Date(1741684422000),
root: '/files/test',
})
expect(deleted.sort).toBeTypeOf('function')
expect(deleted.sort!(nodeA, nodeB)).toBeLessThan(0)
@ -87,8 +140,19 @@ describe('files_trashbin: file list columns', () => {
})
it('correctly sorts nodes even if no deletion date is provided', () => {
const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' })
const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', mtime: new Date(1741684422000) })
const nodeA = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
root: '/files/test',
})
const nodeB = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/b.txt',
mime: 'text/plain',
mtime: new Date(1741684422000),
root: '/files/test',
})
expect(deleted.sort).toBeTypeOf('function')
expect(deleted.sort!(nodeA, nodeB)).toBeGreaterThan(0)
@ -105,7 +169,15 @@ describe('files_trashbin: file list columns', () => {
})
it('renders a node with deletion date', () => {
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': (Date.now() / 1000) - 120 } })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: {
'trashbin-deletion-time': Date.now() / 1000 - 120,
},
root: '/files/test',
})
const el: HTMLElement = deleted.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent).toBe('2 minutes ago')
@ -113,7 +185,13 @@ describe('files_trashbin: file list columns', () => {
})
it('renders a node when deletion date is missing and falls back to mtime', () => {
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', mtime: new Date(Date.now() - 60000) })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
mtime: new Date(Date.now() - 60000),
root: '/files/test',
})
const el: HTMLElement = deleted.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent).toBe('1 minute ago')
@ -121,7 +199,12 @@ describe('files_trashbin: file list columns', () => {
})
it('renders a node when deletion date is missing', () => {
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
root: '/files/test',
})
const el: HTMLElement = deleted.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent).toBe('A long time ago')
@ -138,8 +221,20 @@ describe('files_trashbin: file list columns', () => {
})
it('correctly sorts nodes by user-id of deleting user', () => {
const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'zzz' } })
const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'aaa' } })
const nodeA = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-deleted-by-id': 'zzz' },
root: '/files/test',
})
const nodeB = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/b.txt',
mime: 'text/plain',
attributes: { 'trashbin-deleted-by-id': 'aaa' },
root: '/files/test',
})
expect(deletedBy.sort).toBeTypeOf('function')
expect(deletedBy.sort!(nodeA, nodeB)).toBeGreaterThan(0)
@ -147,8 +242,20 @@ describe('files_trashbin: file list columns', () => {
})
it('correctly sorts nodes by display name of deleting user', () => {
const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'zzz' } })
const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'aaa' } })
const nodeA = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-deleted-by-display-name': 'zzz' },
root: '/files/test',
})
const nodeB = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/b.txt',
mime: 'text/plain',
attributes: { 'trashbin-deleted-by-display-name': 'aaa' },
root: '/files/test',
})
expect(deletedBy.sort).toBeTypeOf('function')
expect(deletedBy.sort!(nodeA, nodeB)).toBeGreaterThan(0)
@ -156,8 +263,26 @@ describe('files_trashbin: file list columns', () => {
})
it('correctly sorts nodes by display name of deleting user before user id', () => {
const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': '000', 'trashbin-deleted-by-id': 'zzz' } })
const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'aaa', 'trashbin-deleted-by-id': '999' } })
const nodeA = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: {
'trashbin-deleted-by-display-name': '000',
'trashbin-deleted-by-id': 'zzz',
},
root: '/files/test',
})
const nodeB = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/b.txt',
mime: 'text/plain',
attributes: {
'trashbin-deleted-by-display-name': 'aaa',
'trashbin-deleted-by-id': '999',
},
root: '/files/test',
})
expect(deletedBy.sort).toBeTypeOf('function')
expect(deletedBy.sort!(nodeA, nodeB)).toBeLessThan(0)
@ -165,9 +290,26 @@ describe('files_trashbin: file list columns', () => {
})
it('correctly sorts nodes even when one is missing', () => {
const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'aaa' } })
const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'zzz' } })
const nodeC = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain' })
const nodeA = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-deleted-by-id': 'aaa' },
root: '/files/test',
})
const nodeB = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-deleted-by-id': 'zzz' },
root: '/files/test',
})
const nodeC = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/b.txt',
mime: 'text/plain',
root: '/files/test',
})
expect(deletedBy.sort).toBeTypeOf('function')
// aaa is less then "Unknown"
@ -177,21 +319,41 @@ describe('files_trashbin: file list columns', () => {
})
it('renders a node with deleting user', () => {
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'user-id' } })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-deleted-by-id': 'user-id' },
root: '/files/test',
})
const el: HTMLElement = deletedBy.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent.trim()).toBe('user-id')
})
it('renders a node with deleting user display name', () => {
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'user-name', 'trashbin-deleted-by-id': 'user-id' } })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: {
'trashbin-deleted-by-display-name': 'user-name',
'trashbin-deleted-by-id': 'user-id',
},
root: '/files/test',
})
const el: HTMLElement = deletedBy.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent.trim()).toBe('user-name')
})
it('renders a node even when information is missing', () => {
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
root: '/files/test',
})
const el: HTMLElement = deletedBy.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent).toBe('Unknown')
@ -204,7 +366,13 @@ describe('files_trashbin: file list columns', () => {
isAdmin: false,
}))
const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'user-id' } })
const node = new File({
owner: 'test',
source: 'https://example.com/remote.php/dav/files/test/a.txt',
mime: 'text/plain',
attributes: { 'trashbin-deleted-by-id': 'user-id' },
root: '/files/test',
})
const el: HTMLElement = deletedBy.render(node, trashbinView)
expect(el).toBeInstanceOf(HTMLElement)
expect(el.textContent).toBe('You')

View file

@ -22,8 +22,8 @@
<NcTextField
:id="inputIdWithDefault"
ref="email"
class="email__input"
v-model="emailAddress"
class="email__input"
autocapitalize="none"
autocomplete="email"
:error="hasError || !!helperText"

View file

@ -55,9 +55,9 @@
:class="{ 'row__cell--obfuscated': hasObfuscated }">
<template v-if="editing && settings.canChangePassword && user.backendCapabilities.setPassword">
<NcTextField
v-model="editedPassword"
class="user-row-text-field"
data-cy-user-list-input-password
v-model="editedPassword"
:data-loading="loading.password || undefined"
:trailing-button-label="t('settings', 'Submit')"
:class="{ 'icon-loading-small': loading.password }"

View file

@ -17,13 +17,14 @@
<div class="system-tag-form__group">
<label for="system-tags-input">{{ t('systemtags', 'Search for a tag to edit') }}</label>
<NcSelectTags
v-model="selectedTag"
:model-value="selectedTag"
input-id="system-tags-input"
:placeholder="t('systemtags', 'Collaborative tags …')"
:fetch-tags="false"
:options="tags"
:multiple="false"
passthru>
label-outside
@update:model-value="onSelectTag">
<template #no-options>
{{ t('systemtags', 'No tags to select') }}
</template>
@ -49,7 +50,8 @@
:options="tagLevelOptions"
:reduce="level => level.id"
:clearable="false"
:disabled="loading" />
:disabled="loading"
label-outside />
</div>
<div class="system-tag-form__row">
@ -85,11 +87,12 @@
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { Tag, TagWithId } from '../types.js'
import { showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import Vue, { type PropType } from 'vue'
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcSelect from '@nextcloud/vue/components/NcSelect'
@ -135,10 +138,10 @@ function getTagLevel(userVisible: boolean, userAssignable: boolean): TagLevel {
[[true, false].join(',')]: TagLevel.Restricted,
[[false, false].join(',')]: TagLevel.Invisible,
}
return matchLevel[[userVisible, userAssignable].join(',')]
return matchLevel[[userVisible, userAssignable].join(',')]!
}
export default Vue.extend({
export default defineComponent({
name: 'SystemTagForm',
components: {
@ -156,6 +159,12 @@ export default Vue.extend({
},
},
emits: [
'tag:created',
'tag:updated',
'tag:deleted',
],
data() {
return {
loading: false,
@ -230,6 +239,11 @@ export default Vue.extend({
methods: {
t,
onSelectTag(tagId: number | null) {
const tag = this.tags.find((search) => search.id === tagId) || null
this.selectedTag = tag
},
async handleSubmit() {
if (this.isCreating) {
await this.create()

View file

@ -18,8 +18,18 @@ describe('Manage tags action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('systemtags:bulk')
expect(action.displayName([], view)).toBe('Manage tags')
expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.displayName({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toBe('Manage tags')
expect(action.iconSvgInline({
nodes: [],
view,
folder: {} as Folder,
contents: [],
})).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(undefined)
expect(action.enabled).toBeDefined()
@ -34,6 +44,7 @@ describe('Manage tags action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.NONE,
root: '/files/admin',
})
const file2 = new File({
@ -42,12 +53,28 @@ describe('Manage tags action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.UPDATE,
root: '/files/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file1, file2], view)).toBe(false)
expect(action.enabled!([file1], view)).toBe(false)
expect(action.enabled!([file2], view)).toBe(true)
expect(action.enabled!({
nodes: [file1, file2],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
expect(action.enabled!({
nodes: [file1],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
expect(action.enabled!({
nodes: [file2],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled for non-dav ressources', () => {
@ -57,10 +84,16 @@ describe('Manage tags action enabled tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(false)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Enabled for files outside the user root folder', () => {
@ -69,9 +102,15 @@ describe('Manage tags action enabled tests', () => {
source: 'https://cloud.domain.com/remote.php/dav/trashbin/admin/trash/image.jpg.d1731053878',
owner: 'admin',
permissions: Permission.ALL,
root: '/trashbin/admin',
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
})

View file

@ -16,8 +16,9 @@ import { defineAsyncComponent } from 'vue'
* Spawn a dialog to add or remove tags from multiple nodes.
*
* @param nodes Nodes to modify tags for
* @param nodes.nodes
*/
async function execBatch(nodes: Node[]): Promise<(null | boolean)[]> {
async function execBatch({ nodes }: { nodes: Node[] }): Promise<(null | boolean)[]> {
const response = await new Promise<null | boolean>((resolve) => {
spawnDialog(defineAsyncComponent(() => import('../components/SystemTagPicker.vue')), {
nodes,
@ -34,7 +35,7 @@ export const action = new FileAction({
iconSvgInline: () => TagMultipleSvg,
// If the app is disabled, the action is not available anyway
enabled(nodes) {
enabled({ nodes }) {
if (isPublicShare()) {
return false
}
@ -52,8 +53,8 @@ export const action = new FileAction({
return !nodes.some((node) => (node.permissions & Permission.UPDATE) === 0)
},
async exec(node: Node) {
return execBatch([node])[0]
async exec({ nodes }) {
return execBatch({ nodes })[0]
},
execBatch,

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { Folder, View } from '@nextcloud/files'
import { emit, subscribe } from '@nextcloud/event-bus'
import { File, FileAction, Permission } from '@nextcloud/files'
@ -25,17 +25,33 @@ describe('Inline system tags action conditions tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('system-tags')
expect(action.displayName([file], view)).toBe('')
expect(action.iconSvgInline([], view)).toBe('')
expect(action.displayName({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('')
expect(action.iconSvgInline({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe('')
expect(action.default).toBeUndefined()
expect(action.enabled).toBeDefined()
expect(action.order).toBe(0)
// Always enabled
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Enabled with valid system tags', () => {
@ -50,9 +66,15 @@ describe('Inline system tags action conditions tests', () => {
'system-tag': 'Confidential',
},
},
root: '/files/admin',
})
expect(action.enabled!([file], view)).toBe(true)
expect(action.enabled!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
})
@ -70,9 +92,15 @@ describe('Inline system tags action render tests', () => {
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
root: '/files/admin',
})
const result = await action.renderInline!(file, view)
const result = await action.renderInline!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot('"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"></ul>"')
})
@ -89,9 +117,15 @@ describe('Inline system tags action render tests', () => {
'system-tag': 'Confidential',
},
},
root: '/files/admin',
})
const result = await action.renderInline!(file, view)
const result = await action.renderInline!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot('"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag" data-systemtag-name="Confidential">Confidential</li></ul>"')
})
@ -108,9 +142,15 @@ describe('Inline system tags action render tests', () => {
'system-tag': ['Important', 'Confidential'],
},
},
root: '/files/admin',
})
const result = await action.renderInline!(file, view)
const result = await action.renderInline!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot('"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag" data-systemtag-name="Important">Important</li><li class="files-list__system-tag" data-systemtag-name="Confidential">Confidential</li></ul>"')
})
@ -132,9 +172,15 @@ describe('Inline system tags action render tests', () => {
],
},
},
root: '/files/admin',
})
const result = await action.renderInline!(file, view)
const result = await action.renderInline!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot('"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag" data-systemtag-name="Important">Important</li><li class="files-list__system-tag files-list__system-tag--more" data-systemtag-name="+3" title="Confidential, Secret, Classified" aria-hidden="true" role="presentation">+3</li><li class="files-list__system-tag hidden-visually" data-systemtag-name="Confidential">Confidential</li><li class="files-list__system-tag hidden-visually" data-systemtag-name="Secret">Secret</li><li class="files-list__system-tag hidden-visually" data-systemtag-name="Classified">Classified</li></ul>"')
})
@ -156,9 +202,15 @@ describe('Inline system tags action render tests', () => {
],
},
},
root: '/files/admin',
})
const result = await action.renderInline!(file, view) as HTMLElement
const result = await action.renderInline!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
}) as HTMLElement
document.body.appendChild(result)
expect(result).toBeInstanceOf(HTMLElement)
expect(document.body.innerHTML).toMatchInlineSnapshot('"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag" data-systemtag-name="Important">Important</li><li class="files-list__system-tag files-list__system-tag--more" data-systemtag-name="+3" title="Confidential, Secret, Classified" aria-hidden="true" role="presentation">+3</li><li class="files-list__system-tag hidden-visually" data-systemtag-name="Confidential">Confidential</li><li class="files-list__system-tag hidden-visually" data-systemtag-name="Secret">Secret</li><li class="files-list__system-tag hidden-visually" data-systemtag-name="Classified">Classified</li></ul>"')
@ -212,9 +264,15 @@ describe('Inline system tags action colors', () => {
'system-tag': 'Confidential',
},
},
root: '/files/admin',
})
const result = await action.renderInline!(file, view)
const result = await action.renderInline!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot('"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag" data-systemtag-name="Confidential" style="--systemtag-color: #000000;" data-systemtag-color="true">Confidential</li></ul>"')
})
@ -231,11 +289,17 @@ describe('Inline system tags action colors', () => {
'system-tag': 'Confidential',
},
},
root: '/files/admin',
})
document.body.setAttribute('data-themes', 'theme-dark')
const result = await action.renderInline!(file, view)
const result = await action.renderInline!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot('"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag" data-systemtag-name="Confidential" style="--systemtag-color: #646464;" data-systemtag-color="true">Confidential</li></ul>"')
@ -254,9 +318,15 @@ describe('Inline system tags action colors', () => {
'system-tag': 'Confidential',
},
},
root: '/files/admin',
})
const result = await action.renderInline!(file, view) as HTMLElement
const result = await action.renderInline!({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
}) as HTMLElement
document.body.appendChild(result)
expect(result).toBeInstanceOf(HTMLElement)
expect(document.body.innerHTML).toMatchInlineSnapshot('"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag" data-systemtag-name="Confidential" style="--systemtag-color: #000000;" data-systemtag-color="true">Confidential</li></ul>"')

View file

@ -24,7 +24,7 @@ export const action = new FileAction({
displayName: () => '',
iconSvgInline: () => '',
enabled(nodes: Node[]) {
enabled({ nodes }) {
// Only show the action on single nodes
if (nodes.length !== 1) {
return false
@ -36,7 +36,12 @@ export const action = new FileAction({
},
exec: async () => null,
renderInline,
renderInline: ({ nodes }) => {
if (nodes.length !== 1 || !nodes[0]) {
return Promise.resolve(null)
}
return renderInline(nodes[0])
},
order: 0,

View file

@ -45,8 +45,18 @@ describe('Open in files action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('systemtags:open-in-files')
expect(action.displayName([], systemTagsView)).toBe('Open in Files')
expect(action.iconSvgInline([], systemTagsView)).toBe('')
expect(action.displayName({
nodes: [],
view: systemTagsView,
folder: {} as Folder,
contents: [],
})).toBe('Open in Files')
expect(action.iconSvgInline({
nodes: [],
view: systemTagsView,
folder: {} as Folder,
contents: [],
})).toBe('')
expect(action.default).toBe(DefaultType.HIDDEN)
expect(action.order).toBe(-1000)
expect(action.inline).toBeUndefined()
@ -56,34 +66,58 @@ describe('Open in files action conditions tests', () => {
describe('Open in files action enabled tests', () => {
test('Enabled with on valid view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([validNode], systemTagsView)).toBe(true)
expect(action.enabled!({
nodes: [validNode],
view: systemTagsView,
folder: {} as Folder,
contents: [],
})).toBe(true)
})
test('Disabled on wrong view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([validNode], view)).toBe(false)
expect(action.enabled!({
nodes: [validNode],
view,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled without nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
expect(action.enabled!({
nodes: [],
view: systemTagsView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled with too many nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([validNode, validNode], view)).toBe(false)
expect(action.enabled!({
nodes: [validNode, validNode],
view: systemTagsView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
test('Disabled with when node is a tag', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([validTag], view)).toBe(false)
expect(action.enabled!({
nodes: [validTag],
view: systemTagsView,
folder: {} as Folder,
contents: [],
})).toBe(false)
})
})
describe('Open in files action execute tests', () => {
test('Open in files', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new File({
@ -95,7 +129,12 @@ describe('Open in files action execute tests', () => {
permissions: Permission.ALL,
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
@ -105,7 +144,6 @@ describe('Open in files action execute tests', () => {
test('Open in files with folder', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new Folder({
@ -116,7 +154,12 @@ describe('Open in files action execute tests', () => {
permissions: Permission.ALL,
})
const exec = await action.exec(file, view, '/')
const exec = await action.exec({
nodes: [file],
view,
folder: {} as Folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)

View file

@ -3,8 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import { DefaultType, FileAction, FileType } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { systemTagsViewId } from '../files_views/systemtagsView.ts'
@ -14,13 +12,13 @@ export const action = new FileAction({
displayName: () => t('systemtags', 'Open in Files'),
iconSvgInline: () => '',
enabled(nodes, view) {
enabled({ nodes, view }) {
// Only for the system tags view
if (view.id !== systemTagsViewId) {
return false
}
// Only for single nodes
if (nodes.length !== 1) {
if (nodes.length !== 1 || !nodes[0]) {
return false
}
// Do not open tags (keep the default action) and only open folders
@ -28,15 +26,19 @@ export const action = new FileAction({
&& nodes[0].type === FileType.Folder
},
async exec(node: Node) {
let dir = node.dirname
if (node.type === FileType.Folder) {
dir = node.path
async exec({ nodes }) {
if (!nodes[0] || nodes.length !== 1) {
return false
}
let dir = nodes[0].dirname
if (nodes[0].type === FileType.Folder) {
dir = nodes[0].path
}
window.OCP.Files.Router.goToRoute(
null, // use default route
{ view: 'files', fileid: String(node.fileid) },
{ view: 'files', fileid: String(nodes[0].fileid) },
{ dir, openfile: 'true' },
)
return null

View file

@ -2,7 +2,8 @@
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { registerDavProperty, registerFileAction } from '@nextcloud/files'
import { registerFileAction } from '@nextcloud/files'
import { registerDavProperty } from '@nextcloud/files/dav'
import { action as bulkSystemTagsAction } from './files_actions/bulkSystemTagsAction.ts'
import { action as inlineSystemTagsAction } from './files_actions/inlineSystemTagsAction.ts'
import { action as openInFilesAction } from './files_actions/openInFilesAction.ts'

View file

@ -1,6 +1,6 @@
import type { View } from '@nextcloud/files'
import { File, Permission } from '@nextcloud/files'
import { File, Folder, Permission } from '@nextcloud/files'
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
@ -29,16 +29,26 @@ describe('HotKeysService testing', () => {
beforeEach(() => {
// Make sure the file is reset before each test
file = new File({
id: 1,
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
root: '/files/admin',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
})
const root = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/',
root: '/files/admin',
owner: 'admin',
permissions: Permission.CREATE,
})
// Setting the view first as it reset the active node
activeStore.activeView = view
activeStore.activeNode = file
activeStore.activeFolder = root
})
it('Pressing t should open the tag management dialog', () => {

View file

@ -7,13 +7,13 @@ import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { TagWithId } from '../types.ts'
import { getCurrentUser } from '@nextcloud/auth'
import { davGetClient, davRemoteURL, davResultToNode, davRootPath, Folder, getDavNameSpaces, getDavProperties, Permission } from '@nextcloud/files'
import { Folder, Permission } from '@nextcloud/files'
import { getClient, getDavNameSpaces, getDavProperties, getRemoteURL, getRootPath, resultToNode } from '@nextcloud/files/dav'
import { fetchTags } from './api.ts'
const rootPath = '/systemtags'
const client = davGetClient()
const resultToNode = (node: FileStat) => davResultToNode(node)
const client = getClient()
/**
*
@ -38,7 +38,7 @@ function formatReportPayload(tagId: number) {
function tagToNode(tag: TagWithId): Folder {
return new Folder({
id: tag.id,
source: `${davRemoteURL}${rootPath}/${tag.id}`,
source: `${getRemoteURL()}${rootPath}/${tag.id}`,
owner: String(getCurrentUser()?.uid ?? 'anonymous'),
root: rootPath,
displayname: tag.displayName,
@ -62,7 +62,7 @@ export async function getContents(path = '/'): Promise<ContentsWithRoot> {
return {
folder: new Folder({
id: 0,
source: `${davRemoteURL}${rootPath}`,
source: `${getRemoteURL()}${rootPath}`,
owner: getCurrentUser()?.uid as string,
root: rootPath,
permissions: Permission.NONE,
@ -79,7 +79,7 @@ export async function getContents(path = '/'): Promise<ContentsWithRoot> {
}
const folder = tagToNode(tag)
const contentsResponse = await client.getDirectoryContents(davRootPath, {
const contentsResponse = await client.getDirectoryContents(getRootPath(), {
details: true,
// Only filter favorites if we're at the root
data: formatReportPayload(tagId),
@ -91,6 +91,6 @@ export async function getContents(path = '/'): Promise<ContentsWithRoot> {
return {
folder,
contents: contentsResponse.data.map(resultToNode),
contents: contentsResponse.data.map((stat) => resultToNode(stat)),
}
}

View file

@ -97,6 +97,7 @@ describe('systemtags - utils', () => {
'system-tag': 'tag',
},
},
root: '/files/test',
})
expect(getNodeSystemTags(node)).toStrictEqual(['tag'])
})
@ -113,6 +114,7 @@ describe('systemtags - utils', () => {
],
},
},
root: '/files/test',
})
expect(getNodeSystemTags(node)).toStrictEqual(['tag', 'my-tag'])
})
@ -129,6 +131,7 @@ describe('systemtags - utils', () => {
},
},
},
root: '/files/test',
})
expect(getNodeSystemTags(node)).toStrictEqual(['tag'])
})
@ -151,6 +154,7 @@ describe('systemtags - utils', () => {
],
},
},
root: '/files/test',
})
expect(getNodeSystemTags(node)).toStrictEqual(['tag', 'my-tag'])
})
@ -170,6 +174,7 @@ describe('systemtags - utils', () => {
],
},
},
root: '/files/test',
})
expect(getNodeSystemTags(node)).toStrictEqual(['tag', 'my-tag'])
})

View file

@ -18,7 +18,7 @@
"@nextcloud/capabilities": "^1.2.1",
"@nextcloud/dialogs": "^7.1.0",
"@nextcloud/event-bus": "^3.3.3",
"@nextcloud/files": "^3.12.0",
"@nextcloud/files": "^4.0.0-beta.4",
"@nextcloud/initial-state": "^3.0.0",
"@nextcloud/l10n": "^3.4.1",
"@nextcloud/logger": "^3.0.3",
@ -28,7 +28,7 @@
"@nextcloud/router": "^3.1.0",
"@nextcloud/sharing": "^0.3.0",
"@nextcloud/upload": "^1.11.0",
"@nextcloud/vue": "^8.34.0",
"@nextcloud/vue": "^8.35.0",
"@simplewebauthn/browser": "^13.2.2",
"@vue/web-component-wrapper": "^1.3.0",
"@vueuse/components": "^11.3.0",
@ -2896,6 +2896,50 @@
"vue": "^3.2.0"
}
},
"node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.0.tgz",
"integrity": "sha512-LVZklgooZzBj2jkbPRZO4jnnvW5+RvOn7wN5weyOZltF6i2wVMbg1Y/Czl2pi/UNMjUm5ENqc0j7FgxMBo8bwA==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nextcloud/auth": "^2.5.1",
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/l10n": "^3.3.0",
"@nextcloud/logger": "^3.0.2",
"@nextcloud/paths": "^2.2.1",
"@nextcloud/router": "^3.0.1",
"@nextcloud/sharing": "^0.2.4",
"cancelable-promise": "^4.3.1",
"is-svg": "^6.0.0",
"typescript-event-target": "^1.1.1",
"webdav": "^5.8.0"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
}
},
"node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files/node_modules/@nextcloud/initial-state": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-2.2.0.tgz",
"integrity": "sha512-cDW98L5KGGgpS8pzd+05304/p80cyu8U2xSDQGa+kGPTpUFmCbv2qnO5WrwwGTauyjYijCal2bmw82VddSH+Pg==",
"license": "GPL-3.0-or-later",
"engines": {
"node": "^20.0.0",
"npm": "^10.0.0"
}
},
"node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files/node_modules/@nextcloud/sharing": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.2.5.tgz",
"integrity": "sha512-B3K5Dq9b5dexDA5n3AAuCF69Huwhrpw0J72fsVXV4KpPdImjhVPlExAv5o70AoXa+OqN4Rwn6gqJw+3ED892zg==",
"license": "GPL-3.0-or-later",
"dependencies": {
"@nextcloud/initial-state": "^2.2.0"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
}
},
"node_modules/@nextcloud/dialogs/node_modules/@nextcloud/vue": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.0.1.tgz",
@ -3206,20 +3250,20 @@
}
},
"node_modules/@nextcloud/files": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.0.tgz",
"integrity": "sha512-LVZklgooZzBj2jkbPRZO4jnnvW5+RvOn7wN5weyOZltF6i2wVMbg1Y/Czl2pi/UNMjUm5ENqc0j7FgxMBo8bwA==",
"version": "4.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.0.0-beta.4.tgz",
"integrity": "sha512-2MwEZw0JXyNPOmj6PcEUT/9TbmEzT+C5c9zMC6hwJMjjLoO19Nf1VaxmHqyzgfNy1jzst8Eq106ZsBExH2rncw==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nextcloud/auth": "^2.5.1",
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/l10n": "^3.3.0",
"@nextcloud/auth": "^2.5.3",
"@nextcloud/capabilities": "^1.2.1",
"@nextcloud/l10n": "^3.4.1",
"@nextcloud/logger": "^3.0.2",
"@nextcloud/paths": "^2.2.1",
"@nextcloud/router": "^3.0.1",
"@nextcloud/sharing": "^0.2.4",
"@nextcloud/paths": "^2.3.0",
"@nextcloud/router": "^3.1.0",
"@nextcloud/sharing": "^0.3.0",
"cancelable-promise": "^4.3.1",
"is-svg": "^6.0.0",
"is-svg": "^6.1.0",
"typescript-event-target": "^1.1.1",
"webdav": "^5.8.0"
},
@ -3227,28 +3271,6 @@
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
}
},
"node_modules/@nextcloud/files/node_modules/@nextcloud/initial-state": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-2.2.0.tgz",
"integrity": "sha512-cDW98L5KGGgpS8pzd+05304/p80cyu8U2xSDQGa+kGPTpUFmCbv2qnO5WrwwGTauyjYijCal2bmw82VddSH+Pg==",
"license": "GPL-3.0-or-later",
"engines": {
"node": "^20.0.0",
"npm": "^10.0.0"
}
},
"node_modules/@nextcloud/files/node_modules/@nextcloud/sharing": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.2.5.tgz",
"integrity": "sha512-B3K5Dq9b5dexDA5n3AAuCF69Huwhrpw0J72fsVXV4KpPdImjhVPlExAv5o70AoXa+OqN4Rwn6gqJw+3ED892zg==",
"license": "GPL-3.0-or-later",
"dependencies": {
"@nextcloud/initial-state": "^2.2.0"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
}
},
"node_modules/@nextcloud/initial-state": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-3.0.0.tgz",
@ -3679,6 +3701,53 @@
"@nextcloud/files": "^3.12.0"
}
},
"node_modules/@nextcloud/sharing/node_modules/@nextcloud/files": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.0.tgz",
"integrity": "sha512-LVZklgooZzBj2jkbPRZO4jnnvW5+RvOn7wN5weyOZltF6i2wVMbg1Y/Czl2pi/UNMjUm5ENqc0j7FgxMBo8bwA==",
"license": "AGPL-3.0-or-later",
"optional": true,
"dependencies": {
"@nextcloud/auth": "^2.5.1",
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/l10n": "^3.3.0",
"@nextcloud/logger": "^3.0.2",
"@nextcloud/paths": "^2.2.1",
"@nextcloud/router": "^3.0.1",
"@nextcloud/sharing": "^0.2.4",
"cancelable-promise": "^4.3.1",
"is-svg": "^6.0.0",
"typescript-event-target": "^1.1.1",
"webdav": "^5.8.0"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
}
},
"node_modules/@nextcloud/sharing/node_modules/@nextcloud/sharing": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.2.5.tgz",
"integrity": "sha512-B3K5Dq9b5dexDA5n3AAuCF69Huwhrpw0J72fsVXV4KpPdImjhVPlExAv5o70AoXa+OqN4Rwn6gqJw+3ED892zg==",
"license": "GPL-3.0-or-later",
"optional": true,
"dependencies": {
"@nextcloud/initial-state": "^2.2.0"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
}
},
"node_modules/@nextcloud/sharing/node_modules/@nextcloud/sharing/node_modules/@nextcloud/initial-state": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-2.2.0.tgz",
"integrity": "sha512-cDW98L5KGGgpS8pzd+05304/p80cyu8U2xSDQGa+kGPTpUFmCbv2qnO5WrwwGTauyjYijCal2bmw82VddSH+Pg==",
"license": "GPL-3.0-or-later",
"optional": true,
"engines": {
"node": "^20.0.0",
"npm": "^10.0.0"
}
},
"node_modules/@nextcloud/timezones": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/timezones/-/timezones-0.2.0.tgz",
@ -3735,9 +3804,9 @@
}
},
"node_modules/@nextcloud/upload/node_modules/@nextcloud/dialogs": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-6.4.0.tgz",
"integrity": "sha512-xtmmj7vGocEmImDJrnD4WfSIbzWH0qaSNP9+OnrX4E61IB1MjoQ/+NszRzf+M/vT1k9X+o0Tjgnzf06QKOichA==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-6.4.1.tgz",
"integrity": "sha512-Pr9pnZ21bwvvM43sc9tTLo3ETM6kIffmdILwvyfbTdCXkPcOsgggE2FI1c5iZUbtd8hrvAYMic4oZxhqA2izSA==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@mdi/js": "^7.4.47",
@ -3808,6 +3877,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@nextcloud/upload/node_modules/@nextcloud/files": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.0.tgz",
"integrity": "sha512-LVZklgooZzBj2jkbPRZO4jnnvW5+RvOn7wN5weyOZltF6i2wVMbg1Y/Czl2pi/UNMjUm5ENqc0j7FgxMBo8bwA==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nextcloud/auth": "^2.5.1",
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/l10n": "^3.3.0",
"@nextcloud/logger": "^3.0.2",
"@nextcloud/paths": "^2.2.1",
"@nextcloud/router": "^3.0.1",
"@nextcloud/sharing": "^0.2.4",
"cancelable-promise": "^4.3.1",
"is-svg": "^6.0.0",
"typescript-event-target": "^1.1.1",
"webdav": "^5.8.0"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
}
},
"node_modules/@nextcloud/upload/node_modules/@nextcloud/initial-state": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-2.2.0.tgz",
@ -3862,9 +3953,9 @@
}
},
"node_modules/@nextcloud/vue": {
"version": "8.34.0",
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.34.0.tgz",
"integrity": "sha512-zUmInTvT4NgbRjWJZbw8nA+h4EqitYKfoCTj3h3Xr930sQZcczQatPtSo5Sps8RAh+JJz3iiAqAawYqS9jvBdA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.35.0.tgz",
"integrity": "sha512-qPm0aaPbnt7n694WQ97T+EMQTxCa3+RPKDzsBVD6vb01N4uGYwjvrEEOLVmBMlEWqkFy+ks3tpeOjkDPOoJbNA==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@floating-ui/dom": "^1.7.4",
@ -3872,12 +3963,12 @@
"@nextcloud/auth": "^2.5.3",
"@nextcloud/axios": "^2.5.2",
"@nextcloud/browser-storage": "^0.5.0",
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/event-bus": "^3.3.2",
"@nextcloud/capabilities": "^1.2.1",
"@nextcloud/event-bus": "^3.3.3",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.4.0",
"@nextcloud/l10n": "^3.4.1",
"@nextcloud/logger": "^3.0.2",
"@nextcloud/router": "^3.0.1",
"@nextcloud/router": "^3.1.0",
"@nextcloud/sharing": "^0.3.0",
"@nextcloud/timezones": "^0.2.0",
"@nextcloud/vue-select": "^3.26.0",
@ -11291,9 +11382,9 @@
}
},
"node_modules/js-beautify/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {

View file

@ -34,7 +34,7 @@
"@nextcloud/capabilities": "^1.2.1",
"@nextcloud/dialogs": "^7.1.0",
"@nextcloud/event-bus": "^3.3.3",
"@nextcloud/files": "^3.12.0",
"@nextcloud/files": "^4.0.0-beta.4",
"@nextcloud/initial-state": "^3.0.0",
"@nextcloud/l10n": "^3.4.1",
"@nextcloud/logger": "^3.0.3",
@ -44,7 +44,7 @@
"@nextcloud/router": "^3.1.0",
"@nextcloud/sharing": "^0.3.0",
"@nextcloud/upload": "^1.11.0",
"@nextcloud/vue": "^8.34.0",
"@nextcloud/vue": "^8.35.0",
"@simplewebauthn/browser": "^13.2.2",
"@vue/web-component-wrapper": "^1.3.0",
"@vueuse/components": "^11.3.0",

View file

@ -60,8 +60,6 @@ describe('core: LoginForm', () => {
},
})
page.debug()
const input: HTMLInputElement = page.getByRole('textbox', { name: /Account name or email/ })
expect(input.id).toBe('user')
expect(input.name).toBe('user')

View file

@ -12,25 +12,35 @@ const updatedTagName = 'bar'
describe('Create system tags', () => {
before(() => {
// delete any existing tags
cy.runOccCommand('tag:list --output=json').then((output) => {
Object.keys(JSON.parse(output.stdout)).forEach((id) => {
cy.runOccCommand(`tag:delete ${id}`)
})
})
// login as admin and go to admin settings
cy.login(admin)
cy.visit('/settings/admin')
})
it('Can create a tag', () => {
cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag')
cy.get('input#system-tag-name').should('exist').and('have.value', '')
cy.get('input#system-tag-name').type(tagName)
cy.get('input#system-tag-name').should('have.value', tagName)
// submit the form
cy.get('input#system-tag-name').type('{enter}')
// wait for the tag to be created
cy.wait('@createTag').its('response.statusCode').should('eq', 201)
// see that the created tag is in the list
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', tagName).should('exist')
// ensure only one tag exists
cy.get('li').should('have.length', 1)
})
cy.get(`ul#${id} li span[title="${tagName}"]`)
.should('exist')
.should('have.length', 1)
})
})
})
@ -42,12 +52,9 @@ describe('Update system tags', { testIsolation: false }, () => {
})
it('select the tag', () => {
// select the tag to edit
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', tagName).should('exist').click()
})
cy.get(`ul#${id} li span[title="${tagName}"]`).should('exist').click()
})
// see that the tag name matches the selected tag
cy.get('input#system-tag-name').should('exist').and('have.value', tagName)
@ -57,28 +64,27 @@ describe('Update system tags', { testIsolation: false }, () => {
})
it('update the tag name and level', () => {
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*').as('updateTag')
cy.get('input#system-tag-name').clear()
cy.get('input#system-tag-name').type(updatedTagName)
cy.get('input#system-tag-name').should('have.value', updatedTagName)
// select the new tag level
cy.get('input#system-tag-level').focus()
cy.get('input#system-tag-level').invoke('attr', 'aria-controls').then((id) => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', 'Invisible').should('exist').click()
})
cy.get(`ul#${id} li span[title="Invisible"]`).should('exist').click()
})
// submit the form
cy.get('input#system-tag-name').type('{enter}')
// wait for the tag to be updated
cy.wait('@updateTag').its('response.statusCode').should('eq', 207)
})
it('see the tag was successfully updated', () => {
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', `${updatedTagName} (invisible)`).should('exist')
// ensure only one tag exists
cy.get('li').should('have.length', 1)
})
cy.get(`ul#${id} li span[title="${updatedTagName} (invisible)"]`)
.should('exist')
.should('have.length', 1)
})
})
})
@ -93,9 +99,7 @@ describe('Delete system tags', { testIsolation: false }, () => {
// select the tag to edit
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', `${updatedTagName} (invisible)`).should('exist').click()
})
cy.get(`ul#${id} li span[title="${updatedTagName} (invisible)"]`).should('exist').click()
})
// see that the tag name matches the selected tag
cy.get('input#system-tag-name').should('exist').and('have.value', updatedTagName)
@ -105,17 +109,18 @@ describe('Delete system tags', { testIsolation: false }, () => {
})
it('can delete the tag', () => {
cy.intercept('DELETE', '/remote.php/dav/systemtags/*').as('deleteTag')
cy.get('.system-tag-form__row').within(() => {
cy.contains('button', 'Delete').should('be.enabled').click()
})
// wait for the tag to be deleted
cy.wait('@deleteTag').its('response.statusCode').should('eq', 204)
})
it('see that the deleted tag is not present', () => {
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', updatedTagName).should('not.exist')
})
cy.get(`ul#${id} li span[title="${updatedTagName}"]`).should('not.exist')
})
})
})

2
dist/1078-1078.js vendored

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
1078-1078.js.license

View file

@ -12,6 +12,7 @@ SPDX-FileCopyrightText: debounce developers
SPDX-FileCopyrightText: Tobias Koppers @sokra
SPDX-FileCopyrightText: T. Jameson Little <t.jameson.little@gmail.com>
SPDX-FileCopyrightText: Roman Shtylman <shtylman@gmail.com>
SPDX-FileCopyrightText: Perry Mitchell <perry@perrymitchell.net>
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
SPDX-FileCopyrightText: Joyent
SPDX-FileCopyrightText: Jonas Schade <derzade@gmail.com>
@ -49,11 +50,8 @@ This file is generated from multiple sources. Included packages:
- @nextcloud/event-bus
- version: 3.3.3
- license: GPL-3.0-or-later
- @nextcloud/initial-state
- version: 2.2.0
- license: GPL-3.0-or-later
- @nextcloud/files
- version: 3.12.0
- version: 4.0.0-beta.4
- license: AGPL-3.0-or-later
- @nextcloud/initial-state
- version: 3.0.0
@ -70,8 +68,11 @@ This file is generated from multiple sources. Included packages:
- @nextcloud/router
- version: 3.1.0
- license: GPL-3.0-or-later
- @nextcloud/sharing
- version: 0.3.0
- license: GPL-3.0-or-later
- @nextcloud/vue
- version: 8.34.0
- version: 8.35.0
- license: AGPL-3.0-or-later
- @vue/devtools-api
- version: 6.6.4
@ -139,6 +140,9 @@ This file is generated from multiple sources. Included packages:
- vue
- version: 2.7.16
- license: MIT
- webdav
- version: 5.8.0
- license: MIT
- nextcloud
- version: 1.0.0
- license: AGPL-3.0-or-later

View file

@ -179,7 +179,7 @@ This file is generated from multiple sources. Included packages:
- version: 0.3.0
- license: GPL-3.0-or-later
- @nextcloud/vue
- version: 8.34.0
- version: 8.35.0
- license: AGPL-3.0-or-later
- @ungap/structured-clone
- version: 1.3.0

Some files were not shown because too many files have changed in this diff Show more