Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: #8134 - copy to clipboard #8136

Closed
Closed
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
8 changes: 8 additions & 0 deletions changelog/unreleased/enhancement-clipboard-copy
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Enhancement: Make clipboard copy available to more browsers

We have added more functionality for copying (e.g. links) to the user's clipboard.
By switching libraries we now use the standard browser API (if available) with a
fallback and only offer copy-to-clipboard buttons if the browser supports it.

https://github.com/owncloud/web/pull/8136
https://github.com/owncloud/web/issues/8134
1 change: 0 additions & 1 deletion packages/web-app-files/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
"description": "ownCloud web files",
"license": "AGPL-3.0",
"dependencies": {
"copy-to-clipboard": "^3.3.1",
"mark.js": "^8.11.1"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
v-text="file.path"
/>
<oc-button
v-if="isSupported"
v-oc-tooltip="copyEosPathLabel"
:aria-label="copyEosPathLabel"
appearance="raw"
Expand Down Expand Up @@ -123,6 +124,7 @@
v-text="directLink"
/>
<oc-button
v-if="isSupported"
v-oc-tooltip="copyDirectLinkLabel"
:aria-label="copyDirectLinkLabel"
appearance="raw"
Expand Down Expand Up @@ -160,7 +162,7 @@
</div>
</template>
<script lang="ts">
import { ComputedRef, defineComponent, inject } from 'vue'
import { ComputedRef, defineComponent, inject, ref } from 'vue'
import { mapActions, mapGetters } from 'vuex'
import { ImageDimension } from '../../../constants'
import { loadPreview } from 'web-pkg/src/helpers/preview'
Expand All @@ -173,10 +175,11 @@ import {
useCapabilityFilesTags,
usePublicLinkContext,
useStore,
useTranslations,
useUserContext
} from 'web-pkg/src/composables'
import { getIndicators } from '../../../helpers/statusIndicators'
import copyToClipboard from 'copy-to-clipboard'
import { useClipboard } from '@vueuse/core'
import { encodePath } from 'web-pkg/src/utils'
import { formatDateFromHTTP, formatFileSize } from 'web-pkg/src/helpers'
import { eventBus } from 'web-pkg/src/services/eventBus'
Expand All @@ -190,8 +193,40 @@ export default defineComponent({
name: 'FileDetails',
setup() {
const store = useStore()
const { $gettext } = useTranslations()

const copiedDirect = ref(false)
const copiedEos = ref(false)
const { copy, copied, isSupported } = useClipboard({ legacy: true, copiedDuring: 550 })

const copyEosPathToClipboard = () => {
copy(inject<ComputedRef<Resource>>('displayedItem').value.path)
copiedEos.value = copied.value
store.dispatch('showMessage', {
title: $gettext('EOS path copied'),
desc: $gettext('The EOS path has been copied to your clipboard.')
})
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you just copied existing code, but do we really want to expose the EOS string/name to a user?

Should we revisit this, while you're at it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, obviously 💫

I'm not quite sure if I get the isSupported right though - reading https://github.com/vueuse/vueuse/blob/70f55d4e68fd8f100fe844dd883c89b6d6594d03/packages/core/useClipboard/index.ts#L67 it seems like I could omit it since it is basically supported through the legacy option anyways?

Now it's "only" fixing unit tests, no opinion about the EOS translation thought...it's a CERN-internal thing anyways and practically no-one will be able to "casually spin up Reva/oCIS/oc-web on an EOS and then be confused about those strings" ;P

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And from what I remember they literally send each other EOS paths when referencing resources


const copyDirectLinkToClipboard = () => {
copy(
`${store.getters.configuration.server}files/spaces/personal/home${
inject<ComputedRef<Resource>>('displayedItem').value.path
}`
)
copiedDirect.value = copied.value
store.dispatch('showMessage', {
title: $gettext('Direct link copied'),
desc: $gettext('The direct link has been copied to your clipboard.')
})
}

return {
copiedEos,
copyEosPathToClipboard,
copiedDirect,
copyDirectLinkToClipboard,
isSupported,
isUserContext: useUserContext({ store }),
isPublicLinkContext: usePublicLinkContext({ store }),
accessToken: useAccessToken({ store }),
Expand All @@ -200,12 +235,8 @@ export default defineComponent({
hasTags: useCapabilityFilesTags()
}
},

data: () => ({
loading: false,
copiedDirect: false,
copiedEos: false,
timeout: null
loading: false
}),
computed: {
...mapGetters('runtime/spaces', ['spaces']),
Expand Down Expand Up @@ -468,31 +499,6 @@ export default defineComponent({
await Promise.all(calls.map((p) => p.catch((e) => e)))
this.loading = false
},
copyEosPathToClipboard() {
copyToClipboard(this.file.path)
this.copiedEos = true
this.clipboardSuccessHandler()
this.showMessage({
title: this.$gettext('EOS path copied'),
desc: this.$gettext('The EOS path has been copied to your clipboard.')
})
},
copyDirectLinkToClipboard() {
copyToClipboard(this.directLink)
this.copiedDirect = true
this.clipboardSuccessHandler()
this.showMessage({
title: this.$gettext('Direct link copied'),
desc: this.$gettext('The direct link has been copied to your clipboard.')
})
},
clipboardSuccessHandler() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.copiedDirect = false
this.copiedEos = false
}, 550)
},
getTagLink(tag) {
return createLocationCommon('files-common-search', {
query: { term: `Tags:${tag}`, provider: 'files.sdk' }
Expand Down
59 changes: 30 additions & 29 deletions packages/web-app-files/src/components/SideBar/PrivateLinkItem.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<oc-button
v-if="isSupported"
v-oc-tooltip="buttonLabel"
appearance="raw"
:aria-label="buttonLabel"
Expand All @@ -18,43 +19,43 @@
</oc-button>
</template>

<script>
import { mapActions } from 'vuex'
import copyToClipboard from 'copy-to-clipboard'
import { unref } from 'vue'
<script lang="ts">
import { computed, defineComponent, inject, unref } from 'vue'
import { useClipboard } from '@vueuse/core'
import { Resource } from 'web-client'
import { useStore, useTranslations } from 'web-pkg/src'

export default {
export default defineComponent({
name: 'PrivateLinkItem',
inject: ['displayedItem'],
data: () => ({
copied: false,
timeout: null
}),
setup() {
const { $gettext } = useTranslations()
const store = useStore<any>()
const displayedItem = inject<Resource>('displayedItem')
const privateLink = computed(() => unref(displayedItem))

const { copy, copied, isSupported } = useClipboard({ legacy: true, copiedDuring: 550 })

const copyLinkToClipboard = () => {
copy(privateLink.value.privateLink)
store.dispatch('showMessage', {
title: $gettext('Private link copied'),
desc: $gettext('The private link has been copied to your clipboard.')
})
}

return {
copied,
copyLinkToClipboard,
isSupported
}
},
computed: {
buttonText() {
return this.$gettext('Private link')
},
buttonLabel() {
return this.$gettext('Copy private link to clipboard')
}
},
methods: {
...mapActions(['showMessage']),
copyPrivateLinkToClipboard() {
copyToClipboard(unref(this.displayedItem).privateLink)
this.clipboardSuccessHandler()
this.showMessage({
title: this.$gettext('Private link copied'),
desc: this.$gettext('The private link has been copied to your clipboard.')
})
},
clipboardSuccessHandler() {
this.copied = true
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.copied = false
}, 550)
}
}
}
})
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
/>
</div>
<oc-button
v-if="isSupported"
v-oc-tooltip="copyBtnHint"
appearance="raw"
:aria-label="copyBtnHint"
Expand All @@ -26,10 +27,10 @@
</div>
</template>

<script>
import { mapActions } from 'vuex'
<script lang="ts">
import { defineComponent } from 'vue'
import copyToClipboard from 'copy-to-clipboard'
import { useStore, useTranslations } from 'web-pkg/src/composables'
import { useClipboard } from '@vueuse/core'

export default defineComponent({
name: 'NameAndCopy',
Expand All @@ -39,10 +40,33 @@ export default defineComponent({
required: true
}
},
data: () => ({
copied: false,
timeout: null
}),
setup(props) {
const { $gettext, $gettextInterpolate } = useTranslations()
const store = useStore<any>()

const { copy, copied, isSupported } = useClipboard({ legacy: true, copiedDuring: 550 })

const copyLinkToClipboard = () => {
copy(props.link.url)
store.dispatch('showMessage', {
title: props.link.quicklink
? $gettext('The quicklink has been copied to your clipboard.')
: $gettextInterpolate(
$gettext('The link "%{linkName}" has been copied to your clipboard.'),
{
linkName: props.link.linkName
},
true
)
})
}

return {
copied,
copyLinkToClipboard,
isSupported
}
},
computed: {
linkName() {
return this.link.name
Expand All @@ -56,31 +80,6 @@ export default defineComponent({
copiedLabel() {
return this.$gettext('Copied')
}
},
methods: {
...mapActions(['showMessage']),
copyLinkToClipboard() {
copyToClipboard(this.link.url)
this.clipboardSuccessHandler()
this.showMessage({
title: this.link.quicklink
? this.$gettext('The quicklink has been copied to your clipboard.')
: this.$gettextInterpolate(
this.$gettext('The link "%{linkName}" has been copied to your clipboard.'),
{
linkName: this.linkName
},
true
)
})
},
clipboardSuccessHandler() {
this.copied = true
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.copied = false
}, 550)
}
}
})
</script>
Expand Down
5 changes: 3 additions & 2 deletions packages/web-app-files/src/helpers/share/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'
import { Share } from 'web-client/src/helpers/share'
import { Store } from 'vuex'
import { clientService } from 'web-pkg/src/services'
import copyToClipboard from 'copy-to-clipboard'
import { useClipboard } from '@vueuse/core'

interface CreateQuicklink {
store: Store<any>
Expand Down Expand Up @@ -45,7 +45,8 @@ export const createQuicklink = async (args: CreateQuicklink): Promise<Share> =>
storageId: resource.fileId || resource.id
})

copyToClipboard(link.url)
const { copy } = useClipboard({ legacy: true })
copy(link.url)

await store.dispatch('showMessage', {
title: $gettext('The quicklink has been copied to your clipboard.')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,39 @@
import PrivateLinkItem from 'web-app-files/src/components/SideBar/PrivateLinkItem.vue'
import { mockDeep } from 'jest-mock-extended'
import { mock } from 'jest-mock-extended'
import { Resource } from 'web-client'
import { createStore, defaultPlugins, mount, defaultStoreMockOptions } from 'web-test-helpers'
import PrivateLinkItem from 'web-app-files/src/components/SideBar/PrivateLinkItem.vue'

jest.useFakeTimers()

const folder = mock<Resource>({
type: 'folder',
ownerId: 'marie',
ownerDisplayName: 'Marie',
mdate: 'Wed, 21 Oct 2015 07:28:00 GMT',
size: '740',
name: 'lorem.txt',
privateLink: 'https://example.com/fake-private-link'
})

describe('PrivateLinkItem', () => {
it('should render a button', () => {
const { wrapper } = getWrapper()
expect(wrapper.html()).toMatchSnapshot()
})
it('upon clicking it should copy the private link to the clipboard button, render a success message and change icon for half a second', async () => {
jest.spyOn(window, 'prompt').mockImplementation()
Object.assign(window.navigator, {
clipboard: {
writeText: jest.fn().mockImplementation(() => Promise.resolve())
}
})

const { wrapper } = getWrapper()
const spyShowMessage = jest.spyOn(wrapper.vm, 'showMessage')
expect(spyShowMessage).not.toHaveBeenCalled()

await wrapper.trigger('click')
expect(wrapper.html()).toMatchSnapshot()
expect(window.navigator.clipboard.writeText).toHaveBeenCalledWith(folder.privateLink)
expect(spyShowMessage).toHaveBeenCalledTimes(1)

jest.advanceTimersByTime(550)
Expand All @@ -29,15 +45,6 @@ describe('PrivateLinkItem', () => {
})

function getWrapper() {
const folder = mockDeep<Resource>({
type: 'folder',
ownerId: 'marie',
ownerDisplayName: 'Marie',
mdate: 'Wed, 21 Oct 2015 07:28:00 GMT',
size: '740',
name: 'lorem.txt',
privateLink: 'https://example.com/fake-private-link'
})
const storeOptions = { ...defaultStoreMockOptions }
storeOptions.getters.capabilities.mockImplementation(() => ({ files: { privateLinks: true } }))
storeOptions.modules.Files.getters.highlightedFile.mockImplementation(() => folder)
Expand Down
Loading