refactor(federatedfilesharing): Replace deprecated functions

* Replace deprecated OC dialogs methods
* Replace deprecated global jQuery with axios
* Replace deprecated jQuery event with event bus
* Add component + unit tests for new dialog

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/48725/head
Ferdinand Thiessen 1 month ago committed by skjnldsv
parent 9aec7b9b85
commit 55595f61df
  1. 1
      __tests__/setup-testing-library.js
  2. 123
      apps/federatedfilesharing/src/components/RemoteShareDialog.cy.ts
  3. 67
      apps/federatedfilesharing/src/components/RemoteShareDialog.vue
  4. 182
      apps/federatedfilesharing/src/external.js
  5. 65
      apps/federatedfilesharing/src/services/dialogService.spec.ts
  6. 36
      apps/federatedfilesharing/src/services/dialogService.ts
  7. 241
      apps/federatedfilesharing/tests/js/externalSpec.js

@ -3,3 +3,4 @@
* SPDX-License-Identifier: CC0-1.0 * SPDX-License-Identifier: CC0-1.0
*/ */
import '@testing-library/jest-dom/vitest' import '@testing-library/jest-dom/vitest'
import 'core-js/stable/index.js'

@ -0,0 +1,123 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import RemoteShareDialog from './RemoteShareDialog.vue'
describe('RemoteShareDialog', () => {
it('can be mounted', () => {
cy.mount(RemoteShareDialog, {
propsData: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
})
cy.findByRole('dialog')
.should('be.visible')
.and('contain.text', 'user123@nextcloud.local')
.and('contain.text', 'my-photos')
cy.findByRole('button', { name: 'Cancel' })
.should('be.visible')
cy.findByRole('button', { name: /add remote share/i })
.should('be.visible')
})
it('does not show password input if not enabled', () => {
cy.mount(RemoteShareDialog, {
propsData: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
})
cy.findByRole('dialog')
.should('be.visible')
.find('input[type="password"]')
.should('not.exist')
})
it('emits true when accepted', () => {
const onClose = cy.spy().as('onClose')
cy.mount(RemoteShareDialog, {
listeners: {
close: onClose,
},
propsData: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
})
cy.findByRole('button', { name: 'Cancel' }).click()
cy.get('@onClose')
.should('have.been.calledWith', false)
})
it('show password input if needed', () => {
cy.mount(RemoteShareDialog, {
propsData: {
owner: 'admin',
name: 'secret-data',
remote: 'nextcloud.local',
passwordRequired: true,
},
})
cy.findByRole('dialog')
.should('be.visible')
.find('input[type="password"]')
.should('be.visible')
})
it('emits the submitted password', () => {
const onClose = cy.spy().as('onClose')
cy.mount(RemoteShareDialog, {
listeners: {
close: onClose,
},
propsData: {
owner: 'admin',
name: 'secret-data',
remote: 'nextcloud.local',
passwordRequired: true,
},
})
cy.get('input[type="password"]')
.type('my password{enter}')
cy.get('@onClose')
.should('have.been.calledWith', true, 'my password')
})
it('emits no password if cancelled', () => {
const onClose = cy.spy().as('onClose')
cy.mount(RemoteShareDialog, {
listeners: {
close: onClose,
},
propsData: {
owner: 'admin',
name: 'secret-data',
remote: 'nextcloud.local',
passwordRequired: true,
},
})
cy.get('input[type="password"]')
.type('my password')
cy.findByRole('button', { name: 'Cancel' }).click()
cy.get('@onClose')
.should('have.been.calledWith', false)
})
})

@ -0,0 +1,67 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed, ref } from 'vue'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
const props = defineProps<{
/** Name of the share */
name: string
/** Display name of the owner */
owner: string
/** The remote instance name */
remote: string
/** True if the user should enter a password */
passwordRequired: boolean
}>()
const emit = defineEmits<{
(e: 'close', state: boolean, password?: string): void
}>()
const password = ref('')
/**
* The dialog buttons
*/
const buttons = computed(() => [
{
label: t('federatedfilesharing', 'Cancel'),
callback: () => emit('close', false),
},
{
label: t('federatedfilesharing', 'Add remote share'),
nativeType: props.passwordRequired ? 'submit' : undefined,
type: 'primary',
callback: () => emit('close', true, password.value),
},
])
</script>
<template>
<NcDialog :buttons="buttons"
:is-form="passwordRequired"
:name="t('federatedfilesharing', 'Remote share')"
@submit="emit('close', true, password)">
<p>
{{ t('federatedfilesharing', 'Do you want to add the remote share {name} from {owner}@{remote}?', { name, owner, remote }) }}
</p>
<NcPasswordField v-if="passwordRequired"
class="remote-share-dialog__password"
:label="t('federatedfilesharing', 'Remote share password')"
:value.sync="password" />
</NcDialog>
</template>
<style scoped lang="scss">
.remote-share-dialog {
&__password {
margin-block: 1em .5em;
}
}
</style>

@ -3,10 +3,15 @@
* SPDX-FileCopyrightText: 2014-2016 ownCloud, Inc. * SPDX-FileCopyrightText: 2014-2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-License-Identifier: AGPL-3.0-or-later
*/ */
import axios, { isAxiosError } from '@nextcloud/axios'
import { showError, showInfo } from '@nextcloud/dialogs'
import { subscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state' import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showRemoteShareDialog } from './services/dialogService.ts'
window.OCA.Sharing = window.OCA.Sharing || {} window.OCA.Sharing = window.OCA.Sharing ?? {}
/** /**
* Shows "add external share" dialog. * Shows "add external share" dialog.
@ -20,57 +25,40 @@ window.OCA.Sharing = window.OCA.Sharing || {}
* @param {Function} callback the callback * @param {Function} callback the callback
*/ */
window.OCA.Sharing.showAddExternalDialog = function(share, passwordProtected, callback) { window.OCA.Sharing.showAddExternalDialog = function(share, passwordProtected, callback) {
const remote = share.remote
const owner = share.ownerDisplayName || share.owner const owner = share.ownerDisplayName || share.owner
const name = share.name const name = share.name
// Clean up the remote URL for display // Clean up the remote URL for display
const remoteClean = remote const remote = share.remote
.replace(/^https?:\/\//, '') // remove http:// or https:// .replace(/^https?:\/\//, '') // remove http:// or https://
.replace(/\/$/, '') // remove trailing slash .replace(/\/$/, '') // remove trailing slash
if (!passwordProtected) { showRemoteShareDialog(name, owner, remote, passwordProtected)
window.OC.dialogs.confirm( .then((result, password) => callback(result, { ...share, password }))
t( // eslint-disable-next-line n/no-callback-literal
'files_sharing', .catch(() => callback(false, share))
'Do you want to add the remote share {name} from {owner}@{remote}?',
{ name, owner, remote: remoteClean },
),
t('files_sharing', 'Remote share'),
function(result) {
callback(result, share)
},
true,
).then(this._adjustDialog)
} else {
window.OC.dialogs.prompt(
t(
'files_sharing',
'Do you want to add the remote share {name} from {owner}@{remote}?',
{ name, owner, remote: remoteClean },
),
t('files_sharing', 'Remote share'),
function(result, password) {
share.password = password
callback(result, share)
},
true,
t('files_sharing', 'Remote share password'),
true,
).then(this._adjustDialog)
}
} }
window.OCA.Sharing._adjustDialog = function() { window.addEventListener('DOMContentLoaded', () => {
const $dialog = $('.oc-dialog:visible') processIncomingShareFromUrl()
const $buttons = $dialog.find('button')
// hack the buttons if (loadState('federatedfilesharing', 'notificationsEnabled', true) !== true) {
$dialog.find('.ui-icon').remove() // No notification app, display the modal
$buttons.eq(1).text(t('core', 'Cancel')) processSharesToConfirm()
$buttons.eq(2).text(t('files_sharing', 'Add remote share')) }
}
const reloadFilesList = function() { subscribe('notifications:action:executed', ({ action, notification }) => {
if (notification.app === 'files_sharing' && notification.object_type === 'remote_share' && action.type === 'POST') {
// User accepted a remote share reload
reloadFilesList()
}
})
})
/**
* Reload the files list to show accepted shares
*/
function reloadFilesList() {
if (!window?.OCP?.Files?.Router?.goToRoute) { if (!window?.OCP?.Files?.Router?.goToRoute) {
// No router, just reload the page // No router, just reload the page
window.location.reload() window.location.reload()
@ -89,35 +77,42 @@ const reloadFilesList = function() {
* Process incoming remote share that might have been passed * Process incoming remote share that might have been passed
* through the URL * through the URL
*/ */
const processIncomingShareFromUrl = function() { function processIncomingShareFromUrl() {
const params = window.OC.Util.History.parseUrlQuery() const params = window.OC.Util.History.parseUrlQuery()
// manually add server-to-server share // manually add server-to-server share
if (params.remote && params.token && params.name) { if (params.remote && params.token && params.name) {
const callbackAddShare = function(result, share) { const callbackAddShare = (result, share) => {
const password = share.password || '' if (result === false) {
if (result) { return
$.post(
generateUrl('apps/federatedfilesharing/askForFederatedShare'),
{
remote: share.remote,
token: share.token,
owner: share.owner,
ownerDisplayName: share.ownerDisplayName || share.owner,
name: share.name,
password,
},
).done(function(data) {
if (Object.hasOwn(data, 'legacyMount')) {
reloadFilesList()
} else {
window.OC.Notification.showTemporary(data.message)
}
}).fail(function(data) {
window.OC.Notification.showTemporary(JSON.parse(data.responseText).message)
})
} }
axios.post(
generateUrl('apps/federatedfilesharing/askForFederatedShare'),
{
remote: share.remote,
token: share.token,
owner: share.owner,
ownerDisplayName: share.ownerDisplayName || share.owner,
name: share.name,
password: share.password || '',
},
).then(({ data }) => {
if (Object.hasOwn(data, 'legacyMount')) {
reloadFilesList()
} else {
showInfo(data.message)
}
}).catch((error) => {
console.error('Error while processing incoming share', error)
if (isAxiosError(error) && error.response.data.message) {
showError(error.response.data.message)
} else {
showError(t('federatedfilesharing', 'Incoming share could not be processed'))
}
})
} }
// clear hash, it is unlikely that it contain any extra parameters // clear hash, it is unlikely that it contain any extra parameters
@ -134,44 +129,23 @@ const processIncomingShareFromUrl = function() {
/** /**
* Retrieve a list of remote shares that need to be approved * Retrieve a list of remote shares that need to be approved
*/ */
const processSharesToConfirm = function() { async function processSharesToConfirm() {
// check for new server-to-server shares which need to be approved // check for new server-to-server shares which need to be approved
$.get(generateUrl('/apps/files_sharing/api/externalShares'), {}, function(shares) { const { data: shares } = await axios.get(generateUrl('/apps/files_sharing/api/externalShares'))
let index for (let index = 0; index < shares.length; ++index) {
for (index = 0; index < shares.length; ++index) { window.OCA.Sharing.showAddExternalDialog(
window.OCA.Sharing.showAddExternalDialog( shares[index],
shares[index], false,
false, function(result, share) {
function(result, share) { if (result) {
if (result) { // Accept
// Accept axios.post(generateUrl('/apps/files_sharing/api/externalShares'), { id: share.id })
$.post(generateUrl('/apps/files_sharing/api/externalShares'), { id: share.id }) .then(() => reloadFilesList())
.then(function() { } else {
reloadFilesList() // Delete
}) axios.delete(generateUrl('/apps/files_sharing/api/externalShares/' + share.id))
} else { }
// Delete },
$.ajax({ )
url: generateUrl('/apps/files_sharing/api/externalShares/' + share.id),
type: 'DELETE',
})
}
},
)
}
})
}
processIncomingShareFromUrl()
if (loadState('federatedfilesharing', 'notificationsEnabled', true) !== true) {
// No notification app, display the modal
processSharesToConfirm()
}
$('body').on('window.OCA.Notification.Action', function(e) {
if (e.notification.app === 'files_sharing' && e.notification.object_type === 'remote_share' && e.action.type === 'POST') {
// User accepted a remote share reload
reloadFilesList()
} }
}) }

@ -0,0 +1,65 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { describe, expect, it } from 'vitest'
import { showRemoteShareDialog } from './dialogService'
import { nextTick } from 'vue'
describe('federatedfilesharing: dialog service', () => {
it('mounts dialog', async () => {
showRemoteShareDialog('share-name', 'user123', 'example.com')
await nextTick()
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
expect(document.querySelector('[role="dialog"]')!.textContent).to.contain('share-name')
expect(document.querySelector('[role="dialog"]')!.textContent).to.contain('user123@example.com')
expect(document.querySelector('[role="dialog"] input[type="password"]')).toBeNull()
})
it('shows password input', async () => {
showRemoteShareDialog('share-name', 'user123', 'example.com', true)
await nextTick()
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
expect(document.querySelector('[role="dialog"] input[type="password"]')).not.toBeNull()
})
it('resolves if accepted', async () => {
const promise = showRemoteShareDialog('share-name', 'user123', 'example.com')
await nextTick()
for (const button of document.querySelectorAll('button').values()) {
if (button.textContent?.match(/add remote share/i)) {
button.click()
}
}
expect(await promise).toBe(undefined)
})
it('resolves password if accepted', async () => {
const promise = showRemoteShareDialog('share-name', 'user123', 'example.com', true)
await nextTick()
for (const button of document.querySelectorAll('button').values()) {
if (button.textContent?.match(/add remote share/i)) {
button.click()
}
}
expect(await promise).toBe('')
})
it('rejects if cancelled', async () => {
const promise = showRemoteShareDialog('share-name', 'user123', 'example.com')
await nextTick()
for (const button of document.querySelectorAll('button').values()) {
if (button.textContent?.match(/cancel/i)) {
button.click()
}
}
expect(async () => await promise).rejects.toThrow()
})
})

@ -0,0 +1,36 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { spawnDialog } from '@nextcloud/dialogs'
import RemoteShareDialog from '../components/RemoteShareDialog.vue'
/**
* Open a dialog to ask the user whether to add a remote share.
*
* @param name The name of the share
* @param owner The owner of the share
* @param remote The remote address
* @param passwordRequired True if the share is password protected
*/
export function showRemoteShareDialog(
name: string,
owner: string,
remote: string,
passwordRequired = false,
): Promise<string|void> {
const { promise, reject, resolve } = Promise.withResolvers<string|void>()
spawnDialog(RemoteShareDialog, { name, owner, remote, passwordRequired }, (status, password) => {
if (passwordRequired && status) {
resolve(password as string)
} else if (status) {
resolve(undefined)
} else {
reject()
}
})
return promise
}

@ -1,241 +0,0 @@
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2015 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
describe('OCA.Sharing external tests', function() {
var plugin;
var urlQueryStub;
var promptDialogStub;
var confirmDialogStub;
function dummyShowDialog() {
var deferred = $.Deferred();
deferred.resolve();
return deferred.promise();
}
beforeEach(function() {
plugin = OCA.Sharing.ExternalShareDialogPlugin;
urlQueryStub = sinon.stub(OC.Util.History, 'parseUrlQuery');
confirmDialogStub = sinon.stub(OC.dialogs, 'confirm').callsFake(dummyShowDialog);
promptDialogStub = sinon.stub(OC.dialogs, 'prompt').callsFake(dummyShowDialog);
plugin.filesApp = {
fileList: {
reload: sinon.stub()
}
};
});
afterEach(function() {
urlQueryStub.restore();
confirmDialogStub.restore();
promptDialogStub.restore();
plugin = null;
});
describe('confirmation dialog from URL', function() {
var testShare;
/**
* Checks that the server call's query matches what is
* expected.
*
* @param {Object} expectedQuery expected query params
*/
function checkRequest(expectedQuery) {
var request = fakeServer.requests[0];
var query = OC.parseQueryString(request.requestBody);
expect(request.method).toEqual('POST');
expect(query).toEqual(expectedQuery);
request.respond(
200,
{'Content-Type': 'application/json'},
JSON.stringify({status: 'success'})
);
expect(plugin.filesApp.fileList.reload.calledOnce).toEqual(true);
}
beforeEach(function() {
testShare = {
remote: 'http://example.com/owncloud',
token: 'abcdefg',
owner: 'theowner',
ownerDisplayName: 'The Generous Owner',
name: 'the share name'
};
});
it('does nothing when no share was passed in URL', function() {
urlQueryStub.returns({});
plugin.processIncomingShareFromUrl();
expect(promptDialogStub.notCalled).toEqual(true);
expect(confirmDialogStub.notCalled).toEqual(true);
expect(fakeServer.requests.length).toEqual(0);
});
it('sends share info to server on confirm', function() {
urlQueryStub.returns(testShare);
plugin.processIncomingShareFromUrl();
expect(promptDialogStub.notCalled).toEqual(true);
expect(confirmDialogStub.calledOnce).toEqual(true);
confirmDialogStub.getCall(0).args[2](true);
expect(fakeServer.requests.length).toEqual(1);
checkRequest({
remote: 'http://example.com/owncloud',
token: 'abcdefg',
owner: 'theowner',
ownerDisplayName: 'The Generous Owner',
name: 'the share name',
password: ''
});
});
it('sends share info with password to server on confirm', function() {
testShare = _.extend(testShare, {protected: 1});
urlQueryStub.returns(testShare);
plugin.processIncomingShareFromUrl();
expect(promptDialogStub.calledOnce).toEqual(true);
expect(confirmDialogStub.notCalled).toEqual(true);
promptDialogStub.getCall(0).args[2](true, 'thepassword');
expect(fakeServer.requests.length).toEqual(1);
checkRequest({
remote: 'http://example.com/owncloud',
token: 'abcdefg',
owner: 'theowner',
ownerDisplayName: 'The Generous Owner',
name: 'the share name',
password: 'thepassword'
});
});
it('does not send share info on cancel', function() {
urlQueryStub.returns(testShare);
plugin.processIncomingShareFromUrl();
expect(promptDialogStub.notCalled).toEqual(true);
expect(confirmDialogStub.calledOnce).toEqual(true);
confirmDialogStub.getCall(0).args[2](false);
expect(fakeServer.requests.length).toEqual(0);
});
});
describe('show dialog for each share to confirm', function() {
var testShare;
/**
* Call processSharesToConfirm() and make the fake server
* return the passed response.
*
* @param {Array} response list of shares to process
*/
function processShares(response) {
plugin.processSharesToConfirm();
expect(fakeServer.requests.length).toEqual(1);
var req = fakeServer.requests[0];
expect(req.method).toEqual('GET');
expect(req.url).toEqual(OC.getRootPath() + '/index.php/apps/files_sharing/api/externalShares');
req.respond(
200,
{'Content-Type': 'application/json'},
JSON.stringify(response)
);
}
beforeEach(function() {
testShare = {
id: 123,
remote: 'http://example.com/owncloud',
token: 'abcdefg',
owner: 'theowner',
ownerDisplayName: 'The Generous Owner',
name: 'the share name'
};
});
it('does not show any dialog if no shares to confirm', function() {
processShares([]);
expect(confirmDialogStub.notCalled).toEqual(true);
expect(promptDialogStub.notCalled).toEqual(true);
});
it('sends accept info to server on confirm', function() {
processShares([testShare]);
expect(promptDialogStub.notCalled).toEqual(true);
expect(confirmDialogStub.calledOnce).toEqual(true);
confirmDialogStub.getCall(0).args[2](true);
expect(fakeServer.requests.length).toEqual(2);
var request = fakeServer.requests[1];
var query = OC.parseQueryString(request.requestBody);
expect(request.method).toEqual('POST');
expect(query).toEqual({id: '123'});
expect(request.url).toEqual(
OC.getRootPath() + '/index.php/apps/files_sharing/api/externalShares'
);
expect(plugin.filesApp.fileList.reload.notCalled).toEqual(true);
request.respond(
200,
{'Content-Type': 'application/json'},
JSON.stringify({status: 'success'})
);
expect(plugin.filesApp.fileList.reload.calledOnce).toEqual(true);
});
it('sends delete info to server on cancel', function() {
processShares([testShare]);
expect(promptDialogStub.notCalled).toEqual(true);
expect(confirmDialogStub.calledOnce).toEqual(true);
confirmDialogStub.getCall(0).args[2](false);
expect(fakeServer.requests.length).toEqual(2);
var request = fakeServer.requests[1];
expect(request.method).toEqual('DELETE');
expect(request.url).toEqual(
OC.getRootPath() + '/index.php/apps/files_sharing/api/externalShares/123'
);
expect(plugin.filesApp.fileList.reload.notCalled).toEqual(true);
request.respond(
200,
{'Content-Type': 'application/json'},
JSON.stringify({status: 'success'})
);
expect(plugin.filesApp.fileList.reload.notCalled).toEqual(true);
});
xit('shows another dialog when multiple shares need to be accepted', function() {
// TODO: enable this test when fixing multiple dialogs issue / confirm loop
var testShare2 = _.extend({}, testShare);
testShare2.id = 256;
processShares([testShare, testShare2]);
// confirm first one
expect(confirmDialogStub.calledOnce).toEqual(true);
confirmDialogStub.getCall(0).args[2](true);
// next dialog not shown yet
expect(confirmDialogStub.calledOnce);
// respond to the first accept request
fakeServer.requests[1].respond(
200,
{'Content-Type': 'application/json'},
JSON.stringify({status: 'success'})
);
// don't reload yet, there are other shares to confirm
expect(plugin.filesApp.fileList.reload.notCalled).toEqual(true);
// cancel second share
expect(confirmDialogStub.calledTwice).toEqual(true);
confirmDialogStub.getCall(1).args[2](true);
// reload only called at the very end
expect(plugin.filesApp.fileList.reload.calledOnce).toEqual(true);
});
});
});
Loading…
Cancel
Save