Skip to content

Commit

Permalink
Merge pull request #189 from CPatchane/improve_intent
Browse files Browse the repository at this point in the history
Intents improvements (onReadyCallback, exposeFrameRemoval)
  • Loading branch information
CPatchane authored Jul 20, 2017
2 parents ed95ffe + 4a10117 commit 01bf933
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 8 deletions.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Removed
- none yet

## [v0.3.10] - 2017-07-XX
### Changed
- none yet

### Fixed
- none yet

### Added
- Handle `exposeIntentFrameRemoval` data flag to terminate the service without removing the intent DOM node directly but by providing the removal function to the client.
- Add an `onReadyCallback` optional argument to the create method in order to allow providing a callback function to be run when the intent iframe will be loaded (iframe `onload` listener).

### Removed
- none yet


## [v0.3.9] - 2017-07-10
### Added
Expand Down
25 changes: 25 additions & 0 deletions docs/intents-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

`cozy.client.intents.create(action, doctype [, data, permissions])` create an intent. It returns a modified Promise for the intent document, having a custom `start(element)` method. This method interacts with the DOM to append an iframe to the given HTML element. This iframe will provide an access to an app, which will serve a service page able to manage the intent action for the intent doctype. The `start(element)` method returns a promise for the result document provided by intent service.

> __On Intent ready callback:__ This `start` method also takes a second optional argument which is a callback function (`start(element, onReadyCallback)`). When provided, this function will be run when the intent iframe will be completely loaded (using the `onload` iframe listener). This callback could be useful to run a client code only when the intent iframe is ready and loaded.
An intent has to be created everytime an app need to perform an action over a doctype for wich it does not have permission. For example, the Cozy Drive app should create an intent to `pick` a `io.cozy.contacts` document. The cozy-stack will determines which app can offer a service to resolve the intent. It's this service's URL that will be passed to the iframe `src` property.

Once the intent process is terminated by service, the iframe is removed from DOM.
Expand All @@ -16,6 +18,27 @@ cozy.client.intents.create('EDIT', 'io.cozy.photos', {action: 'crop', width: 100

See cozy-stack [documentation](https://cozy.github.io/cozy-stack/intents.html) for more details.

You can also use `.then` to run some code after the intents is terminated like following:

```js
cozy.client.intents.create('EDIT', 'io.cozy.photos', {action: 'crop', width: 100, height: 100})
.start(document.getElementById('intent-service-wrapper'))
.then(doc => { // after service.terminate(doc)
// code to use the doc
})
```

Example to use `removeIntentFrame()` method (by passing the flag `exposeIntentFrameRemoval` flag):
```js
cozy.client.intents.create('EDIT', 'io.cozy.photos', {action: 'crop', width: 100, height: 100, exposeIntentFrameRemoval: true})
.start(document.getElementById('intent-service-wrapper'))
.then({removeIntentFrame, doc} => { // after service.terminate(doc)
// Code to be run before removing the terminated intent iframe
removeIntentFrame()
// Other code, use doc
})
```

### `cozy.client.intents.createService()`

`cozy.client.intents.createService([intentId, window])` has to be used in the intent service page. It initializes communication with the parent window (remember: the service is supposed to be in an iframe).
Expand All @@ -37,6 +60,8 @@ It returns a *service* object, which provides the following methods :
})
```
* `terminate(doc)`: ends the intent process by passing to the client the resulting document `doc`. An intent service may only be terminated once.
> If a boolean `exposeIntentFrameRemoval` is found as `true` in the data sent by the client, the `terminate()` method will return an object with as properties a function named `removeIntentFrame` to remove the iframe DOM node (in order to be run by the client later on) and the resulting document `doc`. This could be useful to animate an intent closing and remove the iframe node at the animation ending.
* `cancel()`: ends the intent process by passing a `null` value to the client. This method terminate the intent service the same way that `terminate()`.
* `throw(error)`: throw an error to client and causes the intent promise rejection.

Expand Down
35 changes: 27 additions & 8 deletions src/intents.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ const errorSerializer = (() => {
})()

// inject iframe for service in given element
function injectService (url, element, intent, data) {
function injectService (url, element, intent, data, onReadyCallback) {
const document = element.ownerDocument
if (!document) throw new Error('Cannot retrieve document object from given element')

const window = document.defaultView
if (!window) throw new Error('Cannot retrieve window object from document')

const iframe = document.createElement('iframe')
// if callback provided for when iframe is loaded
if (typeof onReadyCallback === 'function') iframe.onload = onReadyCallback
iframe.setAttribute('src', url)
iframe.classList.add(intentClass)
element.appendChild(iframe)
Expand Down Expand Up @@ -62,7 +64,15 @@ function injectService (url, element, intent, data) {
}

window.removeEventListener('message', messageHandler)
iframe.parentNode.removeChild(iframe)
const removeIntentFrame = () => {
iframe.parentNode.removeChild(iframe)
}

if (handshaken && event.data.type === `intent-${intent._id}:exposeFrameRemoval`) {
return resolve({removeIntentFrame, doc: event.data.document})
}

removeIntentFrame()

if (event.data.type === `intent-${intent._id}:error`) {
return reject(errorSerializer.deserialize(event.data.error))
Expand Down Expand Up @@ -108,15 +118,15 @@ export function create (cozy, action, type, data = {}, permissions = []) {
}
})

createPromise.start = (element) => {
createPromise.start = (element, onReadyCallback) => {
return createPromise.then(intent => {
let service = intent.attributes.services && intent.attributes.services[0]

if (!service) {
return Promise.reject(new Error('Unable to find a service'))
}

return injectService(service.href, element, intent, data)
return injectService(service.href, element, intent, data, onReadyCallback)
})
}

Expand Down Expand Up @@ -189,10 +199,19 @@ export function createService (cozy, intentId, serviceWindow) {
return {
getData: () => data,
getIntent: () => intent,
terminate: (doc) => terminate({
type: `intent-${intent._id}:done`,
document: doc
}),
terminate: (doc) => {
if (data && data.exposeIntentFrameRemoval) {
return terminate({
type: `intent-${intent._id}:exposeFrameRemoval`,
document: doc
})
} else {
return terminate({
type: `intent-${intent._id}:done`,
document: doc
})
}
},
throw: error => terminate({
type: `intent-${intent._id}:error`,
error: errorSerializer.serialize(error)
Expand Down
106 changes: 106 additions & 0 deletions test/unit/intents.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,33 @@ describe('Intents', function () {
const element = mockElement()
const {documentMock, iframeMock} = element

const onReadyCallbackMock = () => {}

cozy.client.intents
.create('PICK', 'io.cozy.files')
.start(element, onReadyCallbackMock)

setTimeout(() => {
should(documentMock.createElement.withArgs('iframe').calledOnce).be.true()
should(iframeMock.onload).equal(onReadyCallbackMock)
should(iframeMock.setAttribute.withArgs('src', expectedIntent.attributes.services[0].href).calledOnce).be.true()
should(iframeMock.classList.add.withArgs('coz-intent').calledOnce).be.true()
should(element.appendChild.withArgs(iframeMock).calledOnce).be.true()
done()
}, 10)
})

it('should inject iframe (not async) also without onReadyCallback', function (done) {
const element = mockElement()
const {documentMock, iframeMock} = element

cozy.client.intents
.create('PICK', 'io.cozy.files')
.start(element)

setTimeout(() => {
should(documentMock.createElement.withArgs('iframe').calledOnce).be.true()
should(iframeMock.onload).be.undefined()
should(iframeMock.setAttribute.withArgs('src', expectedIntent.attributes.services[0].href).calledOnce).be.true()
should(iframeMock.classList.add.withArgs('coz-intent').calledOnce).be.true()
should(element.appendChild.withArgs(iframeMock).calledOnce).be.true()
Expand Down Expand Up @@ -346,6 +367,59 @@ describe('Intents', function () {

return call.should.be.fulfilledWith(result)
})

it('should handle intent exposeFrameRemoval', async function () {
const element = mockElement()
const {windowMock, iframeMock, iframeWindowMock} = element

const handshakeEventMessageMock = {
origin: serviceUrl,
data: {
type: 'intent-77bcc42c-0fd8-11e7-ac95-8f605f6e8338:ready'
},
source: iframeWindowMock
}

const docMock = {
id: 'abcde1234'
}

const resolveexposeFrameRemovalEventMessageMock = {
origin: serviceUrl,
data: {
type: 'intent-77bcc42c-0fd8-11e7-ac95-8f605f6e8338:exposeFrameRemoval',
document: docMock
},
source: iframeWindowMock
}

const call = cozy.client.intents
.create('PICK', 'io.cozy.files', {key: 'value'})
.start(element)

setTimeout(() => {
should(windowMock.addEventListener.withArgs('message').calledOnce).be.true()
should(windowMock.removeEventListener.neverCalledWith('message')).be.true()

const messageEventListener = windowMock.addEventListener.firstCall.args[1]

messageEventListener(handshakeEventMessageMock)
should(iframeWindowMock.postMessage.calledWithMatch({key: 'value'}, serviceUrl)).be.true()

messageEventListener(resolveexposeFrameRemovalEventMessageMock)
should(windowMock.removeEventListener.withArgs('message', messageEventListener).calledOnce).be.true()
}, 10)

return call.then(result => {
should(result.doc).equal(docMock)
should(result.removeIntentFrame).be.Function()

// test iframe removing by calling the returned closing method
should(iframeMock.parentNode.removeChild.withArgs(iframeMock).calledOnce).be.false()
result.removeIntentFrame()
should(iframeMock.parentNode.removeChild.withArgs(iframeMock).calledOnce).be.true()
})
})
})

describe('CreateService', function () {
Expand Down Expand Up @@ -600,6 +674,38 @@ describe('Intents', function () {
delete global.window
})

it('should send removeIntentFrame method also if exposeIntentFrameRemoval flag found in data', async function () {
global.window = mockWindow()

const clientHandshakeEventMessageMock = {
origin: expectedIntent.attributes.client,
data: { foo: 'bar', exposeIntentFrameRemoval: true }
}

const docMock = {
type: 'io.cozy.things'
}

window.parent.postMessage.callsFake(() => {
const messageEventListener = window.addEventListener.secondCall.args[1]
messageEventListener(clientHandshakeEventMessageMock)
})

const service = await cozy.client.intents.createService()

service.terminate(docMock)

const messageMatch = sinon.match({
type: 'intent-77bcc42c-0fd8-11e7-ac95-8f605f6e8338:exposeFrameRemoval',
document: docMock
})

window.parent.postMessage
.withArgs(messageMatch, expectedIntent.attributes.client).calledOnce.should.be.true()

delete global.window
})

it('should not be called twice', async function () {
const windowMock = mockWindow()

Expand Down

0 comments on commit 01bf933

Please sign in to comment.