Browse Source

Merge pull request #38939 from nextcloud/feat/f2v/more-actions

John Molakvoæ 1 year ago
parent
commit
6234b090cd

+ 2 - 1
__mocks__/@nextcloud/axios.ts

@@ -20,5 +20,6 @@
  *
  */
 export default {
-	  delete: async () => ({ status: 200, data: {} }),
+	delete: async () => ({ status: 200, data: {} }),
+	post: async () => ({ status: 200, data: {} }),
 }

+ 3 - 0
__tests__/jest-setup.ts

@@ -21,3 +21,6 @@
  */
 
 import '@testing-library/jest-dom'
+
+// Mock `window.location` with Jest spies and extend expect
+import 'jest-location-mock'

+ 2 - 1
apps/files/src/actions/deleteAction.spec.ts

@@ -25,8 +25,8 @@ import { File, Folder, Permission } from '@nextcloud/files'
 import { FileAction } from '../services/FileAction'
 import * as eventBus from '@nextcloud/event-bus'
 import axios from '@nextcloud/axios'
-import type { Navigation } from '../services/Navigation'
 import logger from '../logger'
+import type { Navigation } from '../services/Navigation'
 
 const view = {
 	id: 'files',
@@ -44,6 +44,7 @@ describe('Delete action conditions tests', () => {
 		expect(action.id).toBe('delete')
 		expect(action.displayName([], view)).toBe('Delete')
 		expect(action.iconSvgInline([], view)).toBe('SvgMock')
+		expect(action.default).toBe(false)
 		expect(action.order).toBe(100)
 	})
 

+ 1 - 0
apps/files/src/actions/downloadAction.spec.ts

@@ -39,6 +39,7 @@ describe('Download action conditions tests', () => {
 		expect(action.id).toBe('download')
 		expect(action.displayName([], view)).toBe('Download')
 		expect(action.iconSvgInline([], view)).toBe('SvgMock')
+		expect(action.default).toBe(false)
 		expect(action.order).toBe(30)
 	})
 })

+ 163 - 0
apps/files/src/actions/editLocallyAction.spec.ts

@@ -0,0 +1,163 @@
+/**
+ * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { action } from './editLocallyAction'
+import { expect } from '@jest/globals'
+import { File, Folder, Permission } from '@nextcloud/files'
+import { FileAction } from '../services/FileAction'
+import axios from '@nextcloud/axios'
+import type { Navigation } from '../services/Navigation'
+import ncDialogs from '@nextcloud/dialogs'
+
+const view = {
+	id: 'files',
+	name: 'Files',
+} as Navigation
+
+describe('Edit locally action conditions tests', () => {
+	test('Default values', () => {
+		expect(action).toBeInstanceOf(FileAction)
+		expect(action.id).toBe('edit-locally')
+		expect(action.displayName([], view)).toBe('Edit locally')
+		expect(action.iconSvgInline([], view)).toBe('SvgMock')
+		expect(action.default).toBe(true)
+		expect(action.order).toBe(25)
+	})
+})
+
+describe('Edit locally action enabled tests', () => {
+	test('Enabled for file with UPDATE permission', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file], view)).toBe(true)
+	})
+
+	test('Disabled for non-dav ressources', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://domain.com/data/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file], view)).toBe(false)
+	})
+
+	test('Disabled if more than one node', () => {
+		const file1 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+		})
+		const file2 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file1, file2], view)).toBe(false)
+	})
+
+	test('Disabled for files', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+			owner: 'admin',
+			mime: 'text/plain',
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file], view)).toBe(false)
+	})
+
+	test('Disabled without UPDATE permissions', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.READ,
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file], view)).toBe(false)
+	})
+})
+
+describe('Edit locally action execute tests', () => {
+	test('Edit locally opens proper URL', async () => {
+		jest.spyOn(axios, 'post').mockImplementation(async () => ({ data: { ocs: { data: { token: 'foobar' } } } }))
+		jest.spyOn(ncDialogs, 'showError')
+
+		const file = new File({
+			id: 1,
+			source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.UPDATE,
+		})
+
+		const exec = await action.exec(file, view, '/')
+
+		// Silent action
+		expect(exec).toBe(null)
+		expect(axios.post).toBeCalledTimes(1)
+		expect(axios.post).toBeCalledWith('http://localhost/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' })
+		expect(ncDialogs.showError).toBeCalledTimes(0)
+		expect(window.location.href).toBe('nc://open/test@localhost/foobar.txt?token=foobar')
+	})
+
+	test('Edit locally fails and show error', async () => {
+		jest.spyOn(axios, 'post').mockImplementation(async () => ({}))
+		jest.spyOn(ncDialogs, 'showError')
+
+		const file = new File({
+			id: 1,
+			source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.UPDATE,
+		})
+
+		const exec = await action.exec(file, view, '/')
+
+		// Silent action
+		expect(exec).toBe(null)
+		expect(axios.post).toBeCalledTimes(1)
+		expect(axios.post).toBeCalledWith('http://localhost/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' })
+		expect(ncDialogs.showError).toBeCalledTimes(1)
+		expect(ncDialogs.showError).toBeCalledWith('Failed to redirect to client')
+		expect(window.location.href).toBe('http://localhost/')
+	})
+})

+ 74 - 0
apps/files/src/actions/editLocallyAction.ts

@@ -0,0 +1,74 @@
+/**
+ * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { encodePath } from '@nextcloud/paths'
+import { Permission, type Node } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import DevicesSvg from '@mdi/svg/svg/devices.svg?raw'
+
+import { generateOcsUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { registerFileAction, FileAction } from '../services/FileAction'
+import { showError } from '@nextcloud/dialogs'
+
+const openLocalClient = async function(path: string) {
+	const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json'
+
+	try {
+		const result = await axios.post(link, { path })
+		const uid = getCurrentUser()?.uid
+		let url = `nc://open/${uid}@` + window.location.host + encodePath(path)
+		url += '?token=' + result.data.ocs.data.token
+
+		window.location.href = url
+	} catch (error) {
+		showError(t('files', 'Failed to redirect to client'))
+	}
+}
+
+export const action = new FileAction({
+	id: 'edit-locally',
+	displayName: () => t('files', 'Edit locally'),
+	iconSvgInline: () => DevicesSvg,
+
+	// Only works on single files
+	enabled(nodes: Node[]) {
+		// Only works on single node
+		if (nodes.length !== 1) {
+			return false
+		}
+
+		return (nodes[0].permissions & Permission.UPDATE) !== 0
+	},
+
+	async exec(node: Node) {
+		openLocalClient(node.path)
+		return null
+	},
+
+	default: true,
+	order: 25,
+})
+
+if (!/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
+	registerFileAction(action)
+}

+ 391 - 0
apps/files/src/actions/favoriteAction.spec.ts

@@ -0,0 +1,391 @@
+/**
+ * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import * as favoriteAction from './favoriteAction'
+import { action } from './favoriteAction'
+import { expect } from '@jest/globals'
+import { File, Folder, Permission } from '@nextcloud/files'
+import { FileAction } from '../services/FileAction'
+import * as eventBus from '@nextcloud/event-bus'
+import axios from '@nextcloud/axios'
+import type { Navigation } from '../services/Navigation'
+import logger from '../logger'
+
+const view = {
+	id: 'files',
+	name: 'Files',
+} as Navigation
+
+const favoriteView = {
+	id: 'favorites',
+	name: 'Favorites',
+} as Navigation
+
+global.window.OC = {
+	TAG_FAVORITE: '_$!<Favorite>!$_',
+}
+
+describe('Favorite action conditions tests', () => {
+	test('Default values', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+		})
+
+		expect(action).toBeInstanceOf(FileAction)
+		expect(action.id).toBe('favorite')
+		expect(action.displayName([file], view)).toBe('Add to favorites')
+		expect(action.iconSvgInline([], view)).toBe('SvgMock')
+		expect(action.default).toBe(false)
+		expect(action.order).toBe(-50)
+	})
+
+	test('Display name is Remove from favorites if already in favorites', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			attributes: {
+				favorite: 1,
+			},
+		})
+
+		expect(action.displayName([file], view)).toBe('Remove from favorites')
+	})
+
+	test('Display name for multiple state files', () => {
+		const file1 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+			attributes: {
+				favorite: 1,
+			},
+		})
+		const file2 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+			attributes: {
+				favorite: 0,
+			},
+		})
+		const file3 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+			attributes: {
+				favorite: 1,
+			},
+		})
+
+		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')
+	})
+})
+
+describe('Favorite action enabled tests', () => {
+	test('Enabled for dav file', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file], view)).toBe(true)
+	})
+
+	test('Disabled for non-dav ressources', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://domain.com/data/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file], view)).toBe(false)
+	})
+})
+
+describe('Favorite action execute tests', () => {
+	afterEach(() => {
+		jest.spyOn(axios, 'post').mockRestore()
+	})
+
+	test('Favorite triggers tag addition', async () => {
+		jest.spyOn(axios, 'post')
+		jest.spyOn(eventBus, 'emit')
+
+		const file = new File({
+			id: 1,
+			source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+		})
+
+		const exec = await action.exec(file, view, '/')
+
+		expect(exec).toBe(true)
+
+		// Check POST request
+		expect(axios.post).toBeCalledTimes(1)
+		expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: ['_$!<Favorite>!$_'] })
+
+		// Check node change propagation
+		expect(file.attributes.favorite).toBe(1)
+		expect(eventBus.emit).toBeCalledTimes(1)
+		expect(eventBus.emit).toBeCalledWith('files:favorites:added', file)
+	})
+
+	test('Favorite triggers tag removal', async () => {
+		jest.spyOn(axios, 'post')
+		jest.spyOn(eventBus, 'emit')
+
+		const file = new File({
+			id: 1,
+			source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			attributes: {
+				favorite: 1,
+			},
+		})
+
+		const exec = await action.exec(file, view, '/')
+
+		expect(exec).toBe(true)
+
+		// Check POST request
+		expect(axios.post).toBeCalledTimes(1)
+		expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: [] })
+
+		// Check node change propagation
+		expect(file.attributes.favorite).toBe(0)
+		expect(eventBus.emit).toBeCalledTimes(1)
+		expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file)
+	})
+
+	test('Favorite triggers node removal if favorite view and root dir', async () => {
+		jest.spyOn(axios, 'post')
+		jest.spyOn(eventBus, 'emit')
+
+		const file = new File({
+			id: 1,
+			source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			attributes: {
+				favorite: 1,
+			},
+		})
+
+		const exec = await action.exec(file, favoriteView, '/')
+
+		expect(exec).toBe(true)
+
+		// Check POST request
+		expect(axios.post).toBeCalledTimes(1)
+		expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: [] })
+
+		// Check node change propagation
+		expect(file.attributes.favorite).toBe(0)
+		expect(eventBus.emit).toBeCalledTimes(2)
+		expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file)
+		expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:favorites:removed', file)
+	})
+
+	test('Favorite does NOT triggers node removal if favorite view but NOT root dir', async () => {
+		jest.spyOn(axios, 'post')
+		jest.spyOn(eventBus, 'emit')
+
+		const file = new File({
+			id: 1,
+			source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar/foobar.txt',
+			root: '/files/admin',
+			owner: 'admin',
+			mime: 'text/plain',
+			attributes: {
+				favorite: 1,
+			},
+		})
+
+		const exec = await action.exec(file, favoriteView, '/')
+
+		expect(exec).toBe(true)
+
+		// Check POST request
+		expect(axios.post).toBeCalledTimes(1)
+		expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/Foo/Bar/foobar.txt', { tags: [] })
+
+		// Check node change propagation
+		expect(file.attributes.favorite).toBe(0)
+		expect(eventBus.emit).toBeCalledTimes(1)
+		expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file)
+	})
+
+	test('Favorite fails and show error', async () => {
+		const error = new Error('Mock error')
+		jest.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') })
+		jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
+
+		const file = new File({
+			id: 1,
+			source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			attributes: {
+				favorite: 0,
+			},
+		})
+
+		const exec = await action.exec(file, view, '/')
+
+		expect(exec).toBe(false)
+
+		// Check POST request
+		expect(axios.post).toBeCalledTimes(1)
+		expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: ['_$!<Favorite>!$_'] })
+
+		// Check node change propagation
+		expect(logger.error).toBeCalledTimes(1)
+		expect(logger.error).toBeCalledWith('Error while adding a file to favourites', { error, source: file.source, node: file })
+		expect(file.attributes.favorite).toBe(0)
+		expect(eventBus.emit).toBeCalledTimes(0)
+	})
+
+	test('Removing from favorites fails and show error', async () => {
+		const error = new Error('Mock error')
+		jest.spyOn(axios, 'post').mockImplementation(() => { throw error })
+		jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
+
+		const file = new File({
+			id: 1,
+			source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			attributes: {
+				favorite: 1,
+			},
+		})
+
+		const exec = await action.exec(file, view, '/')
+
+		expect(exec).toBe(false)
+
+		// Check POST request
+		expect(axios.post).toBeCalledTimes(1)
+		expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: [] })
+
+		// Check node change propagation
+		expect(logger.error).toBeCalledTimes(1)
+		expect(logger.error).toBeCalledWith('Error while removing a file from favourites', { error, source: file.source, node: file })
+		expect(file.attributes.favorite).toBe(1)
+		expect(eventBus.emit).toBeCalledTimes(0)
+	})
+})
+
+describe('Favorite action batch execute tests', () => {
+	test('Favorite action batch execute with mixed files', async () => {
+		jest.spyOn(favoriteAction, 'favoriteNode')
+		jest.spyOn(axios, 'post')
+
+		const file1 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+			attributes: {
+				favorite: 1,
+			},
+		})
+		const file2 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+			attributes: {
+				favorite: 0,
+			},
+		})
+
+		// Mixed states triggers favorite action
+		const exec = await action.execBatch!([file1, file2], view, '/')
+		expect(exec).toStrictEqual([true, true])
+		expect([file1, file2].every(file => file.attributes.favorite === 1)).toBe(true)
+
+		expect(favoriteAction.favoriteNode).toBeCalledTimes(2)
+		expect(axios.post).toBeCalledTimes(2)
+		expect(axios.post).toHaveBeenNthCalledWith(1, '/index.php/apps/files/api/v1/files/foo.txt', { tags: ['_$!<Favorite>!$_'] })
+		expect(axios.post).toHaveBeenNthCalledWith(2, '/index.php/apps/files/api/v1/files/bar.txt', { tags: ['_$!<Favorite>!$_'] })
+	})
+
+	test('Remove from favorite action batch execute with favorites only files', async () => {
+		jest.spyOn(favoriteAction, 'favoriteNode')
+		jest.spyOn(axios, 'post')
+
+		const file1 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+			attributes: {
+				favorite: 1,
+			},
+		})
+		const file2 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.ALL,
+			attributes: {
+				favorite: 1,
+			},
+		})
+
+		// Mixed states triggers favorite action
+		const exec = await action.execBatch!([file1, file2], view, '/')
+		expect(exec).toStrictEqual([true, true])
+		expect([file1, file2].every(file => file.attributes.favorite === 0)).toBe(true)
+
+		expect(favoriteAction.favoriteNode).toBeCalledTimes(2)
+		expect(axios.post).toBeCalledTimes(2)
+		expect(axios.post).toHaveBeenNthCalledWith(1, '/index.php/apps/files/api/v1/files/foo.txt', { tags: [] })
+		expect(axios.post).toHaveBeenNthCalledWith(2, '/index.php/apps/files/api/v1/files/bar.txt', { tags: [] })
+	})
+})

+ 99 - 0
apps/files/src/actions/favoriteAction.ts

@@ -0,0 +1,99 @@
+/**
+ * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { emit } from '@nextcloud/event-bus'
+import { translate as t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import Star from '@mdi/svg/svg/star.svg?raw'
+import type { Node } from '@nextcloud/files'
+
+import { generateUrl } from '@nextcloud/router'
+import { registerFileAction, FileAction } from '../services/FileAction'
+import logger from '../logger.js'
+import type { Navigation } from '../services/Navigation'
+
+// If any of the nodes is not favorited, we display the favorite action.
+const shouldFavorite = (nodes: Node[]): boolean => {
+	return nodes.some(node => node.attributes.favorite !== 1)
+}
+
+export const favoriteNode = async (node: Node, view: Navigation, willFavorite: boolean): Promise<boolean> => {
+	try {
+		// TODO: migrate to webdav tags plugin
+		const url = generateUrl('/apps/files/api/v1/files') + node.path
+		await axios.post(url, {
+			tags: willFavorite
+				? [window.OC.TAG_FAVORITE]
+				: [],
+		})
+
+		// Let's delete if we are in the favourites view
+		// AND if it is removed from the user favorites
+		// AND it's in the root of the favorites view
+		if (view.id === 'favorites' && !willFavorite && node.dirname === '/') {
+			emit('files:node:deleted', node)
+		}
+
+		// Update the node webdav attribute
+		node.attributes.favorite = willFavorite ? 1 : 0
+
+		// Dispatch event to whoever is interested
+		if (willFavorite) {
+			emit('files:favorites:added', node)
+		} else {
+			emit('files:favorites:removed', node)
+		}
+
+		return true
+	} catch (error) {
+		const action = willFavorite ? 'adding a file to favourites' : 'removing a file from favourites'
+		logger.error('Error while ' + action, { error, source: node.source, node })
+		return false
+	}
+}
+
+export const action = new FileAction({
+	id: 'favorite',
+	displayName(nodes: Node[]) {
+		return shouldFavorite(nodes)
+			? t('files', 'Add to favorites')
+			: t('files', 'Remove from favorites')
+	},
+	iconSvgInline: () => Star,
+
+	enabled(nodes: Node[]) {
+		// We can only favorite nodes within files
+		return !nodes.some(node => !node.root?.startsWith?.('/files'))
+	},
+
+	async exec(node: Node, view: Navigation) {
+		const willFavorite = shouldFavorite([node])
+		return await favoriteNode(node, view, willFavorite)
+	},
+	async execBatch(nodes: Node[], view: Navigation) {
+		const willFavorite = shouldFavorite(nodes)
+		return Promise.all(nodes.map(async node => await favoriteNode(node, view, willFavorite)))
+	},
+
+	order: -50,
+})
+
+registerFileAction(action)

+ 111 - 0
apps/files/src/actions/renameAction.spec.ts

@@ -0,0 +1,111 @@
+/**
+ * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { action } from './renameAction'
+import { expect } from '@jest/globals'
+import { File, Permission } from '@nextcloud/files'
+import { FileAction } from '../services/FileAction'
+import * as eventBus from '@nextcloud/event-bus'
+import type { Navigation } from '../services/Navigation'
+
+const view = {
+	id: 'files',
+	name: 'Files',
+} as Navigation
+
+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)).toBe('SvgMock')
+		expect(action.default).toBe(false)
+		expect(action.order).toBe(10)
+	})
+})
+
+describe('Rename action enabled tests', () => {
+	test('Enabled for node with UPDATE permission', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.UPDATE,
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file], view)).toBe(true)
+	})
+
+	test('Disabled for node without UPDATE permission', () => {
+		const file = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+			permissions: Permission.READ,
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file], view)).toBe(false)
+	})
+
+	test('Disabled if more than one node', () => {
+		window.OCA = { Files: { Sidebar: {} } }
+
+		const file1 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+		})
+		const file2 = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+		})
+
+		expect(action.enabled).toBeDefined()
+		expect(action.enabled!([file1, file2], view)).toBe(false)
+	})
+})
+
+describe('Rename action exec tests', () => {
+	test('Rename', async () => {
+		jest.spyOn(eventBus, 'emit')
+
+		const file = new File({
+			id: 1,
+			source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+			owner: 'admin',
+			mime: 'text/plain',
+		})
+
+		const exec = await action.exec(file, view, '/')
+
+		// Silent action
+		expect(exec).toBe(null)
+		expect(eventBus.emit).toBeCalledTimes(1)
+		expect(eventBus.emit).toHaveBeenCalledWith('files:node:rename', file)
+	})
+})

+ 51 - 0
apps/files/src/actions/renameAction.ts

@@ -0,0 +1,51 @@
+/**
+ * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { Permission, type Node } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import PencilSvg from '@mdi/svg/svg/pencil.svg?raw'
+
+import { emit } from '@nextcloud/event-bus'
+import { registerFileAction, FileAction } from '../services/FileAction'
+
+export const ACTION_DETAILS = 'details'
+
+export const action = new FileAction({
+	id: 'rename',
+	displayName: () => t('files', 'Rename'),
+	iconSvgInline: () => PencilSvg,
+
+	enabled: (nodes: Node[]) => {
+		return nodes.length > 0 && nodes
+			.map(node => node.permissions)
+			.every(permission => (permission & Permission.UPDATE) !== 0)
+	},
+
+	async exec(node: Node) {
+		// Renaming is a built-in feature of the files app
+		emit('files:node:rename', node)
+		return null
+	},
+
+	order: 10,
+})
+
+registerFileAction(action)

+ 1 - 1
apps/files/src/actions/sidebarAction.spec.ts

@@ -42,7 +42,7 @@ describe('Open sidebar action conditions tests', () => {
 	})
 })
 
-describe('Open folder action enabled tests', () => {
+describe('Open sidebar action enabled tests', () => {
 	test('Enabled for ressources within user root folder', () => {
 		window.OCA = { Files: { Sidebar: {} } }
 

+ 1 - 1
apps/files/src/services/FileAction.ts

@@ -110,7 +110,7 @@ export class FileAction {
 	}
 
 	get default() {
-		return this._action.default
+		return this._action.default === true
 	}
 
 	get inline() {

File diff suppressed because it is too large
+ 0 - 0
dist/files-main.js


File diff suppressed because it is too large
+ 0 - 0
dist/files-main.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/files_trashbin-main.js


File diff suppressed because it is too large
+ 0 - 0
dist/files_trashbin-main.js.map


+ 216 - 2
package-lock.json

@@ -128,6 +128,7 @@
         "jasmine-sinon": "^0.4.0",
         "jest": "^29.0.3",
         "jest-environment-jsdom": "^29.5.0",
+        "jest-location-mock": "^1.0.9",
         "jsdoc": "^4.0.2",
         "karma": "^6.4.2",
         "karma-chrome-launcher": "^3.1.1",
@@ -158,8 +159,8 @@
         "workbox-webpack-plugin": "^6.5.4"
       },
       "engines": {
-        "node": "^16.0.0",
-        "npm": "^7.0.0 || ^8.0.0"
+        "node": "^20.0.0",
+        "npm": "^9.0.0"
       }
     },
     "node_modules/@adobe/css-tools": {
@@ -2330,6 +2331,12 @@
         "node": ">=8"
       }
     },
+    "node_modules/@jedmao/location": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@jedmao/location/-/location-3.0.0.tgz",
+      "integrity": "sha512-p7mzNlgJbCioUYLUEKds3cQG4CHONVFJNYqMe6ocEtENCL/jYmMo1Q3ApwsMmU+L0ZkaDJEyv4HokaByLoPwlQ==",
+      "dev": true
+    },
     "node_modules/@jest/console": {
       "version": "29.5.0",
       "dev": true,
@@ -14039,6 +14046,122 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/jest-location-mock": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/jest-location-mock/-/jest-location-mock-1.0.9.tgz",
+      "integrity": "sha512-DN/v7Zsa3N4uGgWTCrMrPPxhZORr/4N5gi+u7Tk6sLdORYplrC0//wfFN5FOtx4ZdQzDVfY6rLa4d+wfTKzQHw==",
+      "dev": true,
+      "dependencies": {
+        "@jedmao/location": "^3.0.0",
+        "jest-diff": "^27.0.1"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/jest-location-mock/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/jest-location-mock/node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/jest-location-mock/node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/jest-location-mock/node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/jest-location-mock/node_modules/diff-sequences": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
+      "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==",
+      "dev": true,
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-location-mock/node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/jest-location-mock/node_modules/jest-diff": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
+      "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^4.0.0",
+        "diff-sequences": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "pretty-format": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-location-mock/node_modules/jest-get-type": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
+      "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==",
+      "dev": true,
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-location-mock/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/jest-matcher-utils": {
       "version": "29.5.0",
       "dev": true,
@@ -25839,6 +25962,12 @@
       "version": "0.1.3",
       "dev": true
     },
+    "@jedmao/location": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@jedmao/location/-/location-3.0.0.tgz",
+      "integrity": "sha512-p7mzNlgJbCioUYLUEKds3cQG4CHONVFJNYqMe6ocEtENCL/jYmMo1Q3ApwsMmU+L0ZkaDJEyv4HokaByLoPwlQ==",
+      "dev": true
+    },
     "@jest/console": {
       "version": "29.5.0",
       "dev": true,
@@ -33493,6 +33622,91 @@
         }
       }
     },
+    "jest-location-mock": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/jest-location-mock/-/jest-location-mock-1.0.9.tgz",
+      "integrity": "sha512-DN/v7Zsa3N4uGgWTCrMrPPxhZORr/4N5gi+u7Tk6sLdORYplrC0//wfFN5FOtx4ZdQzDVfY6rLa4d+wfTKzQHw==",
+      "dev": true,
+      "requires": {
+        "@jedmao/location": "^3.0.0",
+        "jest-diff": "^27.0.1"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "chalk": {
+          "version": "4.1.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+          "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.1.0",
+            "supports-color": "^7.1.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "diff-sequences": {
+          "version": "27.5.1",
+          "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
+          "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==",
+          "dev": true
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "jest-diff": {
+          "version": "27.5.1",
+          "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
+          "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==",
+          "dev": true,
+          "requires": {
+            "chalk": "^4.0.0",
+            "diff-sequences": "^27.5.1",
+            "jest-get-type": "^27.5.1",
+            "pretty-format": "^27.5.1"
+          }
+        },
+        "jest-get-type": {
+          "version": "27.5.1",
+          "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
+          "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
     "jest-matcher-utils": {
       "version": "29.5.0",
       "dev": true,

+ 1 - 0
package.json

@@ -154,6 +154,7 @@
     "jasmine-sinon": "^0.4.0",
     "jest": "^29.0.3",
     "jest-environment-jsdom": "^29.5.0",
+    "jest-location-mock": "^1.0.9",
     "jsdoc": "^4.0.2",
     "karma": "^6.4.2",
     "karma-chrome-launcher": "^3.1.1",

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