From 111296338544865df18aa550ecd8de9879134334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= Date: Wed, 21 Jun 2023 17:10:06 +0200 Subject: [PATCH] feat: add edit locally action with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John Molakvoæ --- __mocks__/@nextcloud/axios.ts | 3 +- __tests__/jest-setup.ts | 3 + .../src/actions/editLocallyAction.spec.ts | 163 +++++++++++++ apps/files/src/actions/editLocallyAction.ts | 74 ++++++ package-lock.json | 218 +++++++++++++++++- package.json | 1 + 6 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 apps/files/src/actions/editLocallyAction.spec.ts create mode 100644 apps/files/src/actions/editLocallyAction.ts diff --git a/__mocks__/@nextcloud/axios.ts b/__mocks__/@nextcloud/axios.ts index c78420c98b0..02564ab6c6d 100644 --- a/__mocks__/@nextcloud/axios.ts +++ b/__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: {} }), } diff --git a/__tests__/jest-setup.ts b/__tests__/jest-setup.ts index c0813ff003f..1bcd6bf767d 100644 --- a/__tests__/jest-setup.ts +++ b/__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' diff --git a/apps/files/src/actions/editLocallyAction.spec.ts b/apps/files/src/actions/editLocallyAction.spec.ts new file mode 100644 index 00000000000..93315c5a016 --- /dev/null +++ b/apps/files/src/actions/editLocallyAction.spec.ts @@ -0,0 +1,163 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +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/') + }) +}) diff --git a/apps/files/src/actions/editLocallyAction.ts b/apps/files/src/actions/editLocallyAction.ts new file mode 100644 index 00000000000..ad7e805ec2e --- /dev/null +++ b/apps/files/src/actions/editLocallyAction.ts @@ -0,0 +1,74 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +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) +} diff --git a/package-lock.json b/package-lock.json index 4631d185b96..785fd9951ef 100644 --- a/package-lock.json +++ b/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, diff --git a/package.json b/package.json index 632ee14b39e..13538c5a2ac 100644 --- a/package.json +++ b/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",