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

feat: add actions into the embed mode #9841

Merged
merged 5 commits into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions changelog/unreleased/enhancement-embed-mode-actions
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Enhancement: Add embed mode actions

We've added three new actions available in the embed mode. These actions are "Share", "Select" and "Share". They are emitting events with an optional payload. For more information, check the documentation.

https://github.com/owncloud/web/pull/9841
https://github.com/owncloud/web/issues/9768
31 changes: 31 additions & 0 deletions docs/embed-mode/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
title: 'Embed Mode'
date: 2023-10-23T00:00:00+00:00
weight: 60
geekdocRepo: https://github.com/owncloud/web
geekdocEditPath: edit/master/docs/embed-mode
geekdocFilePath: _index.md
geekdocCollapseSection: true
---

{{< toc >}}

The ownCloud Web can be consumed by another application in a stripped down version called "Embed mode". This mode is supposed to be used in the context of selecting or sharing resources. If you're looking for even more minimalistic approach, you can take a look at the [File picker](https://owncloud.dev/integration/file_picker/).

## Getting started

To integrate ownCloud Web into your application, add an iframe element pointing to your ownCloud Web deployed instance with additional query parameter `mode=embed`.

```html
<iframe src="<web-url>?mode=embed"></iframe>
```

## Events

The app is emitting various events depending on the goal of the user. All events are prefixed with `owncloud-embed:` to prevent any naming conflicts with other events.

| Event name | Payload | Description |
| --- | --- | --- |
| **owncloud-embed:select** | Resource[] | Gets emitted when user selects resources via the "Attach as copy" action |
| **owncloud-embed:share** | string[] | Gets emitted when user selects resources and shares them via the "Share links" action |
| **owncloud-embed:cancel** | void | Gets emitted when user attempts to close the embedded instance via "Cancel" action |
142 changes: 142 additions & 0 deletions packages/web-app-files/src/components/EmbedActions/EmbedActions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<template>
<section class="files-embed-actions">
<oc-button data-testid="button-cancel" appearance="raw-inverse" @click="emitCancel">{{
$gettext('Cancel')
}}</oc-button>
<oc-button
data-testid="button-share"
variation="inverse"
appearance="filled"
:disabled="areSelectActionsDisabled || !canCreatePublicLinks"
@click="sharePublicLinks"
>{{ $gettext('Share links') }}</oc-button
>
<oc-button
data-testid="button-select"
variation="inverse"
appearance="filled"
:disabled="areSelectActionsDisabled"
@click="emitSelect"
>{{ $gettext('Attach as copy') }}</oc-button
>
</section>
</template>

<script setup lang="ts">
LukasHirt marked this conversation as resolved.
Show resolved Hide resolved
import { computed } from 'vue'
import {
createQuicklink,
showQuickLinkPasswordModal,
useAbility,
useClientService,
usePasswordPolicyService,
useStore
} from '@ownclouders/web-pkg'
import { Resource } from '@ownclouders/web-client'
import { useGettext } from 'vue3-gettext'

const store = useStore()
const ability = useAbility()
const clientService = useClientService()
const passwordPolicyService = usePasswordPolicyService()
const language = useGettext()

const selectedFiles = computed<Resource[]>(() => {
return store.getters['Files/selectedFiles']
})

const areSelectActionsDisabled = computed<boolean>(() => selectedFiles.value.length < 1)

const canCreatePublicLinks = computed<boolean>(() => ability.can('create-all', 'PublicLink'))

const emitSelect = (): void => {
const event: CustomEvent<Resource[]> = new CustomEvent('owncloud-embed:select', {
detail: selectedFiles.value
})

window.parent.dispatchEvent(event)
}

const emitCancel = (): void => {
const event: CustomEvent<void> = new CustomEvent('owncloud-embed:cancel')

window.parent.dispatchEvent(event)
}

const emitShare = (links: string[]): void => {
if (!canCreatePublicLinks.value) return

const event: CustomEvent<string[]> = new CustomEvent('owncloud-embed:share', {
detail: links
})

window.parent.dispatchEvent(event)
}

const sharePublicLinks = async (): Promise<string[]> => {
if (!canCreatePublicLinks.value) return

try {
const passwordEnforced: boolean =
store.getters.capabilities?.files_sharing?.public?.password?.enforced_for?.read_only === true

if (passwordEnforced) {
showQuickLinkPasswordModal(
{ store, $gettext: language.$gettext, passwordPolicyService },
async (password) => {
const links: string[] = await Promise.all(
selectedFiles.value.map(
async (resource) =>
(
await createQuicklink({
ability,
resource,
clientService,
language,
store,
password
})
).url
)
)

emitShare(links)
}
)

return
}

const links: string[] = await Promise.all(
selectedFiles.value.map(
async (resource) =>
(
await createQuicklink({ ability, resource, clientService, language, store })
).url
)
)

emitShare(links)
} catch (error) {
console.error(error)
store.dispatch('showErrorMessage', {
title: language.$gettext('Sharing links failed...'),
error
})
}
}
</script>

<style scoped>
.files-embed-actions {
align-items: center;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
gap: var(--oc-space-medium);
justify-content: flex-end;
padding: var(--oc-space-medium) 0;
padding-right: var(--oc-space-small);
width: 100%;
}
</style>
14 changes: 13 additions & 1 deletion packages/web-app-files/src/components/FilesViewWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,25 @@
<slot />
</div>
</div>

<portal v-if="isEmbedModeEnabled" to="app.runtime.footer">
<embed-actions />
</portal>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { useEmbedMode } from '@ownclouders/web-pkg'
import EmbedActions from './EmbedActions/EmbedActions.vue'

export default defineComponent({
inheritAttrs: false
components: { EmbedActions },
inheritAttrs: false,
setup() {
const { isEnabled: isEmbedModeEnabled } = useEmbedMode()

return { isEmbedModeEnabled }
}
})
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import {
createStore,
defaultPlugins,
defaultStoreMockOptions,
shallowMount
} from 'web-test-helpers'
import EmbedActions from 'web-app-files/src/components/EmbedActions/EmbedActions.vue'

jest.mock('@ownclouders/web-pkg', () => ({
...jest.requireActual('@ownclouders/web-pkg'),
createQuicklink: jest.fn().mockImplementation(({ resource, password }) => ({
url: (password ? password + '-' : '') + 'link-' + resource.id
})),
showQuickLinkPasswordModal: jest.fn().mockImplementation((_options, cb) => cb('password'))
}))

const selectors = Object.freeze({
btnSelect: '[data-testid="button-select"]',
btnCancel: '[data-testid="button-cancel"]',
btnShare: '[data-testid="button-share"]'
})

describe('EmbedActions', () => {
afterEach(() => {
jest.clearAllMocks()
})

describe('select action', () => {
it('should disable select action when no resources are selected', () => {
const { wrapper } = getWrapper()

expect(wrapper.find(selectors.btnSelect).attributes()).toHaveProperty('disabled')
})

it('should enable select action when at least one resource is selected', () => {
const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }] })

expect(wrapper.find(selectors.btnSelect).attributes()).not.toHaveProperty('disabled')
})

it('should emit select event when the select action is triggered', async () => {
window.parent.dispatchEvent = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }] })

await wrapper.find(selectors.btnSelect).trigger('click')

expect(window.parent.dispatchEvent).toHaveBeenCalledWith({
name: 'owncloud-embed:select',
payload: { detail: [{ id: 1 }] }
})
})
})

describe('cancel action', () => {
it('should emit cancel event when the cancel action is triggered', async () => {
window.parent.dispatchEvent = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }] })

await wrapper.find(selectors.btnCancel).trigger('click')

expect(window.parent.dispatchEvent).toHaveBeenCalledWith({
name: 'owncloud-embed:cancel',
payload: undefined
})
})
})

describe('share action', () => {
it('should disable share action when link creation is disabled', () => {
const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }] })

expect(wrapper.find(selectors.btnShare).attributes()).toHaveProperty('disabled')
})

it('should disable share action when no resources are selected', () => {
const { wrapper } = getWrapper()

expect(wrapper.find(selectors.btnShare).attributes()).toHaveProperty('disabled')
})

it('should enable share action when at least one resource is selected and link creation is enabled', () => {
const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
abilities: [{ action: 'create-all', subject: 'PublicLink' }]
})

expect(wrapper.find(selectors.btnShare).attributes()).not.toHaveProperty('disabled')
})

it('should emit share event when share action is triggered', async () => {
window.parent.dispatchEvent = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
abilities: [{ action: 'create-all', subject: 'PublicLink' }]
})

await wrapper.find(selectors.btnShare).trigger('click')

expect(window.parent.dispatchEvent).toHaveBeenCalledWith({
name: 'owncloud-embed:share',
payload: { detail: ['link-1'] }
})
})

it('should ask for password first when required when share action is triggered', async () => {
window.parent.dispatchEvent = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
abilities: [{ action: 'create-all', subject: 'PublicLink' }],
capabilities: jest.fn().mockReturnValue({
files_sharing: { public: { password: { enforced_for: { read_only: true } } } }
})
})

await wrapper.find(selectors.btnShare).trigger('click')

expect(window.parent.dispatchEvent).toHaveBeenCalledWith({
name: 'owncloud-embed:share',
payload: { detail: ['password-link-1'] }
})
})
})
})

function getWrapper(
{ selectedFiles = [], abilities = [], capabilities = jest.fn().mockReturnValue({}) } = {
selectedFiles: [],
abilities: [],
capabilities: jest.fn().mockReturnValue({})
}
) {
const storeOptions = {
...defaultStoreMockOptions,
getters: { ...defaultStoreMockOptions.getters, capabilities },
modules: {
...defaultStoreMockOptions.modules,
Files: {
...defaultStoreMockOptions.modules.Files,
getters: {
...defaultStoreMockOptions.modules.Files.getters,
selectedFiles: jest.fn().mockReturnValue(selectedFiles)
}
}
}
}

return {
wrapper: shallowMount(EmbedActions, {
global: {
stubs: { OcButton: false },
plugins: [...defaultPlugins({ abilities }), createStore(storeOptions)]
}
})
}
}

function mockCustomEvent(name, payload) {
return { name, payload }
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ exports[`Projects view different files view states lists all available project s
</div>
</div>
</div>
<!--v-if-->
<side-bar-stub open="false"></side-bar-stub>
</div>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,6 @@ exports[`TrashOverview view states should render spaces list 1`] = `
</table>
</div>
</div>
<!--v-if-->
</div>
`;