From 202876a0755c6cd116d7120e94204194518dc634 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. --- .../components/EmbedActions/EmbedActions.vue | 90 ++----- .../components/SideBar/Shares/FileLinks.vue | 60 +---- .../SideBar/Shares/Links/DetailsAndEdit.vue | 137 ++-------- .../EmbedActions/EmbedActions.spec.ts | 122 ++------- .../__snapshots__/DetailsAndEdit.spec.ts.snap | 51 +--- packages/web-client/src/ocs/capabilities.ts | 13 +- packages/web-client/src/types.ts | 1 + .../src/components/CreateLinkModal.vue | 245 ++++++++++++++++++ .../src/components/LinkRoleDropdown.vue | 126 +++++++++ packages/web-pkg/src/components/index.ts | 2 + .../src/composables/actions/files/index.ts | 1 + .../actions/files/useFileActionsCreateLink.ts | 66 +++++ .../composables/capability/useCapability.ts | 11 + packages/web-pkg/src/helpers/share/link.ts | 37 +++ .../unit/components/CreateLinkModal.spec.ts | 199 ++++++++++++++ .../unit/components/LinkRoleDropdown.spec.ts | 61 +++++ .../files/useFileActionsCreateLink.spec.ts | 85 ++++++ packages/web-runtime/src/App.vue | 2 +- 18 files changed, 928 insertions(+), 381 deletions(-) create mode 100644 packages/web-pkg/src/components/CreateLinkModal.vue create mode 100644 packages/web-pkg/src/components/LinkRoleDropdown.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/components/LinkRoleDropdown.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateLink.spec.ts 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/LinkRoleDropdown.vue b/packages/web-pkg/src/components/LinkRoleDropdown.vue new file mode 100644 index 00000000000..61e64b3dd53 --- /dev/null +++ b/packages/web-pkg/src/components/LinkRoleDropdown.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/packages/web-pkg/src/components/index.ts b/packages/web-pkg/src/components/index.ts index 99d8456e672..5cf86fc9018 100644 --- a/packages/web-pkg/src/components/index.ts +++ b/packages/web-pkg/src/components/index.ts @@ -20,4 +20,6 @@ export { default as SpaceQuota } from './SpaceQuota.vue' export { default as SearchBarFilter } from './SearchBarFilter.vue' export { default as ViewOptions } from './ViewOptions.vue' export { default as PortalTarget } from './PortalTarget.vue' +export { default as LinkRoleDropdown } from './LinkRoleDropdown.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..7ba2658c4e9 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts @@ -0,0 +1,66 @@ +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 link for the selected items', + resources.length, + { resourceName: resources[0].name } + ), + customComponent: CreateLinkModal, + customComponentAttrs: { resources }, + cancelText: $gettext('Cancel'), + confirmText: $gettext('Create') + } + + 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/src/composables/capability/useCapability.ts b/packages/web-pkg/src/composables/capability/useCapability.ts index b82aeba37c3..f24976dc874 100644 --- a/packages/web-pkg/src/composables/capability/useCapability.ts +++ b/packages/web-pkg/src/composables/capability/useCapability.ts @@ -6,6 +6,7 @@ import { AppProviderCapability, LastModifiedFilterCapability, MediaTypeCapability, + PasswordEnforcedForCapability, PasswordPolicyCapability } from '@ownclouders/web-client/src/ocs/capabilities' import { SharePermissionBit } from '@ownclouders/web-client/src/helpers' @@ -153,3 +154,13 @@ export const useCapabilitySearchMediaType = createCapabilityComposable( + 'files_sharing.public.password.enforced_for', + { + read_only: false, + upload_only: false, + read_write: false + } + ) diff --git a/packages/web-pkg/src/helpers/share/link.ts b/packages/web-pkg/src/helpers/share/link.ts index 7d8b01ccae1..2c7a5b57872 100644 --- a/packages/web-pkg/src/helpers/share/link.ts +++ b/packages/web-pkg/src/helpers/share/link.ts @@ -13,6 +13,7 @@ import { Resource } from '@ownclouders/web-client' import { Language } from 'vue3-gettext' import { unref } from 'vue' import { showQuickLinkPasswordModal } from '../../quickActions' +import { getLocaleFromLanguage } from '../locale' export interface CreateQuicklink { clientService: ClientService @@ -181,3 +182,39 @@ export const getDefaultLinkPermissions = ({ return defaultPermissions } + +export const getExpirationRules = ({ + store, + currentLanguage +}: { + store: Store + currentLanguage: string +}): { enforced: boolean; default: DateTime; min: DateTime; max: DateTime } => { + const expireDate = store.getters.capabilities.files_sharing.public.expire_date + + let defaultExpireDate: DateTime = null + let maxExpireDateFromCaps: DateTime = null + + if (expireDate.days) { + const days = parseInt(expireDate.days) + defaultExpireDate = DateTime.now() + .setLocale(getLocaleFromLanguage(currentLanguage)) + .plus({ days }) + .toJSDate() + } + + if (expireDate.enforced) { + const days = parseInt(expireDate.days) + maxExpireDateFromCaps = DateTime.now() + .setLocale(getLocaleFromLanguage(currentLanguage)) + .plus({ days }) + .toJSDate() + } + + return { + enforced: expireDate.enforced, + default: defaultExpireDate, + min: DateTime.now().setLocale(getLocaleFromLanguage(currentLanguage)).toJSDate(), + max: maxExpireDateFromCaps + } +} 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..dd1d51bfac1 --- /dev/null +++ b/packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts @@ -0,0 +1,199 @@ +import CreateLinkModal from '../../../src/components/CreateLinkModal.vue' +import { + createStore, + defaultComponentMocks, + defaultPlugins, + defaultStoreMockOptions, + shallowMount +} 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', + linkRoleDropdown: 'link-role-dropdown-stub' +} + +describe('CreateLinkModal', () => { + it('should render a password input form', () => { + const { wrapper } = getWrapper() + expect(wrapper.find(selectors.passwordInput).exists()).toBeTruthy() + }) + describe('link role dropdown', () => { + 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 }) + const initialRole = wrapper + .findComponent(selectors.linkRoleDropdown) + .props('modelValue') + + expect(initialRole.name).toEqual(expectedRole.name) + } + ) + describe('available roles', () => { + it('contains all available roles for a folder', () => { + const resource = mock({ isFolder: true }) + const { wrapper } = getWrapper({ resources: [resource] }) + const availableRoleOptions = wrapper + .findComponent(selectors.linkRoleDropdown) + .props('availableRoleOptions') + expect(availableRoleOptions.includes(linkRoleInternalFolder)).toBeTruthy() + expect(availableRoleOptions.includes(linkRoleViewerFolder)).toBeTruthy() + expect(availableRoleOptions.includes(linkRoleEditorFolder)).toBeTruthy() + expect(availableRoleOptions.includes(linkRoleContributorFolder)).toBeTruthy() + expect(availableRoleOptions.includes(linkRoleUploaderFolder)).toBeTruthy() + }) + it('contains all available roles for a file', () => { + const resource = mock({ isFolder: false }) + const { wrapper } = getWrapper({ resources: [resource] }) + const availableRoleOptions = wrapper + .findComponent(selectors.linkRoleDropdown) + .props('availableRoleOptions') + expect(availableRoleOptions.includes(linkRoleInternalFile)).toBeTruthy() + expect(availableRoleOptions.includes(linkRoleViewerFile)).toBeTruthy() + expect(availableRoleOptions.includes(linkRoleEditorFile)).toBeTruthy() + }) + it('contains only the internal role if user can not create public links', () => { + const resource = mock({ isFolder: false }) + const { wrapper } = getWrapper({ resources: [resource], userCanCreatePublicLinks: false }) + const availableRoleOptions = wrapper + .findComponent(selectors.linkRoleDropdown) + .props('availableRoleOptions') + expect(availableRoleOptions.includes(linkRoleInternalFile)).toBeTruthy() + expect(availableRoleOptions.includes(linkRoleViewerFile)).toBeFalsy() + expect(availableRoleOptions.includes(linkRoleEditorFile)).toBeFalsy() + }) + }) + }) + describe('method "onConfirm"', () => { + it('shows an error if a password is enforced but empty', async () => { + const { wrapper } = getWrapper({ passwordEnforced: true }) + await wrapper.vm.onConfirm() + expect(wrapper.vm.password.error).toBeDefined() + }) + it('shows an error if password policies are not fulfilled', async () => { + const { wrapper } = getWrapper({ passwordPolicyFulfilled: false }) + await wrapper.vm.onConfirm() + expect(wrapper.vm.password.error).toBeDefined() + }) + it('creates links for all resources', async () => { + const resources = [mock({ isFolder: false }), mock({ isFolder: false })] + const { wrapper, storeOptions } = getWrapper({ resources }) + await wrapper.vm.onConfirm() + 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.vm.onConfirm() + 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.vm.onConfirm() + expect(storeOptions.actions.showErrorMessage).toHaveBeenCalledTimes(1) + }) + }) + describe('method "onCancel"', () => { + it('hides the modal', () => { + const { wrapper, storeOptions } = getWrapper() + wrapper.vm.onCancel() + 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: shallowMount(CreateLinkModal, { + props: { + resources + }, + global: { + plugins: [...defaultPlugins({ abilities }), store], + mocks, + provide: mocks + } + }) + } +} diff --git a/packages/web-pkg/tests/unit/components/LinkRoleDropdown.spec.ts b/packages/web-pkg/tests/unit/components/LinkRoleDropdown.spec.ts new file mode 100644 index 00000000000..35752cb0d7b --- /dev/null +++ b/packages/web-pkg/tests/unit/components/LinkRoleDropdown.spec.ts @@ -0,0 +1,61 @@ +import LinkRoleDropdown from '../../../src/components/LinkRoleDropdown.vue' +import { + createStore, + defaultComponentMocks, + defaultPlugins, + defaultStoreMockOptions, + mount +} from 'web-test-helpers' +import { mock } from 'jest-mock-extended' +import { + ShareRole, + linkRoleInternalFolder, + linkRoleViewerFolder +} from '@ownclouders/web-client/src/helpers' + +const selectors = { + currentRole: '.link-current-role', + roleOption: '.role-dropdown-list li', + roleOptionLabel: '.role-dropdown-list-option-label' +} + +describe('LinkRoleDropdown', () => { + it('renders the label of the modelValue as button label', () => { + const modelValue = mock({ label: 'someLabel' }) + const { wrapper } = getWrapper({ modelValue }) + + expect(wrapper.find(selectors.currentRole).text()).toEqual(modelValue.label) + }) + it('renders all available role options', () => { + const availableRoleOptions = [linkRoleInternalFolder, linkRoleViewerFolder] + const { wrapper } = getWrapper({ availableRoleOptions }) + + expect(wrapper.findAll(selectors.roleOption).length).toEqual(availableRoleOptions.length) + availableRoleOptions.forEach((role, index) => { + expect(wrapper.findAll(selectors.roleOptionLabel).at(index).text()).toEqual(role.label) + }) + }) +}) + +function getWrapper({ modelValue = mock(), availableRoleOptions = [] } = {}) { + const storeOptions = { ...defaultStoreMockOptions } + const store = createStore(storeOptions) + + const mocks = { ...defaultComponentMocks() } + + return { + mocks, + storeOptions, + wrapper: mount(LinkRoleDropdown, { + props: { + modelValue, + availableRoleOptions + }, + global: { + plugins: [...defaultPlugins(), store], + mocks, + provide: mocks + } + }) + } +} 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..401dfa69117 --- /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 is 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 + } + ) + } +} diff --git a/packages/web-runtime/src/App.vue b/packages/web-runtime/src/App.vue index e5abbfd6a60..bb535dfdf2b 100644 --- a/packages/web-runtime/src/App.vue +++ b/packages/web-runtime/src/App.vue @@ -45,9 +45,9 @@