diff --git a/changelog/unreleased/enhancement-duplicate-space b/changelog/unreleased/enhancement-duplicate-space new file mode 100644 index 00000000000..d8d1da53e9f --- /dev/null +++ b/changelog/unreleased/enhancement-duplicate-space @@ -0,0 +1,8 @@ +Enhancement: Duplicate space + +We've added a new functionality, where users can simply duplicate spaces, via the context menu or batch actions. +This includes copying the contents, the space name, subtitle, description, and image but not metadata +like tags or members. + +https://github.com/owncloud/web/pull/10024 +https://github.com/owncloud/web/issues/10016 diff --git a/packages/web-app-files/src/components/SideBar/Actions/SpaceActions.vue b/packages/web-app-files/src/components/SideBar/Actions/SpaceActions.vue index e035e1868d1..0f071e2280d 100644 --- a/packages/web-app-files/src/components/SideBar/Actions/SpaceActions.vue +++ b/packages/web-app-files/src/components/SideBar/Actions/SpaceActions.vue @@ -42,6 +42,7 @@ import { useCapabilitySpacesMaxQuota, useStore, usePreviewService } from '@owncl import { useSpaceActionsDelete, useSpaceActionsDisable, + useSpaceActionsDuplicate, useSpaceActionsEditDescription, useSpaceActionsEditQuota, useSpaceActionsEditReadmeContent, @@ -50,6 +51,7 @@ import { } from '@ownclouders/web-pkg' import { useSpaceActionsUploadImage } from 'web-app-files/src/composables' import { useFileActionsDownloadArchive } from '@ownclouders/web-pkg' + export default defineComponent({ name: 'SpaceActions', components: { ActionMenuItem, QuotaModal, ReadmeContentModal }, @@ -68,6 +70,7 @@ export default defineComponent({ const { actions: deleteActions } = useSpaceActionsDelete({ store }) const { actions: disableActions } = useSpaceActionsDisable({ store }) + const { actions: duplicateActions } = useSpaceActionsDuplicate({ store }) const { actions: editDescriptionActions } = useSpaceActionsEditDescription({ store }) const { actions: editQuotaActions, @@ -91,6 +94,7 @@ export default defineComponent({ [ ...unref(downloadArchiveActions), ...unref(renameActions), + ...unref(duplicateActions), ...unref(editDescriptionActions), ...unref(uploadImageActions), ...unref(editReadmeContentActions), diff --git a/packages/web-app-files/src/components/Spaces/SpaceContextActions.vue b/packages/web-app-files/src/components/Spaces/SpaceContextActions.vue index 96f81c6ad7b..83057b9be1d 100644 --- a/packages/web-app-files/src/components/Spaces/SpaceContextActions.vue +++ b/packages/web-app-files/src/components/Spaces/SpaceContextActions.vue @@ -35,6 +35,7 @@ import { useSpaceActionsUploadImage } from 'web-app-files/src/composables' import { useSpaceActionsDelete, useSpaceActionsDisable, + useSpaceActionsDuplicate, useSpaceActionsEditDescription, useSpaceActionsEditQuota, useSpaceActionsEditReadmeContent, @@ -76,6 +77,7 @@ export default defineComponent({ const { actions: deleteActions } = useSpaceActionsDelete({ store }) const { actions: disableActions } = useSpaceActionsDisable({ store }) + const { actions: duplicateActions } = useSpaceActionsDuplicate({ store }) const { actions: editQuotaActions, modalOpen: quotaModalIsOpen, @@ -108,6 +110,7 @@ export default defineComponent({ const menuItemsPrimaryActions = computed(() => { const fileHandlers = [ ...unref(renameActions), + ...unref(duplicateActions), ...unref(editDescriptionActions), ...unref(uploadImageActions) ] diff --git a/packages/web-pkg/src/components/AppBar/AppBar.vue b/packages/web-pkg/src/components/AppBar/AppBar.vue index 4228cbbd43d..aad011ce93d 100644 --- a/packages/web-pkg/src/components/AppBar/AppBar.vue +++ b/packages/web-pkg/src/components/AppBar/AppBar.vue @@ -97,7 +97,8 @@ import { useFileActionsDownloadFile, useFileActionsEmptyTrashBin, useFileActionsMove, - useFileActionsRestore + useFileActionsRestore, + useSpaceActionsDuplicate } from '../../composables/actions' import { useAbility, @@ -171,6 +172,7 @@ export default defineComponent({ const { actions: acceptShareActions } = useFileActionsAcceptShare({ store }) const { actions: hideShareActions } = useFileActionsToggleHideShare({ store }) const { actions: copyActions } = useFileActionsCopy({ store }) + const { actions: duplicateActions } = useSpaceActionsDuplicate({ store }) const { actions: declineShareActions } = useFileActionsDeclineShare({ store }) const { actions: deleteActions } = useFileActionsDelete({ store }) const { actions: downloadArchiveActions } = useFileActionsDownloadArchive({ store }) @@ -215,6 +217,7 @@ export default defineComponent({ if (!isSearchLocation.value) { actions = [ ...actions, + ...unref(duplicateActions), ...unref(editSpaceQuotaActions), ...unref(restoreSpaceActions), ...unref(deleteSpaceActions), diff --git a/packages/web-pkg/src/composables/actions/spaces/index.ts b/packages/web-pkg/src/composables/actions/spaces/index.ts index 19f1f78e6af..854c88bab64 100644 --- a/packages/web-pkg/src/composables/actions/spaces/index.ts +++ b/packages/web-pkg/src/composables/actions/spaces/index.ts @@ -1,5 +1,6 @@ export * from './useSpaceActionsDelete' export * from './useSpaceActionsDisable' +export * from './useSpaceActionsDuplicate' export * from './useSpaceActionsEditDescription' export * from './useSpaceActionsEditQuota' export * from './useSpaceActionsEditReadmeContent' diff --git a/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsDuplicate.ts b/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsDuplicate.ts new file mode 100644 index 00000000000..5a92755c405 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsDuplicate.ts @@ -0,0 +1,152 @@ +import { SpaceResource } from '@ownclouders/web-client' +import { computed } from 'vue' +import { SpaceAction, SpaceActionOptions } from '../types' +import { useGettext } from 'vue3-gettext' +import { useStore } from '../../store' +import { useAbility } from '../../ability' +import { useClientService } from '../../clientService' +import { useLoadingService } from '../../loadingService' +import { Store } from 'vuex' +import { buildSpace, isProjectSpaceResource } from '@ownclouders/web-client/src/helpers' +import { Drive } from '@ownclouders/web-client/src/generated' +import { resolveFileNameDuplicate } from '../../../helpers' +import PQueue from 'p-queue' + +export const useSpaceActionsDuplicate = ({ + store +}: { + store?: Store +} = {}) => { + store = store || useStore() + const { $gettext } = useGettext() + const ability = useAbility() + const clientService = useClientService() + const loadingService = useLoadingService() + + const duplicateSpace = async (existingSpace: SpaceResource) => { + const projectSpaces: SpaceResource[] = store.getters['runtime/spaces/spaces'].filter( + (space: SpaceResource) => isProjectSpaceResource(space) + ) + const duplicatedSpaceName = resolveFileNameDuplicate(existingSpace.name, '', projectSpaces) + + try { + const { data: createdSpace } = await clientService.graphAuthenticated.drives.createDrive( + { + name: duplicatedSpaceName, + description: existingSpace.description + }, + {} + ) + let duplicatedSpace = buildSpace(createdSpace) + + const existingSpaceFiles = await clientService.webdav.listFiles(existingSpace) + + if (existingSpaceFiles.children.length) { + const queue = new PQueue({ concurrency: 4 }) + const copyOps = [] + + for (const file of existingSpaceFiles.children) { + copyOps.push( + queue.add(() => + clientService.webdav.copyFiles(existingSpace, file, duplicatedSpace, { + path: file.name + }) + ) + ) + } + await Promise.all(copyOps) + } + + if (existingSpace.spaceReadmeData || existingSpace.spaceImageData) { + const specialRequestData = { + special: [] + } + + if (existingSpace.spaceReadmeData) { + const newSpaceReadmeFile = await clientService.webdav.getFileInfo(duplicatedSpace, { + path: `.space/${existingSpace.spaceReadmeData.name}` + }) + specialRequestData.special.push({ + specialFolder: { + name: 'readme' + }, + id: newSpaceReadmeFile.id + }) + } + + if (existingSpace.spaceImageData) { + const newSpaceImageFile = await clientService.webdav.getFileInfo(duplicatedSpace, { + path: `.space/${existingSpace.spaceImageData.name}` + }) + specialRequestData.special.push({ + specialFolder: { + name: 'image' + }, + id: newSpaceImageFile.id + }) + } + + const { data: updatedDriveData } = + await clientService.graphAuthenticated.drives.updateDrive( + duplicatedSpace.id.toString(), + specialRequestData as Drive, + {} + ) + duplicatedSpace = buildSpace(updatedDriveData) + } + + store.commit('runtime/spaces/UPSERT_SPACE', duplicatedSpace) + store.dispatch('showMessage', { + title: $gettext('Space "%{space}" was duplicated successfully', { + space: existingSpace.name + }) + }) + } catch (error) { + console.error(error) + store.dispatch('showErrorMessage', { + title: $gettext('Failed to duplicate space "%{space}"', { space: existingSpace.name }), + error + }) + } + } + + const handler = async ({ resources }: SpaceActionOptions) => { + for (const resource of resources) { + if (resource.disabled || !isProjectSpaceResource(resource)) { + continue + } + await duplicateSpace(resource) + } + } + + const actions = computed((): SpaceAction[] => [ + { + name: 'duplicate', + icon: 'folders', + label: () => $gettext('Duplicate'), + handler: (args) => loadingService.addTask(() => handler(args)), + isEnabled: ({ resources }) => { + if (!resources?.length) { + return false + } + + if (resources.every((resource) => resource.disabled)) { + return false + } + + if (resources.every((resource) => !isProjectSpaceResource(resource))) { + return false + } + + return ability.can('create-all', 'Drive') + }, + componentType: 'button', + class: 'oc-files-actions-duplicate-trigger' + } + ]) + + return { + actions, + duplicateSpace + } +} diff --git a/packages/web-pkg/src/helpers/resource/conflictHandling/conflictUtils.ts b/packages/web-pkg/src/helpers/resource/conflictHandling/conflictUtils.ts index 2b1ac7c840e..3689827d17b 100644 --- a/packages/web-pkg/src/helpers/resource/conflictHandling/conflictUtils.ts +++ b/packages/web-pkg/src/helpers/resource/conflictHandling/conflictUtils.ts @@ -5,19 +5,24 @@ import { SpaceResource } from '@ownclouders/web-client/src/helpers' -export const resolveFileNameDuplicate = (name, extension, existingFiles, iteration = 1) => { +export const resolveFileNameDuplicate = ( + name: string, + extension: string, + existingResources: Resource[], + iteration = 1 +) => { let potentialName - if (extension.length === 0) { + if (!extension) { potentialName = `${name} (${iteration})` } else { const nameWithoutExtension = extractNameWithoutExtension({ name, extension } as Resource) potentialName = `${nameWithoutExtension} (${iteration}).${extension}` } - const hasConflict = existingFiles.some((f) => f.name === potentialName) + const hasConflict = existingResources.some((f) => f.name === potentialName) if (!hasConflict) { return potentialName } - return resolveFileNameDuplicate(name, extension, existingFiles, iteration + 1) + return resolveFileNameDuplicate(name, extension, existingResources, iteration + 1) } export const isResourceBeeingMovedToSameLocation = ( diff --git a/packages/web-pkg/tests/unit/composables/actions/spaces/useSpaceActionsDuplicate.spec.ts b/packages/web-pkg/tests/unit/composables/actions/spaces/useSpaceActionsDuplicate.spec.ts new file mode 100644 index 00000000000..8169047d709 --- /dev/null +++ b/packages/web-pkg/tests/unit/composables/actions/spaces/useSpaceActionsDuplicate.spec.ts @@ -0,0 +1,172 @@ +import { useSpaceActionsDuplicate } from '../../../../../src/composables/actions' +import { SpaceResource } from '@ownclouders/web-client/src/helpers' +import { mock } from 'jest-mock-extended' +import { + createStore, + defaultComponentMocks, + mockAxiosResolve, + defaultStoreMockOptions, + RouteLocation, + getComposableWrapper +} from 'web-test-helpers' +import { unref } from 'vue' +import { ListFilesResult } from '@ownclouders/web-client/src/webdav/listFiles' + +const spaces = [ + mock({ + name: 'Moon', + description: 'To the moon', + type: 'project', + spaceImageData: null, + spaceReadmeData: null + }) +] +describe('restore', () => { + describe('isEnabled property', () => { + it('should be false when no resource given', () => { + const { wrapper } = getWrapper({ + setup: ({ actions }, { storeOptions }) => { + expect(unref(actions)[0].isEnabled({ resources: [] })).toBe(false) + } + }) + }) + it('should be false when the space is disabled', () => { + const { wrapper } = getWrapper({ + setup: ({ actions }, { storeOptions }) => { + expect( + unref(actions)[0].isEnabled({ + resources: [ + mock({ + disabled: true, + driveType: 'project' + }) + ] + }) + ).toBe(false) + } + }) + }) + it('should be false when the space is no project space', () => { + const { wrapper } = getWrapper({ + setup: ({ actions }, { storeOptions }) => { + expect( + unref(actions)[0].isEnabled({ + resources: [ + mock({ + disabled: false, + driveType: 'personal' + }) + ] + }) + ).toBe(false) + } + }) + }) + it('should be false when the current user can not create spaces', () => { + const { wrapper } = getWrapper({ + abilities: [], + setup: ({ actions }, { storeOptions }) => { + expect( + unref(actions)[0].isEnabled({ + resources: [mock({ disabled: false, driveType: 'project' })] + }) + ).toBe(false) + } + }) + }) + it('should be true when the current user can create spaces', () => { + const { wrapper } = getWrapper({ + setup: ({ actions }, { storeOptions }) => { + expect( + unref(actions)[0].isEnabled({ + resources: [ + mock({ name: 'Moon', disabled: false, driveType: 'project' }), + mock({ name: 'Sun', disabled: false, driveType: 'project' }) + ] + }) + ).toBe(true) + } + }) + }) + }) + describe('method "duplicateSpace"', () => { + it('should show error message on error', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined) + const { wrapper } = getWrapper({ + setup: async ({ duplicateSpace }, { storeOptions, clientService }) => { + clientService.graphAuthenticated.drives.createDrive.mockRejectedValue(new Error()) + await duplicateSpace(spaces[0]) + expect(storeOptions.actions.showErrorMessage).toHaveBeenCalledTimes(1) + } + }) + }) + it('should show message on success', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined) + const { wrapper } = getWrapper({ + setup: async ({ duplicateSpace }, { storeOptions, clientService }) => { + clientService.graphAuthenticated.drives.createDrive.mockResolvedValue( + mockAxiosResolve({ + id: '1', + name: 'Moon (1)', + special: [] + }) + ) + clientService.webdav.listFiles.mockResolvedValue({ children: [] } as ListFilesResult) + await duplicateSpace(spaces[0]) + expect(clientService.graphAuthenticated.drives.createDrive).toHaveBeenCalledWith( + { + description: 'To the moon', + name: 'Moon (1)' + }, + expect.anything() + ) + expect( + storeOptions.modules.runtime.modules.spaces.mutations.UPSERT_SPACE + ).toHaveBeenCalled() + expect(storeOptions.actions.showMessage).toHaveBeenCalled() + } + }) + }) + }) +}) + +function getWrapper({ + setup, + abilities = [{ action: 'create-all', subject: 'Drive' }] +}: { + setup: ( + instance: ReturnType, + { + storeOptions, + clientService + }: { + storeOptions: typeof defaultStoreMockOptions + clientService: ReturnType['$clientService'] + } + ) => void + abilities? +}) { + const storeOptions = { + ...defaultStoreMockOptions + } + storeOptions.modules.runtime.modules.spaces.getters.spaces = jest.fn(() => spaces) + const store = createStore(storeOptions) + const mocks = defaultComponentMocks({ + currentRoute: mock({ name: 'files-spaces-projects' }) + }) + return { + mocks, + wrapper: getComposableWrapper( + () => { + const instance = useSpaceActionsDuplicate({ store }) + setup(instance, { storeOptions, clientService: mocks.$clientService }) + }, + { + mocks, + provide: mocks, + store, + pluginOptions: { abilities } + } + ) + } +}