chore: update @nextcloud/files to 4.0.0

Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
This commit is contained in:
skjnldsv 2025-11-28 16:35:09 +01:00
parent d15feb4ba6
commit 492bdb7010
No known key found for this signature in database
GPG key ID: 20CCE4F37ED06566
204 changed files with 198733 additions and 1107 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

@ -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",
@ -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",
@ -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",

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')

28837
dist/Plus-CFgExibL.chunk.mjs vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/Plus-CFgExibL.chunk.mjs.map vendored Normal file

File diff suppressed because one or more lines are too long

9617
dist/Plus-Dd_N8nyq.chunk.css vendored Normal file

File diff suppressed because one or more lines are too long

969
dist/SetStatusModal-VT77gtjx.chunk.mjs vendored Normal file
View file

@ -0,0 +1,969 @@
const appName = "nextcloud-ui";
const appVersion = "1.0.0";
import { v as NcSelect, x as NcEmojiPicker, N as NcButton, _ as _sfc_main$7, y as NcUserStatusIcon, z as NcModal, s as showError } from "./Plus-CFgExibL.chunk.mjs";
import { f as translate, _ as _export_sfc, r as resolveComponent, s as createElementBlock, o as openBlock, k as createBaseVNode, b as createVNode, t as toDisplayString, w as withCtx, d as createTextVNode, F as Fragment, u as renderList, c as createBlock, Z as withKeys, j as createCommentVNode, Q as mergeProps, Y as withModifiers, i as generateUrl } from "./TrashCanOutline-CLxw5nIJ.chunk.mjs";
import { c as clearAtFormat, m as mapGetters, a as mapState, O as OnlineStatusMixin, l as logger } from "./user_status-menu.mjs";
import "./index-MBGsF8CJ.chunk.mjs";
function getAllClearAtOptions() {
return [{
label: translate("user_status", "Don't clear"),
clearAt: null
}, {
label: translate("user_status", "30 minutes"),
clearAt: {
type: "period",
time: 1800
}
}, {
label: translate("user_status", "1 hour"),
clearAt: {
type: "period",
time: 3600
}
}, {
label: translate("user_status", "4 hours"),
clearAt: {
type: "period",
time: 14400
}
}, {
label: translate("user_status", "Today"),
clearAt: {
type: "end-of",
time: "day"
}
}, {
label: translate("user_status", "This week"),
clearAt: {
type: "end-of",
time: "week"
}
}];
}
const _sfc_main$6 = {
name: "ClearAtSelect",
components: {
NcSelect
},
props: {
clearAt: {
type: Object,
default: null
}
},
emits: ["selectClearAt"],
data() {
return {
options: getAllClearAtOptions()
};
},
computed: {
/**
* Returns an object of the currently selected option
*
* @return {object}
*/
option() {
return {
clearAt: this.clearAt,
label: clearAtFormat(this.clearAt)
};
}
},
methods: {
t: translate,
/**
* Triggered when the user selects a new option.
*
* @param {object=} option The new selected option
*/
select(option) {
if (!option) {
return;
}
this.$emit("selectClearAt", option.clearAt);
}
}
};
const _hoisted_1$6 = { class: "clear-at-select" };
const _hoisted_2$6 = {
class: "clear-at-select__label",
for: "clearStatus"
};
function _sfc_render$6(_ctx, _cache, $props, $setup, $data, $options) {
const _component_NcSelect = resolveComponent("NcSelect");
return openBlock(), createElementBlock("div", _hoisted_1$6, [
createBaseVNode(
"label",
_hoisted_2$6,
toDisplayString($options.t("user_status", "Clear status after")),
1
/* TEXT */
),
createVNode(_component_NcSelect, {
"input-id": "clearStatus",
class: "clear-at-select__select",
options: $data.options,
"model-value": $options.option,
clearable: false,
placement: "top",
"label-outside": "",
"onOption:selected": $options.select
}, null, 8, ["options", "model-value", "onOption:selected"])
]);
}
const ClearAtSelect = /* @__PURE__ */ _export_sfc(_sfc_main$6, [["render", _sfc_render$6], ["__scopeId", "data-v-4b76bb61"], ["__file", "/home/admin/Docker/workspace/server/build/frontend/apps/user_status/src/components/ClearAtSelect.vue"]]);
const _sfc_main$5 = {
name: "CustomMessageInput",
components: {
NcTextField: _sfc_main$7,
NcButton,
NcEmojiPicker
},
props: {
icon: {
type: String,
default: "😀"
},
message: {
type: String,
default: ""
},
disabled: {
type: Boolean,
default: false
}
},
emits: [
"change",
"selectIcon"
],
computed: {
/**
* Returns the user-set icon or a smiley in case no icon is set
*
* @return {string}
*/
visibleIcon() {
return this.icon || "😀";
}
},
methods: {
t: translate,
focus() {
this.$refs.input.focus();
},
/**
* Notifies the parent component about a changed input
*
* @param {string} value The new input value
*/
onChange(value) {
this.$emit("change", value);
},
setIcon(icon) {
this.$emit("selectIcon", icon);
}
}
};
const _hoisted_1$5 = {
class: "custom-input",
role: "group"
};
const _hoisted_2$5 = { class: "custom-input__container" };
function _sfc_render$5(_ctx, _cache, $props, $setup, $data, $options) {
const _component_NcButton = resolveComponent("NcButton");
const _component_NcEmojiPicker = resolveComponent("NcEmojiPicker");
const _component_NcTextField = resolveComponent("NcTextField");
return openBlock(), createElementBlock("div", _hoisted_1$5, [
createVNode(_component_NcEmojiPicker, {
container: ".custom-input",
onSelect: $options.setIcon
}, {
default: withCtx(() => [
createVNode(_component_NcButton, {
variant: "tertiary",
"aria-label": $options.t("user_status", "Emoji for your status message")
}, {
icon: withCtx(() => [
createTextVNode(
toDisplayString($options.visibleIcon),
1
/* TEXT */
)
]),
_: 1
/* STABLE */
}, 8, ["aria-label"])
]),
_: 1
/* STABLE */
}, 8, ["onSelect"]),
createBaseVNode("div", _hoisted_2$5, [
createVNode(_component_NcTextField, {
ref: "input",
maxlength: "80",
disabled: $props.disabled,
placeholder: $options.t("user_status", "What is your status?"),
"model-value": $props.message,
type: "text",
label: $options.t("user_status", "What is your status?"),
"onUpdate:modelValue": $options.onChange
}, null, 8, ["disabled", "placeholder", "model-value", "label", "onUpdate:modelValue"])
])
]);
}
const CustomMessageInput = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["render", _sfc_render$5], ["__scopeId", "data-v-0ef173e0"], ["__file", "/home/admin/Docker/workspace/server/build/frontend/apps/user_status/src/components/CustomMessageInput.vue"]]);
const _sfc_main$4 = {
name: "OnlineStatusSelect",
components: {
NcUserStatusIcon
},
props: {
checked: {
type: Boolean,
default: false
},
type: {
type: String,
required: true
},
label: {
type: String,
required: true
},
subline: {
type: String,
default: null
}
},
emits: ["select"],
computed: {
id() {
return `user-status-online-status-${this.type}`;
}
},
methods: {
onChange() {
this.$emit("select", this.type);
}
}
};
const _hoisted_1$4 = { class: "user-status-online-select" };
const _hoisted_2$4 = ["id", "checked"];
const _hoisted_3$3 = ["for"];
const _hoisted_4$3 = { class: "user-status-online-select__icon-wrapper" };
const _hoisted_5$2 = { class: "user-status-online-select__subline" };
function _sfc_render$4(_ctx, _cache, $props, $setup, $data, $options) {
const _component_NcUserStatusIcon = resolveComponent("NcUserStatusIcon");
return openBlock(), createElementBlock("div", _hoisted_1$4, [
createBaseVNode("input", {
id: $options.id,
checked: $props.checked,
class: "hidden-visually user-status-online-select__input",
type: "radio",
name: "user-status-online",
onChange: _cache[0] || (_cache[0] = (...args) => $options.onChange && $options.onChange(...args))
}, null, 40, _hoisted_2$4),
createBaseVNode("label", {
for: $options.id,
class: "user-status-online-select__label"
}, [
createBaseVNode("span", _hoisted_4$3, [
createVNode(_component_NcUserStatusIcon, {
status: $props.type,
class: "user-status-online-select__icon",
"aria-hidden": "true"
}, null, 8, ["status"])
]),
createTextVNode(
" " + toDisplayString($props.label) + " ",
1
/* TEXT */
),
createBaseVNode(
"em",
_hoisted_5$2,
toDisplayString($props.subline),
1
/* TEXT */
)
], 8, _hoisted_3$3)
]);
}
const OnlineStatusSelect = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["render", _sfc_render$4], ["__scopeId", "data-v-a592c307"], ["__file", "/home/admin/Docker/workspace/server/build/frontend/apps/user_status/src/components/OnlineStatusSelect.vue"]]);
const _sfc_main$3 = {
name: "PredefinedStatus",
props: {
messageId: {
type: String,
required: true
},
icon: {
type: String,
required: true
},
message: {
type: String,
required: true
},
clearAt: {
type: Object,
required: false,
default: null
},
selected: {
type: Boolean,
required: false,
default: false
}
},
emits: ["select"],
computed: {
id() {
return `user-status-predefined-status-${this.messageId}`;
},
formattedClearAt() {
return clearAtFormat(this.clearAt);
}
},
methods: {
/**
* Emits an event when the user clicks the row
*/
select() {
this.$emit("select");
}
}
};
const _hoisted_1$3 = { class: "predefined-status" };
const _hoisted_2$3 = ["id", "checked"];
const _hoisted_3$2 = ["for"];
const _hoisted_4$2 = {
"aria-hidden": "true",
class: "predefined-status__label--icon"
};
const _hoisted_5$1 = { class: "predefined-status__label--message" };
const _hoisted_6$1 = { class: "predefined-status__label--clear-at" };
function _sfc_render$3(_ctx, _cache, $props, $setup, $data, $options) {
return openBlock(), createElementBlock("li", _hoisted_1$3, [
createBaseVNode("input", {
id: $options.id,
class: "hidden-visually predefined-status__input",
type: "radio",
name: "predefined-status",
checked: $props.selected,
onChange: _cache[0] || (_cache[0] = (...args) => $options.select && $options.select(...args))
}, null, 40, _hoisted_2$3),
createBaseVNode("label", {
class: "predefined-status__label",
for: $options.id
}, [
createBaseVNode(
"span",
_hoisted_4$2,
toDisplayString($props.icon),
1
/* TEXT */
),
createBaseVNode(
"span",
_hoisted_5$1,
toDisplayString($props.message),
1
/* TEXT */
),
createBaseVNode(
"span",
_hoisted_6$1,
toDisplayString($options.formattedClearAt),
1
/* TEXT */
)
], 8, _hoisted_3$2)
]);
}
const PredefinedStatus = /* @__PURE__ */ _export_sfc(_sfc_main$3, [["render", _sfc_render$3], ["__scopeId", "data-v-c2f9bb60"], ["__file", "/home/admin/Docker/workspace/server/build/frontend/apps/user_status/src/components/PredefinedStatus.vue"]]);
const _sfc_main$2 = {
name: "PredefinedStatusesList",
components: {
PredefinedStatus
},
emits: ["selectStatus"],
data() {
return {
lastSelected: null
};
},
computed: {
...mapState({
predefinedStatuses: (state) => state.predefinedStatuses.predefinedStatuses,
messageId: (state) => state.userStatus.messageId
}),
...mapGetters(["statusesHaveLoaded"])
},
watch: {
messageId: {
immediate: true,
handler() {
this.lastSelected = this.messageId;
}
}
},
/**
* Loads all predefined statuses from the server
* when this component is mounted
*/
created() {
this.$store.dispatch("loadAllPredefinedStatuses");
},
methods: {
t: translate,
/**
* Emits an event when the user selects a status
*
* @param {object} status The selected status
*/
selectStatus(status) {
this.lastSelected = status.id;
this.$emit("selectStatus", status);
}
}
};
const _hoisted_1$2 = ["aria-label"];
const _hoisted_2$2 = {
key: 1,
class: "predefined-statuses-list"
};
function _sfc_render$2(_ctx, _cache, $props, $setup, $data, $options) {
const _component_PredefinedStatus = resolveComponent("PredefinedStatus");
return _ctx.statusesHaveLoaded ? (openBlock(), createElementBlock("ul", {
key: 0,
class: "predefined-statuses-list",
"aria-label": $options.t("user_status", "Predefined statuses")
}, [
(openBlock(true), createElementBlock(
Fragment,
null,
renderList(_ctx.predefinedStatuses, (status) => {
return openBlock(), createBlock(_component_PredefinedStatus, {
key: status.id,
"message-id": status.id,
icon: status.icon,
message: status.message,
"clear-at": status.clearAt,
selected: $data.lastSelected === status.id,
onSelect: ($event) => $options.selectStatus(status)
}, null, 8, ["message-id", "icon", "message", "clear-at", "selected", "onSelect"]);
}),
128
/* KEYED_FRAGMENT */
))
], 8, _hoisted_1$2)) : (openBlock(), createElementBlock("div", _hoisted_2$2, [..._cache[0] || (_cache[0] = [
createBaseVNode(
"div",
{ class: "icon icon-loading-small" },
null,
-1
/* CACHED */
)
])]));
}
const PredefinedStatusesList = /* @__PURE__ */ _export_sfc(_sfc_main$2, [["render", _sfc_render$2], ["__scopeId", "data-v-b3aa73e0"], ["__file", "/home/admin/Docker/workspace/server/build/frontend/apps/user_status/src/components/PredefinedStatusesList.vue"]]);
const _sfc_main$1 = {
name: "PreviousStatus",
components: {
NcButton
},
props: {
icon: {
type: [String, null],
required: true
},
message: {
type: String,
required: true
}
},
emits: ["select"],
methods: {
t: translate,
/**
* Emits an event when the user clicks the row
*/
select() {
this.$emit("select");
}
}
};
const _hoisted_1$1 = { class: "predefined-status__icon" };
const _hoisted_2$1 = { class: "predefined-status__message" };
const _hoisted_3$1 = { class: "predefined-status__clear-at" };
const _hoisted_4$1 = { class: "backup-status__reset-button" };
function _sfc_render$1(_ctx, _cache, $props, $setup, $data, $options) {
const _component_NcButton = resolveComponent("NcButton");
return openBlock(), createElementBlock(
"div",
{
class: "predefined-status backup-status",
tabindex: "0",
onKeyup: [
_cache[0] || (_cache[0] = withKeys((...args) => $options.select && $options.select(...args), ["enter"])),
_cache[1] || (_cache[1] = withKeys((...args) => $options.select && $options.select(...args), ["space"]))
],
onClick: _cache[2] || (_cache[2] = (...args) => $options.select && $options.select(...args))
},
[
createBaseVNode(
"span",
_hoisted_1$1,
toDisplayString($props.icon),
1
/* TEXT */
),
createBaseVNode(
"span",
_hoisted_2$1,
toDisplayString($props.message),
1
/* TEXT */
),
createBaseVNode(
"span",
_hoisted_3$1,
toDisplayString($options.t("user_status", "Previously set")),
1
/* TEXT */
),
createBaseVNode("div", _hoisted_4$1, [
createVNode(_component_NcButton, { onClick: $options.select }, {
default: withCtx(() => [
createTextVNode(
toDisplayString($options.t("user_status", "Reset status")),
1
/* TEXT */
)
]),
_: 1
/* STABLE */
}, 8, ["onClick"])
])
],
32
/* NEED_HYDRATION */
);
}
const PreviousStatus = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["render", _sfc_render$1], ["__scopeId", "data-v-ee7ae222"], ["__file", "/home/admin/Docker/workspace/server/build/frontend/apps/user_status/src/components/PreviousStatus.vue"]]);
function getAllStatusOptions() {
return [{
type: "online",
label: translate("user_status", "Online")
}, {
type: "away",
label: translate("user_status", "Away")
}, {
type: "busy",
label: translate("user_status", "Busy")
}, {
type: "dnd",
label: translate("user_status", "Do not disturb"),
subline: translate("user_status", "Mute all notifications")
}, {
type: "invisible",
label: translate("user_status", "Invisible"),
subline: translate("user_status", "Appear offline")
}];
}
const _sfc_main = {
name: "SetStatusModal",
components: {
ClearAtSelect,
CustomMessageInput,
NcModal,
OnlineStatusSelect,
PredefinedStatusesList,
PreviousStatus,
NcButton
},
mixins: [OnlineStatusMixin],
props: {
/**
* Whether the component should be rendered as a Dashboard Status or a User Menu Entries
* true = Dashboard Status
* false = User Menu Entries
*/
inline: {
type: Boolean,
default: false
}
},
emits: ["close"],
data() {
return {
clearAt: null,
editedMessage: "",
predefinedMessageId: null,
isSavingStatus: false,
statuses: getAllStatusOptions()
};
},
computed: {
messageId() {
return this.$store.state.userStatus.messageId;
},
icon() {
return this.$store.state.userStatus.icon;
},
message() {
return this.$store.state.userStatus.message || "";
},
hasBackupStatus() {
return this.messageId && (this.backupIcon || this.backupMessage);
},
backupIcon() {
return this.$store.state.userBackupStatus.icon || "";
},
backupMessage() {
return this.$store.state.userBackupStatus.message || "";
},
absencePageUrl() {
return generateUrl("settings/user/availability#absence");
},
resetButtonText() {
if (this.backupIcon && this.backupMessage) {
return translate("user_status", 'Reset status to "{icon} {message}"', {
icon: this.backupIcon,
message: this.backupMessage
});
} else if (this.backupMessage) {
return translate("user_status", 'Reset status to "{message}"', {
message: this.backupMessage
});
} else if (this.backupIcon) {
return translate("user_status", 'Reset status to "{icon}"', {
icon: this.backupIcon
});
}
return translate("user_status", "Reset status");
},
setReturnFocus() {
if (this.inline) {
return void 0;
}
return document.querySelector('[aria-controls="header-menu-user-menu"]') ?? void 0;
}
},
watch: {
message: {
immediate: true,
handler(newValue) {
this.editedMessage = newValue;
}
}
},
/**
* Loads the current status when a user opens dialog
*/
mounted() {
this.$store.dispatch("fetchBackupFromServer");
this.predefinedMessageId = this.$store.state.userStatus.messageId;
if (this.$store.state.userStatus.clearAt !== null) {
this.clearAt = {
type: "_time",
time: this.$store.state.userStatus.clearAt
};
}
},
methods: {
t: translate,
/**
* Closes the Set Status modal
*/
closeModal() {
this.$emit("close");
},
/**
* Sets a new icon
*
* @param {string} icon The new icon
*/
setIcon(icon) {
this.predefinedMessageId = null;
this.$store.dispatch("setCustomMessage", {
message: this.message,
icon,
clearAt: this.clearAt
});
this.$nextTick(() => {
this.$refs.customMessageInput.focus();
});
},
/**
* Sets a new message
*
* @param {string} message The new message
*/
setMessage(message) {
this.predefinedMessageId = null;
this.editedMessage = message;
},
/**
* Sets a new clearAt value
*
* @param {object} clearAt The new clearAt object
*/
setClearAt(clearAt) {
this.clearAt = clearAt;
},
/**
* Sets new icon/message/clearAt based on a predefined message
*
* @param {object} status The predefined status object
*/
selectPredefinedMessage(status) {
this.predefinedMessageId = status.id;
this.clearAt = status.clearAt;
this.$store.dispatch("setPredefinedMessage", {
messageId: status.id,
clearAt: status.clearAt
});
},
/**
* Saves the status and closes the
*
* @return {Promise<void>}
*/
async saveStatus() {
if (this.isSavingStatus) {
return;
}
try {
this.isSavingStatus = true;
if (this.predefinedMessageId === null) {
await this.$store.dispatch("setCustomMessage", {
message: this.editedMessage,
icon: this.icon,
clearAt: this.clearAt
});
} else {
this.$store.dispatch("setPredefinedMessage", {
messageId: this.predefinedMessageId,
clearAt: this.clearAt
});
}
} catch (err) {
showError(translate("user_status", "There was an error saving the status"));
logger.debug(err);
this.isSavingStatus = false;
return;
}
this.isSavingStatus = false;
this.closeModal();
},
/**
*
* @return {Promise<void>}
*/
async clearStatus() {
try {
this.isSavingStatus = true;
await this.$store.dispatch("clearMessage");
} catch (err) {
showError(translate("user_status", "There was an error clearing the status"));
logger.debug(err);
this.isSavingStatus = false;
return;
}
this.isSavingStatus = false;
this.predefinedMessageId = null;
this.closeModal();
},
/**
*
* @return {Promise<void>}
*/
async revertBackupFromServer() {
try {
this.isSavingStatus = true;
await this.$store.dispatch("revertBackupFromServer", {
messageId: this.messageId
});
} catch (err) {
showError(translate("user_status", "There was an error reverting the status"));
logger.debug(err);
this.isSavingStatus = false;
return;
}
this.isSavingStatus = false;
this.predefinedMessageId = this.$store.state.userStatus?.messageId;
}
}
};
const _hoisted_1 = { class: "set-status-modal" };
const _hoisted_2 = {
id: "user_status-set-dialog",
class: "set-status-modal__header"
};
const _hoisted_3 = ["aria-label"];
const _hoisted_4 = { class: "set-status-modal__header" };
const _hoisted_5 = { class: "set-status-modal__custom-input" };
const _hoisted_6 = {
key: 0,
class: "set-status-modal__automation-hint"
};
const _hoisted_7 = { class: "status-buttons" };
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_OnlineStatusSelect = resolveComponent("OnlineStatusSelect");
const _component_CustomMessageInput = resolveComponent("CustomMessageInput");
const _component_NcButton = resolveComponent("NcButton");
const _component_PreviousStatus = resolveComponent("PreviousStatus");
const _component_PredefinedStatusesList = resolveComponent("PredefinedStatusesList");
const _component_ClearAtSelect = resolveComponent("ClearAtSelect");
const _component_NcModal = resolveComponent("NcModal");
return openBlock(), createBlock(_component_NcModal, {
size: "normal",
"label-id": "user_status-set-dialog",
dark: "",
"set-return-focus": $options.setReturnFocus,
onClose: $options.closeModal
}, {
default: withCtx(() => [
createBaseVNode("div", _hoisted_1, [
createCommentVNode(" Status selector "),
createBaseVNode(
"h2",
_hoisted_2,
toDisplayString($options.t("user_status", "Online status")),
1
/* TEXT */
),
createBaseVNode("div", {
class: "set-status-modal__online-status",
role: "radiogroup",
"aria-label": $options.t("user_status", "Online status")
}, [
(openBlock(true), createElementBlock(
Fragment,
null,
renderList($data.statuses, (status) => {
return openBlock(), createBlock(_component_OnlineStatusSelect, mergeProps({
key: status.type
}, { ref_for: true }, status, {
checked: status.type === _ctx.statusType,
onSelect: _ctx.changeStatus
}), null, 16, ["checked", "onSelect"]);
}),
128
/* KEYED_FRAGMENT */
))
], 8, _hoisted_3),
createCommentVNode(" Status message form "),
createBaseVNode(
"form",
{
onSubmit: _cache[0] || (_cache[0] = withModifiers((...args) => $options.saveStatus && $options.saveStatus(...args), ["prevent"])),
onReset: _cache[1] || (_cache[1] = (...args) => $options.clearStatus && $options.clearStatus(...args))
},
[
createBaseVNode(
"h3",
_hoisted_4,
toDisplayString($options.t("user_status", "Status message")),
1
/* TEXT */
),
createBaseVNode("div", _hoisted_5, [
createVNode(_component_CustomMessageInput, {
ref: "customMessageInput",
icon: $options.icon,
message: $data.editedMessage,
onChange: $options.setMessage,
onSelectIcon: $options.setIcon
}, null, 8, ["icon", "message", "onChange", "onSelectIcon"]),
$options.messageId === "vacationing" ? (openBlock(), createBlock(_component_NcButton, {
key: 0,
href: $options.absencePageUrl,
target: "_blank",
variant: "secondary",
"aria-label": $options.t("user_status", "Set absence period")
}, {
default: withCtx(() => [
createTextVNode(
toDisplayString($options.t("user_status", "Set absence period and replacement") + " ↗"),
1
/* TEXT */
)
]),
_: 1
/* STABLE */
}, 8, ["href", "aria-label"])) : createCommentVNode("v-if", true)
]),
$options.hasBackupStatus ? (openBlock(), createElementBlock(
"div",
_hoisted_6,
toDisplayString($options.t("user_status", "Your status was set automatically")),
1
/* TEXT */
)) : createCommentVNode("v-if", true),
$options.hasBackupStatus ? (openBlock(), createBlock(_component_PreviousStatus, {
key: 1,
icon: $options.backupIcon,
message: $options.backupMessage,
onSelect: $options.revertBackupFromServer
}, null, 8, ["icon", "message", "onSelect"])) : createCommentVNode("v-if", true),
createVNode(_component_PredefinedStatusesList, { onSelectStatus: $options.selectPredefinedMessage }, null, 8, ["onSelectStatus"]),
createVNode(_component_ClearAtSelect, {
"clear-at": $data.clearAt,
onSelectClearAt: $options.setClearAt
}, null, 8, ["clear-at", "onSelectClearAt"]),
createBaseVNode("div", _hoisted_7, [
createVNode(_component_NcButton, {
wide: true,
variant: "tertiary",
type: "reset",
"aria-label": $options.t("user_status", "Clear status message"),
disabled: $data.isSavingStatus
}, {
default: withCtx(() => [
createTextVNode(
toDisplayString($options.t("user_status", "Clear status message")),
1
/* TEXT */
)
]),
_: 1
/* STABLE */
}, 8, ["aria-label", "disabled"]),
createVNode(_component_NcButton, {
wide: true,
variant: "primary",
type: "submit",
"aria-label": $options.t("user_status", "Set status message"),
disabled: $data.isSavingStatus
}, {
default: withCtx(() => [
createTextVNode(
toDisplayString($options.t("user_status", "Set status message")),
1
/* TEXT */
)
]),
_: 1
/* STABLE */
}, 8, ["aria-label", "disabled"])
])
],
32
/* NEED_HYDRATION */
)
])
]),
_: 1
/* STABLE */
}, 8, ["set-return-focus", "onClose"]);
}
const SetStatusModal = /* @__PURE__ */ _export_sfc(_sfc_main, [["render", _sfc_render], ["__scopeId", "data-v-aaeb0cdc"], ["__file", "/home/admin/Docker/workspace/server/build/frontend/apps/user_status/src/components/SetStatusModal.vue"]]);
export {
SetStatusModal as default
};
//# sourceMappingURL=SetStatusModal-VT77gtjx.chunk.mjs.map

File diff suppressed because one or more lines are too long

17753
dist/TrashCanOutline-CLxw5nIJ.chunk.mjs vendored Normal file

File diff suppressed because one or more lines are too long

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