diff --git a/changelog/unreleased/enhancement-add-spaces-actions b/changelog/unreleased/enhancement-add-spaces-actions new file mode 100644 index 00000000000..a33b7acf808 --- /dev/null +++ b/changelog/unreleased/enhancement-add-spaces-actions @@ -0,0 +1,10 @@ +Enhancement: Add spaces actions + +We added the following actions to the spaces overview: + +* Create a new space +* Rename a space +* Delete a space + +https://github.com/owncloud/web/pull/6254 +https://github.com/owncloud/web/issues/6255 diff --git a/packages/web-app-files/src/mixins/spaces/actions/delete.js b/packages/web-app-files/src/mixins/spaces/actions/delete.js new file mode 100644 index 00000000000..b419b2ea58f --- /dev/null +++ b/packages/web-app-files/src/mixins/spaces/actions/delete.js @@ -0,0 +1,62 @@ +import { mapActions } from 'vuex' + +export default { + computed: { + $_delete_items() { + return [ + { + name: 'delete', + icon: 'delete-bin-5', + label: () => { + return this.$gettext('Delete') + }, + handler: this.$_delete_showModal, + isEnabled: () => true, + componentType: 'oc-button', + class: 'oc-files-actions-delete-trigger' + } + ] + } + }, + methods: { + ...mapActions([ + 'createModal', + 'hideModal', + 'setModalInputErrorMessage', + 'showMessage', + 'toggleModalConfirmButton' + ]), + + $_delete_showModal(space) { + const modal = { + variation: 'danger', + title: this.$gettext('Delete space') + ' ' + space.name, + cancelText: this.$gettext('Cancel'), + confirmText: this.$gettext('Delete'), + icon: 'alarm-warning', + message: this.$gettext('Are you sure you want to delete this space?'), + hasInput: false, + onCancel: this.hideModal, + onConfirm: () => this.$_delete_deleteSpace(space.id) + } + + this.createModal(modal) + }, + + $_delete_deleteSpace(id) { + return this.graph.drives + .deleteDrive(id) + .then(() => { + this.hideModal() + this.loadSpacesTask.perform(this) + }) + .catch((error) => { + this.showMessage({ + title: this.$gettext('Deleting space failed…'), + desc: error, + status: 'danger' + }) + }) + } + } +} diff --git a/packages/web-app-files/src/mixins/spaces/actions/rename.js b/packages/web-app-files/src/mixins/spaces/actions/rename.js new file mode 100644 index 00000000000..80b877d2b23 --- /dev/null +++ b/packages/web-app-files/src/mixins/spaces/actions/rename.js @@ -0,0 +1,69 @@ +import { mapActions } from 'vuex' + +export default { + computed: { + $_rename_items() { + return [ + { + name: 'rename', + icon: 'edit', + label: () => { + return this.$gettext('Rename') + }, + handler: this.$_rename_showModal, + isEnabled: () => true, + componentType: 'oc-button', + class: 'oc-files-actions-rename-trigger' + } + ] + } + }, + methods: { + ...mapActions([ + 'createModal', + 'hideModal', + 'setModalInputErrorMessage', + 'showMessage', + 'toggleModalConfirmButton' + ]), + + $_rename_showModal(space) { + const modal = { + variation: 'passive', + title: this.$gettext('Rename space') + ' ' + space.name, + cancelText: this.$gettext('Cancel'), + confirmText: this.$gettext('Rename'), + hasInput: true, + inputLabel: this.$gettext('Space name'), + inputValue: space.name, + onCancel: this.hideModal, + onConfirm: (name) => this.$_rename_renameSpace(space.id, name), + onInput: this.$_rename_checkName + } + + this.createModal(modal) + }, + + $_rename_checkName(name) { + if (name.trim() === '') { + this.setModalInputErrorMessage(this.$gettext('Space name cannot be empty')) + } + }, + + $_rename_renameSpace(id, name) { + return this.graph.drives + .updateDrive(id, { name }, {}) + .then(() => { + this.hideModal() + this.loadSpacesTask.perform(this) + }) + .catch((error) => { + this.showMessage({ + title: this.$gettext('Renaming space failed…'), + desc: error, + status: 'danger' + }) + }) + } + } +} diff --git a/packages/web-app-files/src/views/spaces/Projects.vue b/packages/web-app-files/src/views/spaces/Projects.vue index 05ac8c73a1e..eab68043c06 100644 --- a/packages/web-app-files/src/views/spaces/Projects.vue +++ b/packages/web-app-files/src/views/spaces/Projects.vue @@ -1,5 +1,20 @@
-
- - - +
  • + +
  • + +
    @@ -52,12 +101,16 @@ import { client } from 'web-client' import { ref } from '@vue/composition-api' import { useStore } from 'web-pkg/src/composables' import { useTask } from 'vue-concurrency' +import Rename from '../../mixins/spaces/actions/rename' +import { mapActions } from 'vuex' +import Delete from '../../mixins/spaces/actions/delete' export default { components: { NoContentMessage, ListLoader }, + mixins: [Rename, Delete], setup() { const store = useStore() const spaces = ref([]) @@ -74,8 +127,66 @@ export default { return { spaces, + graph, loadSpacesTask } + }, + computed: { + hasCreatePermission() { + // @TODO + return true + }, + contextMenuActions() { + return [...this.$_rename_items, ...this.$_delete_items].filter((item) => item.isEnabled()) + } + }, + methods: { + ...mapActions(['createModal', 'hideModal', 'setModalInputErrorMessage']), + + showCreateSpaceModal() { + const modal = { + variation: 'passive', + title: this.$gettext('Create a new space'), + cancelText: this.$gettext('Cancel'), + confirmText: this.$gettext('Create'), + hasInput: true, + inputLabel: this.$gettext('Space name'), + inputValue: this.$gettext('New space'), + onCancel: this.hideModal, + onConfirm: this.addNewSpace, + onInput: this.checkSpaceName + } + + this.createModal(modal) + }, + + checkSpaceName(name) { + if (name.trim() === '') { + this.setModalInputErrorMessage(this.$gettext('Space name cannot be empty')) + } + }, + + addNewSpace(name) { + this.$refs.createNewSpaceButton.$el.blur() + + return this.graph.drives + .createDrive({ name }, {}) + .then(() => { + this.hideModal() + this.loadSpacesTask.perform(this) + }) + .catch((error) => { + this.showMessage({ + title: this.$gettext('Creating space failed…'), + desc: error, + status: 'danger' + }) + }) + }, + + sanitizeSpaceId(id) { + return id.replace('!', '\\!') + } } } @@ -88,6 +199,11 @@ export default { .spaces-list { &-card { box-shadow: none !important; + + .oc-card-media-top button { + top: 0; + right: 0; + } } .oc-card-media-top { diff --git a/packages/web-app-files/tests/unit/mixins/spaces/delete.spec.js b/packages/web-app-files/tests/unit/mixins/spaces/delete.spec.js new file mode 100644 index 00000000000..d989ee6b343 --- /dev/null +++ b/packages/web-app-files/tests/unit/mixins/spaces/delete.spec.js @@ -0,0 +1,83 @@ +import Vuex from 'vuex' +import { createStore } from 'vuex-extensions' +import { mount, createLocalVue } from '@vue/test-utils' +import Delete from '@files/src/mixins/spaces/actions/delete.js' +import { createLocationSpaces } from '../../../../src/router' + +const localVue = createLocalVue() +localVue.use(Vuex) + +describe('delete', () => { + const Component = { + render() {}, + mixins: [Delete] + } + + function getWrapper(deleteSpacePromise) { + return mount(Component, { + localVue, + mocks: { + $router: { + currentRoute: createLocationSpaces('files-spaces-projects'), + resolve: (r) => { + return { href: r.name } + } + }, + graph: { + drives: { + deleteDrive: jest.fn(() => { + return deleteSpacePromise + }) + } + }, + $gettext: jest.fn() + }, + store: createStore(Vuex.Store, { + actions: { + createModal: jest.fn(), + hideModal: jest.fn(), + showMessage: jest.fn() + } + }) + }) + } + + describe('method "$_delete_showModal"', () => { + it('should trigger the delete modal window', async () => { + const deletePromise = new Promise((resolve) => { + return resolve() + }) + const wrapper = getWrapper(deletePromise) + const spyCreateModalStub = jest.spyOn(wrapper.vm, 'createModal') + await wrapper.vm.$_delete_showModal({ id: 1 }) + + expect(spyCreateModalStub).toHaveBeenCalledTimes(1) + }) + }) + + describe('method "$_delete_deleteSpace"', () => { + it('should hide the modal on success', async () => { + const deletePromise = new Promise((resolve) => { + return resolve() + }) + + const wrapper = getWrapper(deletePromise) + const hideModalStub = jest.spyOn(wrapper.vm, 'hideModal') + await wrapper.vm.$_delete_deleteSpace(1) + + expect(hideModalStub).toHaveBeenCalledTimes(1) + }) + + it('should show message on error', async () => { + const deletePromise = new Promise((resolve, reject) => { + return reject(new Error()) + }) + + const wrapper = getWrapper(deletePromise) + const showMessageStub = jest.spyOn(wrapper.vm, 'showMessage') + await wrapper.vm.$_delete_deleteSpace(1) + + expect(showMessageStub).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/web-app-files/tests/unit/mixins/spaces/rename.spec.js b/packages/web-app-files/tests/unit/mixins/spaces/rename.spec.js new file mode 100644 index 00000000000..800583e04fa --- /dev/null +++ b/packages/web-app-files/tests/unit/mixins/spaces/rename.spec.js @@ -0,0 +1,97 @@ +import Vuex from 'vuex' +import { createStore } from 'vuex-extensions' +import { mount, createLocalVue } from '@vue/test-utils' +import rename from '@files/src/mixins/spaces/actions/rename.js' +import { createLocationSpaces } from '../../../../src/router' + +const localVue = createLocalVue() +localVue.use(Vuex) + +describe('rename', () => { + const Component = { + render() {}, + mixins: [rename] + } + + function getWrapper(renameSpacePromise) { + return mount(Component, { + localVue, + mocks: { + $router: { + currentRoute: createLocationSpaces('files-spaces-projects'), + resolve: (r) => { + return { href: r.name } + } + }, + graph: { + drives: { + updateDrive: jest.fn(() => { + return renameSpacePromise + }) + } + }, + $gettext: jest.fn() + }, + store: createStore(Vuex.Store, { + actions: { + createModal: jest.fn(), + hideModal: jest.fn(), + showMessage: jest.fn(), + setModalInputErrorMessage: jest.fn() + } + }) + }) + } + + describe('method "$_rename_showModal"', () => { + it('should trigger the rename modal window', async () => { + const renamePromise = new Promise((resolve) => { + return resolve() + }) + const wrapper = getWrapper(renamePromise) + const spyCreateModalStub = jest.spyOn(wrapper.vm, 'createModal') + await wrapper.vm.$_rename_showModal({ id: 1, name: 'renamed space' }) + + expect(spyCreateModalStub).toHaveBeenCalledTimes(1) + }) + }) + + describe('method "$_rename_checkName"', () => { + it('should throw an error with an empty space name', async () => { + const renamePromise = new Promise((resolve) => { + return resolve() + }) + const wrapper = getWrapper(renamePromise) + const spyInputErrorMessageStub = jest.spyOn(wrapper.vm, 'setModalInputErrorMessage') + await wrapper.vm.$_rename_checkName('') + + expect(spyInputErrorMessageStub).toHaveBeenCalledTimes(1) + }) + }) + + describe('method "$_rename_renameSpace"', () => { + it('should hide the modal on success', async () => { + const renamePromise = new Promise((resolve) => { + return resolve() + }) + + const wrapper = getWrapper(renamePromise) + const hideModalStub = jest.spyOn(wrapper.vm, 'hideModal') + await wrapper.vm.$_rename_renameSpace(1, 'renamed space') + + expect(hideModalStub).toHaveBeenCalledTimes(1) + }) + + it('should show message on error', async () => { + const renamePromise = new Promise((resolve, reject) => { + return reject(new Error()) + }) + + const wrapper = getWrapper(renamePromise) + const showMessageStub = jest.spyOn(wrapper.vm, 'showMessage') + await wrapper.vm.$_rename_renameSpace(1, 'renamed space') + + expect(showMessageStub).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/web-app-files/tests/unit/views/spaces/Projects.spec.js b/packages/web-app-files/tests/unit/views/spaces/Projects.spec.js index 9baf15c67d3..fdfbf6f5e98 100644 --- a/packages/web-app-files/tests/unit/views/spaces/Projects.spec.js +++ b/packages/web-app-files/tests/unit/views/spaces/Projects.spec.js @@ -35,7 +35,10 @@ describe('Spaces component', () => { mockAxios.request.mockImplementationOnce(() => { return Promise.resolve({ data: { - value: [{ driveType: 'project' }, { driveType: 'personal' }] + value: [ + { driveType: 'project', id: '1' }, + { driveType: 'personal', id: '2' } + ] } }) }) @@ -46,6 +49,42 @@ describe('Spaces component', () => { expect(wrapper.vm.spaces.length).toEqual(1) expect(wrapper).toMatchSnapshot() }) + + it('should show the "create new space" modal with sufficient permissions', async () => { + mockAxios.request.mockImplementationOnce(() => { + return Promise.resolve({ + data: { + value: [] + } + }) + }) + const wrapper = getMountedWrapper() + await wrapper.vm.loadSpacesTask.last + + const createModalStub = jest.spyOn(wrapper.vm, 'createModal') + const button = wrapper.find('[data-testid="spaces-list-create-space-btn"]') + expect(button.exists()).toBeTruthy() + await button.trigger('click') + + expect(createModalStub).toHaveBeenCalledTimes(1) + }) + + it('should show an error message when trying to create a space with an empty name', async () => { + mockAxios.request.mockImplementationOnce(() => { + return Promise.resolve({ + data: { + value: [] + } + }) + }) + const wrapper = getMountedWrapper() + await wrapper.vm.loadSpacesTask.last + + const spyInputErrorMessageStub = jest.spyOn(wrapper.vm, 'setModalInputErrorMessage') + wrapper.vm.checkSpaceName('') + + expect(spyInputErrorMessageStub).toHaveBeenCalledTimes(1) + }) }) function getMountedWrapper() { @@ -57,6 +96,9 @@ function getMountedWrapper() { configuration: () => ({ server: 'https://example.com/' }) + }, + actions: { + createModal: jest.fn() } }) }) diff --git a/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Projects.spec.js.snap b/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Projects.spec.js.snap index 0b27fbb22b1..4d0bef78c25 100644 --- a/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Projects.spec.js.snap +++ b/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Projects.spec.js.snap @@ -1,21 +1,38 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Spaces component should only list drives of type "project" 1`] = ` -
    +

    Spaces

    Access all project related files in one place. Learn more about spaces.

    Your spaces


    -
    - -
    + "> +
  • +
    +
    +
    +
    +
      +
    • +
    • +
    +
    +
    +
    +
    +
  • +
    `; diff --git a/packages/web-client/src/index.ts b/packages/web-client/src/index.ts index 3e3881f2810..926482c3f72 100644 --- a/packages/web-client/src/index.ts +++ b/packages/web-client/src/index.ts @@ -1,8 +1,19 @@ -import axios, { AxiosInstance } from 'axios' -import { Configuration, MeDrivesApi } from './generated' +import axios, { AxiosInstance, AxiosPromise, AxiosResponse } from 'axios' +import { + Configuration, + MeDrivesApi, + Drive, + DrivesApiFactory, + CollectionOfDrives +} from './generated' interface Graph { - drives: Pick + drives: { + listMyDrives: () => Promise> + createDrive: (drive: Drive, options: any) => AxiosPromise + updateDrive: (id: string, drive: Drive, options: any) => AxiosPromise + deleteDrive: (id: string, ifMatch: string, options: any) => AxiosPromise + } } const graph = (baseURI: string, axiosClient: AxiosInstance): Graph => { @@ -12,10 +23,17 @@ const graph = (baseURI: string, axiosClient: AxiosInstance): Graph => { }) const meDrivesApi = new MeDrivesApi(config, config.basePath, axiosClient) + const drivesApiFactory = DrivesApiFactory(config, config.basePath, axiosClient) return { drives: { - listMyDrives: () => meDrivesApi.listMyDrives() + listMyDrives: () => meDrivesApi.listMyDrives(), + createDrive: (drive: Drive, options: any): AxiosPromise => + drivesApiFactory.createDrive(drive, options), + updateDrive: (id: string, drive: Drive, options: any): AxiosPromise => + drivesApiFactory.updateDrive(id, drive, options), + deleteDrive: (id: string, ifMatch: string, options: any): AxiosPromise => + drivesApiFactory.deleteDrive(id, ifMatch, options) } } }