From 161c04defb6b996b1960740c640bf0e549fe2e2a Mon Sep 17 00:00:00 2001 From: Jannik Stehle Date: Mon, 27 Nov 2023 13:43:18 +0100 Subject: [PATCH] feat: show modal when creating links in embed mode Adds a modal that pops up after clicking "Share links" in embed mode where the user can specify the props of the link(s) they want to create: password, role, name. It also changes the behavior so there will be always new links created instead of re-using existing links. --- .../src/components/OcRadio/OcRadio.vue | 5 +- .../src/components/AppBar/CreateAndUpload.vue | 2 +- .../components/EmbedActions/EmbedActions.vue | 90 +--- .../src/views/spaces/GenericSpace.vue | 6 +- .../CreateAndUpload.spec.ts.snap | 2 +- .../EmbedActions/EmbedActions.spec.ts | 155 ++----- packages/web-client/src/helpers/share/role.ts | 4 +- packages/web-client/src/types.ts | 1 + .../src/components/CreateLinkModal.vue | 427 ++++++++++++++++++ packages/web-pkg/src/components/index.ts | 1 + .../src/composables/actions/files/index.ts | 1 + .../actions/files/useFileActionsCreateLink.ts | 65 +++ .../unit/components/CreateLinkModal.spec.ts | 225 +++++++++ .../files/useFileActionsCreateLink.spec.ts | 85 ++++ 14 files changed, 881 insertions(+), 188 deletions(-) create mode 100644 packages/web-pkg/src/components/CreateLinkModal.vue create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts create mode 100644 packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateLink.spec.ts diff --git a/packages/design-system/src/components/OcRadio/OcRadio.vue b/packages/design-system/src/components/OcRadio/OcRadio.vue index 53b0bf27a08..ca74f9fae33 100644 --- a/packages/design-system/src/components/OcRadio/OcRadio.vue +++ b/packages/design-system/src/components/OcRadio/OcRadio.vue @@ -52,6 +52,7 @@ export default defineComponent({ **/ // eslint-disable-next-line vue/require-prop-types modelValue: { + type: [String, Number, Boolean, Object], required: false, default: false }, @@ -127,7 +128,7 @@ export default defineComponent({ -webkit-appearance: none; -moz-appearance: none; - border: 1px solid var(--oc-color-input-border); + border: 1px solid var(--oc-color-swatch-brand-default); border-radius: 50%; box-sizing: border-box; background-color: var(--oc-color-input-bg); @@ -148,7 +149,7 @@ export default defineComponent({ } &:checked { - background-color: var(--oc-color-swatch-brand-default); + background-color: var(--oc-color-background-highlight); } &.oc-radio-s { diff --git a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue index f781bea65e1..eac2522d749 100644 --- a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue +++ b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue @@ -491,7 +491,7 @@ export default defineComponent({ return tooltip } if (!this.createFileActionsAvailable) { - return this.$gettext('Create a new folder') + return this.$gettext('New folder') } return this.$gettext('Create new files or folders') }, diff --git a/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue b/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue index b1921639137..43f464d5882 100644 --- a/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue +++ b/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue @@ -9,8 +9,10 @@ data-testid="button-share" variation="inverse" appearance="filled" - :disabled="areSelectActionsDisabled || !canCreatePublicLinks" - @click="sharePublicLinks" + :disabled=" + areSelectActionsDisabled || !createLinkAction.isEnabled({ resources: selectedFiles, space }) + " + @click="createLinkAction.handler({ resources: selectedFiles, space })" >{{ $gettext('Share links') }} + + diff --git a/packages/web-pkg/src/components/index.ts b/packages/web-pkg/src/components/index.ts index 99c6e35ddc3..ec873a6be08 100644 --- a/packages/web-pkg/src/components/index.ts +++ b/packages/web-pkg/src/components/index.ts @@ -22,3 +22,4 @@ export { default as SearchBarFilter } from './SearchBarFilter.vue' export { default as ViewOptions } from './ViewOptions.vue' export { default as PortalTarget } from './PortalTarget.vue' export { default as CreateShortcutModal } from './CreateShortcutModal.vue' +export { default as CreateLinkModal } from './CreateLinkModal.vue' diff --git a/packages/web-pkg/src/composables/actions/files/index.ts b/packages/web-pkg/src/composables/actions/files/index.ts index 01a7e27eda3..b66c45cf3b6 100644 --- a/packages/web-pkg/src/composables/actions/files/index.ts +++ b/packages/web-pkg/src/composables/actions/files/index.ts @@ -24,3 +24,4 @@ export * from './useFileActionsCreateNewFolder' export * from './useFileActionsCreateNewFile' export * from './useFileActionsCreateNewShortcut' export * from './useFileActionsOpenShortcut' +export * from './useFileActionsCreateLink' diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts new file mode 100644 index 00000000000..83f0b7f163e --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts @@ -0,0 +1,65 @@ +import { Store } from 'vuex' +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from '../../actions' +import { CreateLinkModal } from '../../../components' +import { useAbility } from '../../ability' +import { isProjectSpaceResource } from '@ownclouders/web-client/src/helpers' + +export const useFileActionsCreateLink = ({ store }: { store?: Store } = {}) => { + const { $gettext, $ngettext } = useGettext() + const ability = useAbility() + + const handler = ({ resources }: FileActionOptions) => { + const modal = { + variation: 'passive', + title: $ngettext( + 'Create link for "%{resourceName}"', + 'Create links for the selected items', + resources.length, + { resourceName: resources[0].name } + ), + customComponent: CreateLinkModal, + customComponentAttrs: { resources }, + hideActions: true + } + + store.dispatch('createModal', modal) + } + + const actions = computed((): FileAction[] => { + return [ + { + name: 'create-links', + icon: 'link', + handler, + label: () => { + return $gettext('Create links') + }, + isEnabled: ({ resources }) => { + if (!resources.length) { + return false + } + + for (const resource of resources) { + if (!resource.canShare({ user: store.getters.user, ability })) { + return false + } + + if (isProjectSpaceResource(resource) && resource.disabled) { + return false + } + } + + return true + }, + componentType: 'button', + class: 'oc-files-actions-create-links' + } + ] + }) + + return { + actions + } +} diff --git a/packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts b/packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts new file mode 100644 index 00000000000..23f1ff47ad8 --- /dev/null +++ b/packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts @@ -0,0 +1,225 @@ +import CreateLinkModal from '../../../src/components/CreateLinkModal.vue' +import { + createStore, + defaultComponentMocks, + defaultPlugins, + defaultStoreMockOptions, + mount +} from 'web-test-helpers' +import { mock } from 'jest-mock-extended' +import { PasswordPolicyService } from '../../../src/services' +import { usePasswordPolicyService } from '../../../src/composables/passwordPolicyService' +import { getDefaultLinkPermissions } from '../../../src/helpers/share/link' +import { + AbilityRule, + LinkShareRoles, + Resource, + Share, + SharePermissionBit, + linkRoleContributorFolder, + linkRoleEditorFile, + linkRoleEditorFolder, + linkRoleInternalFile, + linkRoleInternalFolder, + linkRoleUploaderFolder, + linkRoleViewerFile, + linkRoleViewerFolder +} from '@ownclouders/web-client/src/helpers' +import { PasswordPolicy } from 'design-system/src/helpers' +import { useEmbedMode } from '../../../src/composables/embedMode' +import { ref } from 'vue' + +jest.mock('../../../src/composables/embedMode') +jest.mock('../../../src/composables/passwordPolicyService') +jest.mock('../../../src/helpers/share/link', () => ({ + ...jest.requireActual('../../../src/helpers/share/link'), + getDefaultLinkPermissions: jest.fn() +})) + +const selectors = { + passwordInput: '.link-modal-password-input', + selectedRoleLabel: '.selected .role-dropdown-list-option-label', + roleLabels: '.role-dropdown-list-option-label', + contextMenuToggle: '#link-modal-context-menu-toggle', + confirmBtn: '.link-modal-confirm', + cancelBtn: '.link-modal-cancel' +} + +describe('CreateLinkModal', () => { + describe('password input', () => { + it('should be rendered', () => { + const { wrapper } = getWrapper() + expect(wrapper.find(selectors.passwordInput).exists()).toBeTruthy() + }) + it('should be disabled for internal links', () => { + const { wrapper } = getWrapper({ defaultLinkPermissions: 0 }) + expect(wrapper.find(selectors.passwordInput).attributes('disabled')).toBeTruthy() + }) + it('should not be rendered if user cannot create public links', () => { + const { wrapper } = getWrapper({ userCanCreatePublicLinks: false, defaultLinkPermissions: 0 }) + expect(wrapper.find(selectors.passwordInput).exists()).toBeFalsy() + }) + }) + describe('link role select', () => { + it.each([SharePermissionBit.Internal, SharePermissionBit.Read])( + 'uses the link default permissions to retrieve the initial role', + (defaultLinkPermissions) => { + const expectedRole = LinkShareRoles.getByBitmask(defaultLinkPermissions, false) + const { wrapper } = getWrapper({ defaultLinkPermissions }) + + expect(wrapper.find(selectors.selectedRoleLabel).text()).toEqual(expectedRole.label) + } + ) + describe('available roles', () => { + it('lists all available roles for a folder', () => { + const resource = mock({ isFolder: true }) + const { wrapper } = getWrapper({ resources: [resource] }) + const folderRoleLabels = [ + linkRoleInternalFolder, + linkRoleViewerFolder, + linkRoleEditorFolder, + linkRoleContributorFolder, + linkRoleUploaderFolder + ].map(({ label }) => label) + + for (const label of wrapper.findAll(selectors.roleLabels)) { + expect(folderRoleLabels.includes(label.text())).toBeTruthy() + } + }) + it('lists all available roles for a file', () => { + const resource = mock({ isFolder: false }) + const { wrapper } = getWrapper({ resources: [resource] }) + const fileRoleLabels = [linkRoleInternalFile, linkRoleViewerFile, linkRoleEditorFile].map( + ({ label }) => label + ) + + for (const label of wrapper.findAll(selectors.roleLabels)) { + expect(fileRoleLabels.includes(label.text())).toBeTruthy() + } + }) + it('lists only the internal role if user cannot create public links', () => { + const resource = mock({ isFolder: false }) + const { wrapper } = getWrapper({ + resources: [resource], + userCanCreatePublicLinks: false, + defaultLinkPermissions: 0 + }) + expect(wrapper.findAll(selectors.roleLabels).length).toBe(1) + expect(wrapper.find(selectors.selectedRoleLabel).text()).toEqual(linkRoleInternalFile.label) + }) + }) + }) + describe('context menu', () => { + it('should display the button to toggle the context menu', () => { + const { wrapper } = getWrapper() + expect(wrapper.find(selectors.contextMenuToggle).exists()).toBeTruthy() + }) + it('should not display the button to toggle the context menu if user cannot create public links', () => { + const { wrapper } = getWrapper({ userCanCreatePublicLinks: false, defaultLinkPermissions: 0 }) + expect(wrapper.find(selectors.contextMenuToggle).exists()).toBeFalsy() + }) + }) + describe('method "confirm"', () => { + it('shows an error if a password is enforced but empty', async () => { + const { wrapper } = getWrapper({ passwordEnforced: true }) + await wrapper.find(selectors.confirmBtn).trigger('click') + expect(wrapper.vm.password.error).toBeDefined() + }) + it('does not create links when the password policy is not fulfilled', async () => { + const { wrapper, storeOptions } = getWrapper({ passwordPolicyFulfilled: false }) + await wrapper.find(selectors.confirmBtn).trigger('click') + expect(storeOptions.actions.hideModal).not.toHaveBeenCalled() + }) + it('creates links for all resources', async () => { + const resources = [mock({ isFolder: false }), mock({ isFolder: false })] + const { wrapper, storeOptions } = getWrapper({ resources }) + await wrapper.find(selectors.confirmBtn).trigger('click') + expect(storeOptions.modules.Files.actions.addLink).toHaveBeenCalledTimes(resources.length) + expect(storeOptions.actions.hideModal).toHaveBeenCalledTimes(1) + }) + it('emits event in embed mode including the created links', async () => { + const resources = [mock({ isFolder: false })] + const { wrapper, storeOptions, mocks } = getWrapper({ resources, embedModeEnabled: true }) + const share = mock({ url: 'someurl' }) + storeOptions.modules.Files.actions.addLink.mockResolvedValue(share) + await wrapper.find(selectors.confirmBtn).trigger('click') + expect(mocks.postMessageMock).toHaveBeenCalledWith('owncloud-embed:share', [share.url]) + }) + it('shows error messages for links that failed to be created', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined) + const resources = [mock({ isFolder: false })] + const { wrapper, storeOptions } = getWrapper({ resources }) + storeOptions.modules.Files.actions.addLink.mockRejectedValue(new Error('')) + await wrapper.find(selectors.confirmBtn).trigger('click') + expect(storeOptions.actions.showErrorMessage).toHaveBeenCalledTimes(1) + }) + }) + describe('method "cancel"', () => { + it('hides the modal', async () => { + const { wrapper, storeOptions } = getWrapper() + await wrapper.find(selectors.cancelBtn).trigger('click') + expect(storeOptions.actions.hideModal).toHaveBeenCalledTimes(1) + }) + }) +}) + +function getWrapper({ + resources = [], + defaultLinkPermissions = 1, + userCanCreatePublicLinks = true, + passwordEnforced = false, + passwordPolicyFulfilled = true, + embedModeEnabled = false +} = {}) { + jest.mocked(usePasswordPolicyService).mockReturnValue( + mock({ + getPolicy: () => mock({ check: () => passwordPolicyFulfilled }) + }) + ) + jest.mocked(getDefaultLinkPermissions).mockReturnValue(defaultLinkPermissions) + + const postMessageMock = jest.fn() + jest.mocked(useEmbedMode).mockReturnValue( + mock>({ + isEnabled: ref(embedModeEnabled), + postMessage: postMessageMock + }) + ) + + const mocks = { ...defaultComponentMocks(), postMessageMock } + + const storeOptions = defaultStoreMockOptions + storeOptions.getters.capabilities.mockReturnValue({ + files_sharing: { + public: { + expire_date: {}, + can_edit: true, + can_contribute: true, + alias: true, + password: { enforced_for: { read_only: passwordEnforced } } + } + } + }) + + const store = createStore(storeOptions) + const abilities = [] as AbilityRule[] + if (userCanCreatePublicLinks) { + abilities.push({ action: 'create-all', subject: 'PublicLink' }) + } + + return { + storeOptions, + mocks, + wrapper: mount(CreateLinkModal, { + props: { + resources + }, + global: { + plugins: [...defaultPlugins({ abilities }), store], + mocks, + provide: mocks, + stubs: { OcTextInput: true, OcDatepicker: true } + } + }) + } +} diff --git a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateLink.spec.ts b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateLink.spec.ts new file mode 100644 index 00000000000..53b7d3baca4 --- /dev/null +++ b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateLink.spec.ts @@ -0,0 +1,85 @@ +import { unref } from 'vue' +import { useFileActionsCreateLink } from '../../../../../src/composables/actions/files/useFileActionsCreateLink' +import { + createStore, + defaultComponentMocks, + defaultStoreMockOptions, + getComposableWrapper +} from 'web-test-helpers' +import { mock } from 'jest-mock-extended' +import { Resource } from '@ownclouders/web-client' + +describe('useFileActionsCreateLink', () => { + describe('isEnabled property', () => { + it('should return false if no resource selected', () => { + getWrapper({ + setup: ({ actions }) => { + expect(unref(actions)[0].isEnabled({ space: null, resources: [] })).toBeFalsy() + } + }) + }) + it('should return false if one resource can not be shared', () => { + getWrapper({ + setup: ({ actions }) => { + const resources = [mock({ canShare: () => false })] + expect(unref(actions)[0].isEnabled({ space: null, resources })).toBeFalsy() + } + }) + }) + it('should return false if one resource is a disabled project space', () => { + getWrapper({ + setup: ({ actions }) => { + const resources = [ + mock({ canShare: () => true, disabled: true, driveType: 'project' }) + ] + expect(unref(actions)[0].isEnabled({ space: null, resources })).toBeFalsy() + } + }) + }) + it('should return true if all files can be shared', () => { + getWrapper({ + setup: ({ actions }) => { + const resources = [ + mock({ canShare: () => true }), + mock({ canShare: () => true }) + ] + expect(unref(actions)[0].isEnabled({ space: null, resources })).toBeTruthy() + } + }) + }) + }) + describe('handler', () => { + it('creates a modal window', () => { + getWrapper({ + setup: ({ actions }, { storeOptions }) => { + unref(actions)[0].handler({ + space: null, + resources: [mock({ canShare: () => true })] + }) + expect(storeOptions.actions.createModal).toHaveBeenCalledTimes(1) + } + }) + }) + }) +}) + +function getWrapper({ setup }) { + const mocks = defaultComponentMocks() + + const storeOptions = defaultStoreMockOptions + const store = createStore(storeOptions) + + return { + wrapper: getComposableWrapper( + () => { + const instance = useFileActionsCreateLink({ store }) + setup(instance, { storeOptions }) + }, + { + mocks, + store, + provide: mocks + } + ) + } +}