Skip to content

Commit

Permalink
feat: use postMessage in embed mode instead of custom events
Browse files Browse the repository at this point in the history
  • Loading branch information
LukasHirt committed Nov 20, 2023
1 parent 263ea12 commit 519d33e
Show file tree
Hide file tree
Showing 12 changed files with 179 additions and 66 deletions.
1 change: 1 addition & 0 deletions changelog/unreleased/enhancement-embed-mode-actions
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ 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/pull/9981
https://github.com/owncloud/web/issues/9768
1 change: 1 addition & 0 deletions changelog/unreleased/enhancement-location-picker
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ We've added a new query param called `embed-target` which can have value `locati
When the value is set to `location`, it allows selecting the `currentFolder` as location instead of selecting resources.

https://github.com/owncloud/web/pull/9863
https://github.com/owncloud/web/pull/9981
https://github.com/owncloud/web/issues/9768
36 changes: 26 additions & 10 deletions docs/embed-mode/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,23 @@ To integrate ownCloud Web into your application, add an iframe element pointing
<iframe src="<web-url>?mode=embed"></iframe>
```

## Events
## Communication

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.
To establish seamless cross-origin communication between the embedded instance and the parent application, our approach involves emitting events using the `postMessage` method. These events can be conveniently captured by utilizing the standard `window.addEventListener('message', listener)` pattern.

| Event name | Payload | Description |
### Target origin

By default, the `postMessage` method has its `targetOrigin` parameter set to `*`. However, this configuration is not considered sufficiently secure. It is recommended best practice to explicitly pass in the URI of the iframe origin (not the parent application). To enhance security, you can specify this value by modifying the config option `options.embed.messagesOrigin`.

### Events

To maintain uniformity and ease of handling, each event encapsulates the same structure within its payload: `{ name: string, data: any }`.

| Name | Data | Description |
| --- | --- | --- |
| **owncloud-embed:select** | Resource[] | Gets emitted when user selects resources via the "Attach as copy" action |
| **owncloud-embed:select** | Resource[] | Gets emitted when user selects resources or location via the select 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 |
| **owncloud-embed:cancel** | null | Gets emitted when user attempts to close the embedded instance via "Cancel" action |

### Example

Expand All @@ -37,12 +45,16 @@ The app is emitting various events depending on the goal of the user. All events

<script>
function selectEventHandler(event) {
const resources = event.detail
if (event.data?.name !== 'owncloud-embed:select') {
return
}
const resources = event.data.data
doSomethingWithSelectedResources(resources)
}
window.addEventListener('owncloud-embed:select', selectEventHandler)
window.addEventListener('message', selectEventHandler)
</script>
```
Expand All @@ -57,11 +69,15 @@ By default, the Embed mode allows users to select resources. In certain cases (e
<script>
function selectEventHandler(event) {
const currentFolder = event.detail[0]
if (event.data?.name !== 'owncloud-embed:select') {
return
}
const resources = event.data.data[0]
uploadIntoCurrentFolder(currentFolder)
doSomethingWithSelectedResources(resources)
}
window.addEventListener('owncloud-embed:select', selectEventHandler)
window.addEventListener('message', selectEventHandler)
</script>
```
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default {
const clientService = useClientService()
const passwordPolicyService = usePasswordPolicyService()
const language = useGettext()
const { isLocationPicker } = useEmbedMode()
const { isLocationPicker, postMessage } = useEmbedMode()
const selectedFiles = computed<Resource[]>(() => {
if (isLocationPicker.value) {
Expand All @@ -64,27 +64,17 @@ export default {
)
const emitSelect = (): void => {
const event: CustomEvent<Resource[]> = new CustomEvent('owncloud-embed:select', {
detail: selectedFiles.value
})
window.parent.dispatchEvent(event)
postMessage<Resource[]>('owncloud-embed:select', selectedFiles.value)
}
const emitCancel = (): void => {
const event: CustomEvent<void> = new CustomEvent('owncloud-embed:cancel')
window.parent.dispatchEvent(event)
postMessage<null>('owncloud-embed:cancel', null)
}
const emitShare = (links: string[]): void => {
if (!canCreatePublicLinks.value) return
const event: CustomEvent<string[]> = new CustomEvent('owncloud-embed:share', {
detail: links
})
window.parent.dispatchEvent(event)
postMessage<string[]>('owncloud-embed:share', links)
}
const sharePublicLinks = async (): Promise<string[]> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,56 +39,107 @@ describe('EmbedActions', () => {
})

it('should emit select event when the select action is triggered', async () => {
window.parent.dispatchEvent = jest.fn()
window.parent.postMessage = 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 }] }
})
expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:select',
data: [{ id: 1 }]
},
{}
)
})

it('should enable select action when embedTarget is set to location', () => {
const { wrapper } = getWrapper({ configuration: { options: { embedTarget: 'location' } } })
const { wrapper } = getWrapper({
configuration: { options: { embed: { target: 'location' } } }
})

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

it('should emit select event with currentFolder as selected resource when select action is triggered', async () => {
window.parent.dispatchEvent = jest.fn()
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
currentFolder: { id: 1 },
configuration: { options: { embedTarget: 'location' } }
configuration: { options: { embed: { target: 'location' } } }
})

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

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

it('should specify the targetOrigin when it is set in the config', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
configuration: { options: { embed: { messagesOrigin: 'https://example.org' } } }
})

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

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:select',
data: [{ id: 1 }]
},
{ targetOrigin: 'https://example.org' }
)
})
})

describe('cancel action', () => {
it('should emit cancel event when the cancel action is triggered', async () => {
window.parent.dispatchEvent = jest.fn()
window.parent.postMessage = 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
expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:cancel',
data: null
},
{}
)
})

it('should specify the targetOrigin when it is set in the config', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
configuration: { options: { embed: { messagesOrigin: 'https://example.org' } } }
})

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

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:cancel',
data: null
},
{ targetOrigin: 'https://example.org' }
)
})
})

Expand All @@ -115,7 +166,7 @@ describe('EmbedActions', () => {
})

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

const { wrapper } = getWrapper({
Expand All @@ -125,14 +176,17 @@ describe('EmbedActions', () => {

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

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

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

const { wrapper } = getWrapper({
Expand All @@ -145,17 +199,43 @@ describe('EmbedActions', () => {

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

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

it('should hide share action when embedTarget is set to location', () => {
const { wrapper } = getWrapper({ configuration: { options: { embedTarget: 'location' } } })
const { wrapper } = getWrapper({
configuration: { options: { embed: { target: 'location' } } }
})

expect(wrapper.find(selectors.btnShare).exists()).toBe(false)
})

it('should specify the targetOrigin when it is set in the config', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
configuration: { options: { embed: { messagesOrigin: 'https://example.org' } } },
abilities: [{ action: 'create-all', subject: 'PublicLink' }]
})

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

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:share',
data: ['link-1']
},
{ targetOrigin: 'https://example.org' }
)
})
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ describe('GenericSpace view', () => {
it('should render create folder button when in embed mode', () => {
const { wrapper } = getMountedWrapper({
stubs: { 'app-bar': AppBarStub, CreateAndUpload: true },
configurationOptions: { mode: 'embed' }
configurationOptions: { embed: { enabled: true } }
})

expect(wrapper.find(selectors.btnCreateFolder).exists()).toBe(true)
Expand All @@ -242,7 +242,7 @@ describe('GenericSpace view', () => {
it('should not render create and upload actions when in embed mode', () => {
const { wrapper } = getMountedWrapper({
stubs: { 'app-bar': AppBarStub, CreateAndUpload: true },
configurationOptions: { mode: 'embed' }
configurationOptions: { embed: { enabled: true } }
})

expect(wrapper.find(selectors.actionsCreateAndUpload).exists()).toBe(false)
Expand All @@ -251,7 +251,7 @@ describe('GenericSpace view', () => {
it('should call createNewFolderAction when create folder button is clicked', () => {
const { wrapper } = getMountedWrapper({
stubs: { 'app-bar': AppBarStub, CreateAndUpload: true },
configurationOptions: { mode: 'embed' }
configurationOptions: { embed: { enabled: true } }
})

// @ts-expect-error even though the vm object is not specified on WrapperLike, it actually is present there
Expand Down
20 changes: 17 additions & 3 deletions packages/web-pkg/src/composables/embedMode/useEmbedMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,25 @@ import { useStore } from '../store'
export const useEmbedMode = () => {
const store = useStore()

const isEnabled = computed<boolean>(() => store.getters.configuration.options.mode === 'embed')
const isEnabled = computed<boolean>(() => store.getters.configuration.options.embed?.enabled)

const isLocationPicker = computed<boolean>(() => {
return store.getters.configuration.options.embedTarget === 'location'
return store.getters.configuration.options.embed?.target === 'location'
})

return { isEnabled, isLocationPicker }
const messagesTargetOrigin = computed<string>(
() => store.getters.configuration.options.embed?.messagesOrigin
)

const postMessage = <Payload>(name: string, data?: Payload): void => {
const options: WindowPostMessageOptions = {}

if (messagesTargetOrigin.value) {
options.targetOrigin = messagesTargetOrigin.value
}

window.parent.postMessage({ name, data }, options)
}

return { isEnabled, isLocationPicker, messagesTargetOrigin, postMessage }
}
7 changes: 5 additions & 2 deletions packages/web-pkg/src/configuration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,18 @@ export interface OptionsConfiguration {
openLinksWithDefaultApp?: boolean
tokenStorageLocal?: boolean
disabledExtensions?: string[]
mode?: string
isRunningOnEos?: boolean
embedTarget?: string
editor?: {
openAsPreview?: boolean | string[]
}
ocm?: {
openRemotely?: boolean
}
embed?: {
enabled?: boolean
target?: string
messagesOrigin?: string
}
}

export interface OAuth2Configuration {
Expand Down
Loading

0 comments on commit 519d33e

Please sign in to comment.