Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
376 changes: 164 additions & 212 deletions packages/web-pkg/src/components/CreateLinkModal.vue

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { computed } from 'vue'
import { useGettext } from 'vue3-gettext'
import { FileAction } from '../types'
import { useClipboard } from '../../clipboard'
import { useClipboard } from '@vueuse/core'
import { useMessages } from '../../piniaStores'
import { isPublicSpaceResource, isTrashResource } from '@opencloud-eu/web-client'
import { useInterceptModifierClick } from '../../keyboardActions'
Expand All @@ -10,12 +10,12 @@ import { FileActionOptionsWithEvent } from './useFileActions'
export const useFileActionsCopyPermanentLink = () => {
const { showMessage, showErrorMessage } = useMessages()
const { $gettext } = useGettext()
const { copyToClipboard } = useClipboard()
const { copy } = useClipboard()
const { interceptModifierClick } = useInterceptModifierClick()

const copyLinkToClipboard = async (url: string) => {
try {
await copyToClipboard(url)
await copy(url)
showMessage({ title: $gettext('The link has been copied to your clipboard.') })
} catch (e) {
showErrorMessage({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,9 @@ import { FileAction, FileActionOptions } from '../../actions'
import CreateLinkModal from '../../../components/CreateLinkModal.vue'
import { useAbility } from '../../ability'
import { LinkShare, isProjectSpaceResource } from '@opencloud-eu/web-client'
import { useLinkTypes } from '../../links'
import { useCopyLink, useLinkTypes } from '../../links'
import { useLoadingService } from '../../loadingService'
import {
useMessages,
useModals,
useUserStore,
useCapabilityStore,
useSharesStore
} from '../../piniaStores'
import { useClipboard } from '../../clipboard'
import { useModals, useUserStore, useCapabilityStore, useSharesStore } from '../../piniaStores'
import { useClientService } from '../../clientService'

export const useFileActionsCreateLink = ({
Expand All @@ -23,68 +16,16 @@ export const useFileActionsCreateLink = ({
} = {}) => {
const clientService = useClientService()
const userStore = useUserStore()
const { showMessage, showErrorMessage } = useMessages()
const { $gettext, $ngettext } = useGettext()
const capabilityStore = useCapabilityStore()
const ability = useAbility()
const loadingService = useLoadingService()
const { defaultLinkType } = useLinkTypes()
const { addLink } = useSharesStore()
const { dispatchModal } = useModals()
const { copyToClipboard } = useClipboard()
const { copyLink } = useCopyLink()

const proceedResult = async ({
result,
password,
options = {}
}: {
result: PromiseSettledResult<LinkShare>[]
password?: string
options?: { copyPassword?: boolean }
}) => {
const succeeded = result.filter(
(val): val is PromiseFulfilledResult<LinkShare> => val.status === 'fulfilled'
)

if (succeeded.length) {
let successMessage = $gettext('Link has been created successfully')

if (result.length === 1) {
// Only copy to clipboard if the user tries to create one single link
try {
const copyToClipboardText = options.copyPassword
? $gettext(
'%{link} Password:%{password}',
{
link: succeeded[0].value.webUrl,
password
},
true
)
: succeeded[0].value.webUrl

await copyToClipboard(copyToClipboardText)
successMessage = $gettext('The link has been copied to your clipboard.')
} catch (e) {
console.warn('Unable to copy link to clipboard', e)
}
}

showMessage({
title: $ngettext(successMessage, 'Links have been created successfully.', succeeded.length)
})
}

const failed = result.filter(({ status }) => status === 'rejected')
if (failed.length) {
showErrorMessage({
errors: (failed as PromiseRejectedResult[]).map(({ reason }) => reason),
title: $ngettext('Failed to create link', 'Failed to create links', failed.length)
})
}
}

const handler = async ({ space, resources }: FileActionOptions) => {
const handler = ({ space, resources }: FileActionOptions) => {
const passwordEnforced = capabilityStore.sharingPublicPasswordEnforcedFor.read_only === true
if (enforceModal || passwordEnforced) {
dispatchModal({
Expand All @@ -95,11 +36,7 @@ export const useFileActionsCreateLink = ({
{ resourceName: resources[0].name }
),
customComponent: CreateLinkModal,
customComponentAttrs: () => ({
space,
resources,
callbackFn: proceedResult
}),
customComponentAttrs: () => ({ space, resources }),
hideActions: true
})
return
Expand All @@ -117,9 +54,9 @@ export const useFileActionsCreateLink = ({
}
})
)
const result = await loadingService.addTask(() => Promise.allSettled<LinkShare>(promises))

proceedResult({ result })
const createLinkHandler = () =>
loadingService.addTask(() => Promise.allSettled<LinkShare>(promises))
copyLink({ createLinkHandler })
}

const isVisible = ({ resources }: FileActionOptions) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/web-pkg/src/composables/clipboard/useClipboard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useClipboard as _useClipboard } from '@vueuse/core'

/**
* @deprecated use useClipboard from vueuse or useCopyLink for links instead
*/
export const useClipboard = () => {
// doCopy creates the requested link and copies the url to the clipboard,
// the copy action uses the clipboard // clipboardItem api to work around the webkit limitations.
Expand Down
1 change: 1 addition & 0 deletions packages/web-pkg/src/composables/links/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './useCopyLink'
export * from './useLinkTypes'
104 changes: 104 additions & 0 deletions packages/web-pkg/src/composables/links/useCopyLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useMessages } from '../piniaStores'
import { useGettext } from 'vue3-gettext'
import { LinkShare } from '@opencloud-eu/web-client'
import { useClipboard } from '@vueuse/core'

/**
* Dedicated composable for copying created links to clipboard because it requires
* special handling for Safari. For this to work you need to pass the link create method
* to copyLink so that the link is created within the same user interaction as the clipboard write.
*
* This composable also takes care of showing success and error messages.
*/
export const useCopyLink = () => {
const { $gettext, $ngettext } = useGettext()
const { showMessage, showErrorMessage } = useMessages()
const { copy } = useClipboard()

const getTextToCopy = ({
result,
password
}: {
result: PromiseSettledResult<LinkShare>[]
password?: string
}) => {
const succeeded = result.filter(
(val): val is PromiseFulfilledResult<LinkShare> => val.status === 'fulfilled'
)

let copyToClipboardText = ''
if (succeeded.length) {
let successMessage = $gettext('Link has been created successfully')

if (result.length === 1) {
// Only copy to clipboard if the user tries to create one single link
try {
copyToClipboardText = password
? $gettext(
'%{link} Password:%{password}',
{ link: succeeded[0].value.webUrl, password },
true
)
: succeeded[0].value.webUrl

successMessage = $gettext('The link has been copied to your clipboard.')
} catch (e) {
console.warn('Unable to copy link to clipboard', e)
}
}

showMessage({
title: $ngettext(successMessage, 'Links have been created successfully.', succeeded.length)
})
}

const failed = result.filter(({ status }) => status === 'rejected')
if (failed.length) {
showErrorMessage({
errors: (failed as PromiseRejectedResult[]).map(({ reason }) => reason),
title: $ngettext('Failed to create link', 'Failed to create links', failed.length)
})
}

return copyToClipboardText
}

const copyLink = async ({
createLinkHandler,
password
}: {
createLinkHandler: () => Promise<PromiseSettledResult<LinkShare>[]>
password?: string
}) => {
// special handling for Safari because it doesn't allow async clipboard writes. works in most other browsers as well.
// see https://wolfgangrittner.dev/how-to-use-clipboard-api-in-safari/ and https://developer.apple.com/forums/thread/691873
if (typeof ClipboardItem && navigator.clipboard.write) {
await new Promise<void>((resolve, reject) => {
const text = new ClipboardItem({
'text/plain': createLinkHandler()
.then((result) => {
const textToCopy = getTextToCopy({ result, password })
const blob = new Blob([textToCopy], { type: 'text/plain' })
resolve()
return blob
})
.catch((error) => {
reject()
throw error
})
})

navigator.clipboard.write([text])
})
} else {
// edge case for browsers that don't support ClipboardItem (e.g. Firefox)
const result = await createLinkHandler()
const textToCopy = getTextToCopy({ result, password })
if (textToCopy) {
copy(textToCopy)
}
}
}

return { copyLink }
}
35 changes: 9 additions & 26 deletions packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import CreateLinkModal from '../../../src/components/CreateLinkModal.vue'
import {
ComponentProps,
defaultComponentMocks,
defaultPlugins,
mount
} from '@opencloud-eu/web-test-helpers'
import { defaultComponentMocks, defaultPlugins, mount } from '@opencloud-eu/web-test-helpers'
import { mock } from 'vitest-mock-extended'
import { PasswordPolicyService } from '../../../src/services'
import { usePasswordPolicyService } from '../../../src/composables/passwordPolicyService'
Expand Down Expand Up @@ -37,13 +32,13 @@ describe('CreateLinkModal', () => {
describe('password input', () => {
it('should not rendered when "isAdvancedMode" is not set', async () => {
const { wrapper } = getWrapper()
wrapper.vm.isAdvancedMode = false
;(wrapper.vm as any).isAdvancedMode = false
await nextTick()
expect(wrapper.find(selectors.passwordInput).exists()).toBeFalsy()
})
it('should be rendered', async () => {
const { wrapper } = getWrapper()
wrapper.vm.isAdvancedMode = true
;(wrapper.vm as any).isAdvancedMode = true
await nextTick()
expect(wrapper.find(selectors.passwordInput).exists()).toBeTruthy()
})
Expand All @@ -59,13 +54,13 @@ describe('CreateLinkModal', () => {
describe('datepicker', () => {
it('should not rendered when "isAdvancedMode" is not set', async () => {
const { wrapper } = getWrapper()
wrapper.vm.isAdvancedMode = false
;(wrapper.vm as any).isAdvancedMode = false
await nextTick()
expect(wrapper.findComponent({ name: 'oc-datepicker' }).exists()).toBeFalsy()
})
it('should be rendered', async () => {
const { wrapper } = getWrapper()
wrapper.vm.isAdvancedMode = true
;(wrapper.vm as any).isAdvancedMode = true
await nextTick()
expect(wrapper.findComponent({ name: 'oc-datepicker' }).exists()).toBeTruthy()
})
Expand All @@ -81,14 +76,14 @@ describe('CreateLinkModal', () => {
describe('link role drop', () => {
it('should not rendered when "isAdvancedMode" is not set', async () => {
const { wrapper } = getWrapper()
wrapper.vm.isAdvancedMode = false
;(wrapper.vm as any).isAdvancedMode = false
await nextTick()
expect(wrapper.find(selectors.linkRoleDropDownToggle).exists()).toBeFalsy()
})
it('lists all types as roles', async () => {
const availableLinkTypes = [SharingLinkType.View, SharingLinkType.Edit]
const { wrapper } = getWrapper({ availableLinkTypes })
wrapper.vm.isAdvancedMode = true
;(wrapper.vm as any).isAdvancedMode = true
await nextTick()
await wrapper.find(selectors.linkRoleDropDownToggle).trigger('click')

Expand All @@ -97,14 +92,12 @@ describe('CreateLinkModal', () => {
})
describe('method "confirm"', () => {
it('creates links for all resources', async () => {
const callbackFn = vi.fn()
const resources = [mock<Resource>({ isFolder: false }), mock<Resource>({ isFolder: false })]
const { wrapper } = getWrapper({ resources, callbackFn })
const { wrapper } = getWrapper({ resources })
await wrapper.vm.onConfirm()

const { addLink } = useSharesStore()
expect(addLink).toHaveBeenCalledTimes(resources.length)
expect(callbackFn).toHaveBeenCalledTimes(1)
})
it('emits event in embed mode including the created links', async () => {
const resources = [mock<Resource>({ isFolder: false })]
Expand All @@ -128,19 +121,12 @@ describe('CreateLinkModal', () => {

expect(consoleMock).toHaveBeenCalledTimes(1)
})
it('calls the callback at the end if given', async () => {
const resources = [mock<Resource>({ isFolder: false })]
const callbackFn = vi.fn()
const { wrapper } = getWrapper({ resources, callbackFn })
await wrapper.vm.onConfirm()
expect(callbackFn).toHaveBeenCalledTimes(1)
})
})
describe('action buttons', () => {
describe('confirm button', () => {
it('is disabled when password policy is not fulfilled', async () => {
const { wrapper } = getWrapper({ passwordPolicyFulfilled: false })
wrapper.vm.isAdvancedMode = true
;(wrapper.vm as any).isAdvancedMode = true
await nextTick()
expect(wrapper.find(selectors.confirmBtn).attributes('disabled')).toBeTruthy()
})
Expand All @@ -155,7 +141,6 @@ function getWrapper({
passwordEnforced = false,
passwordPolicyFulfilled = true,
embedModeEnabled = false,
callbackFn = undefined,
availableLinkTypes = [SharingLinkType.View]
}: {
resources?: Resource[]
Expand All @@ -164,7 +149,6 @@ function getWrapper({
passwordEnforced?: boolean
passwordPolicyFulfilled?: boolean
embedModeEnabled?: boolean
callbackFn?: ComponentProps<typeof CreateLinkModal>['callbackFn']
availableLinkTypes?: SharingLinkType[]
} = {}) {
vi.mocked(usePasswordPolicyService).mockReturnValue(
Expand Down Expand Up @@ -212,7 +196,6 @@ function getWrapper({
wrapper: mount(CreateLinkModal, {
props: {
resources,
callbackFn,
modal: mock<Modal>()
},
global: {
Expand Down
Loading