Skip to content

Commit

Permalink
launch LTI tool in new tab or popup if requested
Browse files Browse the repository at this point in the history
refs INTEROP-6636
flag=none

* open new tab/window or a popup window depending on the message
the tool sends
* include new data from the tool like placement (so that deep linking
launches work), type of window, and width/height of popup
* keep a reference to opened window and close it when the dialog that
opened it also closes
* send postMessages to window.opener if present, instead of
window.parent - helps with tabs/popups opened by the page
* include the canvas placement in the LTI 1.3 launch token, in the
custom claims section - this is required so that the tool can pass it
in the full window launch request, so that Canvas knows whether it
should use a normal launch or a deep linking launch

test plan:
* make sure associated commits for the test tool and safari gem are
checked out and set up, see the test plan for those
* in safari, create a new assignment of type external tool
* choose the test tool from the list of tools
* it should tell you that it needs to open in a popup
* click the button and a popup should open (note: you should not have
Safari in fullscreen mode which is a bummer)
* you should click submit on that form to return a resource link
* the popup should close and the external tool dialog should have a
url in it, like normal

Change-Id: I46dc47cca7f26140041b6a638a8056a7cc0843db
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/261771
Reviewed-by: Evan Battaglia <[email protected]>
Tested-by: Service Cloud Jenkins <[email protected]>
QA-Review: Wagner Goncalves <[email protected]>
Product-Review: Xander Moffatt <[email protected]>
  • Loading branch information
xandroxygen committed Apr 5, 2021
1 parent bbe345f commit 811a119
Show file tree
Hide file tree
Showing 7 changed files with 361 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const {lti_response_messages} = ENV
const {service_id} = ENV
const data = ENV.retrieved_data
const callback = ENV.service
let parentWindow = window.parent
let parentWindow = window.parent || window.opener

ExternalContentSuccess.getIFrameSrc = function() {
let src = parentWindow
Expand Down
5 changes: 5 additions & 0 deletions app/jsx/deep_linking/ContentItemProcessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import LinkContentItem from './models/LinkContentItem'
import ResourceLinkContentItem from './models/ResourceLinkContentItem'
import ImageContentItem from './models/ImageContentItem'
import HtmlFragmentContentItem from './models/HtmlFragmentContentItem'
import {ltiState} from '../../../public/javascripts/lti/post_message/handleLtiPostMessage'

export default class ContentItemProcessor {
constructor(contentItems, messages, logs, ltiEndpoint, processHandler) {
Expand Down Expand Up @@ -69,6 +70,10 @@ export default class ContentItemProcessor {
}

process() {
// close any new tabs/popups that were created by a full window launch
if (ltiState?.fullWindowProxy) {
ltiState.fullWindowProxy.close()
}
return this.processHandler(...arguments)
}

Expand Down
5 changes: 5 additions & 0 deletions app/jsx/deep_linking/DeepLinkingResponse.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export class RetrievingContent extends React.Component {
}

parentWindow() {
// respect windows created using window.open() as well as windows in iframes
if (window.opener) {
return window.opener
}

let parentWindow = window.parent
while (parentWindow && parentWindow.parent !== window.parent) {
parentWindow = parentWindow.parent
Expand Down
207 changes: 207 additions & 0 deletions doc/api/lti_window_post_message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
Using window.postMessage in LTI Tools
=====================================

Canvas listens for events sent through the `window.postMessage` Javascript
API (docs <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage" target="_blank">here</a>)
from LTI tools and other children rendered in iframes or opened in new tabs/windows. Tools
can send various types of events to resize windows, launch in new windows, or other
functionality. Note that this is not part of the LTI specification, and is Canvas-specific.

The data sent to `window.postMessage` can be of any type, and each message type looks for different
data. Most data is sent as an object, with either a `messageType` or `subject` property.

Some of these message handlers require the presence of a `token`, which identifies the tool launch.
This token is present in the launch as a custom variable, `$com.instructure.PostMessageToken`, and
should be passed in postMessage calls if it's present.

If the LTI tool is launched in a iframe, as is most common, then postMessages should be sent to
`window.parent`. However, if the tool is launched in a new tab, window, or popup, then postMessages
should be directed to `window.opener`. The examples will use `window.parent`.

# Message Types

## requestFullWindowLaunch

Launches the tool that sent the event in a full-window context (ie not inside a Canvas iframe).
Mainly used for Safari launches, since Safari disables setting cookies inside iframes.

**Required properties:**
- messageType: "requestFullWindowLaunch"
- data: either a string or an object
- if a string, a url for relaunching the tool
- if an object, has required sub-properties
- data.url: a url for relaunching the tool
- data.placement: the Canvas placement that the tool was launched in. Provided in the 1.3 id token
under the custom claim section (`https://www.instructure.com/placement`).

**Optional properties:**
- data.launchType: defaults to "same_window"
- "same_window": launches the tool in the same window, replacing Canvas entirely
- "new_window": launches the tool in a new tab/window, which depends on user preference
- "popup": launches the tool in a popup window
- data.launchOptions.width: for launchType: popup, defines the popup window's width. Defaults to 800.
- data.launchOptions.height: for launchType: popup, defines the popup window's height. Defaults to 600.

```js
window.parent.postMessage(
{
messageType: "requestFullWindowLaunch",
data: {
url: "https://example-tool.com/launch",
placement: "course_navigation",
launchType: "new_window",
launchOptions: {
width: 1000,
height: 800
}
}
},
"*"
)
```

## lti.resourceImported

Notifies the Canvas page holding the tool that a resource has finished importing.
Canvas will respond by reloading the page, if the tool was present in the external
apps tray. Used on wiki pages.

**Required properties:**
- messageType: "lti.resourceImported"

```js
window.parent.postMessage({ messageType: "lti.resourceImported" }, "*")
```

## lti.frameResize

Tells Canvas to change the height of the iframe containing the tool.

**Required properties:**
- subject: "lti.frameResize"
- height: integer, in px

**Optional properties:**
- token: postMessage token, discussed above.

```js
window.parent.postMessage(
{
subject: "lti.frameResize",
height: 400
},
"*"
)
```

## lti.fetchWindowSize

Sends a postMessage event back to the tool with details about the window size of
the tool's containing iframe.

**Required properties:**
- subject: "lti.fetchWindowSize"

Returning postMessage includes the following properties:
- subject: "lti.fetchWindowSize"
- height: height of the iframe
- width: width of the iframe
- offset: jquery.offset() of the iframe's wrapper
- scrollY: the number of px that the iframe is scrolled vertically

```js
window.parent.postMessage({ subject: "lti.fetchWindowSize" }, "*")
```

## lti.showModuleNavigation

Toggles the module navigation footer based on the message's content.

**Required properties:**
- subject: "lti.showModuleNavigation"
- show: Boolean, whether to show or hide the footer

```js
window.parent.postMessage(
{
subject: "lti.frameResize",
show: true
},
"*"
)
```

## lti.scrollToTop

Scrolls the iframe all the way to the top of its container.

**Required properties:**
- subject: "lti.scrollToTop"

```js
window.parent.postMessage({ subject: "lti.scrollToTop" }, "*")
```

## lti.setUnloadMessage

Sets a message to be shown in a browser dialog before page closes (ie
"Do you really want to leave this page?")

**Required properties:**
- subject: "lti.setUnloadMessage"
- message: The message to be shown in the dialog

```js
window.parent.postMessage(
{
subject: "lti.setUnloadMessage",
message: "Are you sure you want to leave this app?"
},
"*"
)
```

## lti.removeUnloadMessage

Clears any set message to be shown on page close.

Required properties
- subject: "lti.removeUnloadMessage"

```js
window.parent.postMessage({ subject: "lti.removeUnloadMessage" }, "*")
```

## lti.screenReaderAlert

Shows an alert for screen readers.

**Required properties:**
- subject: "lti.screenReaderAlert"
- body: The contents of the alert.

```js
window.parent.postMessage(
{
subject: "lti.screenReaderAlert",
body: "An alert just for screen readers"
},
"*"
)
```

## lti.enableScrollEvents

Sends a debounced postMessage event to the tool every time its containing
iframe is scrolled.

**Required properties:**
- subject: "lti.enableScrollEvents"

Returning postMessage includes the following properties:
- subject: "lti.scroll"
- scrollY: the number of px that the iframe is scrolled vertically

```js
window.parent.postMessage({ subject: "lti.enableScrollEvents" }, "*")
```
1 change: 1 addition & 0 deletions lib/lti/messages/jwt_message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def generate_post_payload_message(validate_launch: true)
add_custom_params_claims! if include_claims?(:custom_params)
add_names_and_roles_service_claims! if include_names_and_roles_service_claims?
add_lti11_legacy_user_id!
add_extension("placement", @opts[:resource_type])

@expander.expand_variables!(@message.extensions)
@message.validate! if validate_launch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,90 @@ import handler from '../requestFullWindowLaunch'

describe('requestFullWindowLaunch', () => {
const {assign} = window.location
const open = window.open

beforeEach(() => {
delete window.open
delete window.location
window.location = {assign: jest.fn(), origin: 'http://localhost'}
window.open = jest.fn()
ENV.context_asset_string = 'account_1'
})

afterEach(() => {
window.location.assign = assign
window.open = open
})

it('opens new window on requestFullWindowLaunch', () => {
ENV.context_asset_string = 'account_1'
handler('http://localhost/test')
expect(window.location.assign).toHaveBeenCalled()
describe('with string provided', () => {
it('uses launch type same_window', () => {
handler('http://localhost/test')
expect(window.location.assign).toHaveBeenCalled()
})

it('pulls out client_id if provided', () => {
handler('http://localhost/test?client_id=hello')
const launch_url = new URL(window.location.assign.mock.calls[0][0])
expect(launch_url.searchParams.get('client_id')).toEqual('hello')
})
})

it('pulls out client_id if provided', () => {
ENV.context_asset_string = 'account_1'
handler('http://localhost/test?client_id=hello')
const launch_url = new URL(window.location.assign.mock.calls[0][0])
expect(launch_url.searchParams.get('client_id')).toEqual('hello')
describe('with object provided', () => {
it('must contain a `url` property', () => {
expect(() => handler({foo: 'bar'})).toThrow('message must contain a `url` property')
})

it('uses launch type same_window by default', () => {
handler({url: 'http://localhost/test'})
expect(window.location.assign).toHaveBeenCalled()
})

it('opens launch type new_window in a new tab', () => {
handler({url: 'http://localhost/test', launchType: 'new_window'})
expect(window.open).toHaveBeenCalled()
})

it('opens launch type popup in a popup window', () => {
handler({url: 'http://localhost/test', launchType: 'popup'})
expect(window.open).toHaveBeenCalledWith(
expect.any(String),
'popupLaunch',
expect.stringMatching(/toolbar/)
)
})

it('errors on unknown launch type', () => {
expect(() => handler({url: 'http://localhost/test', launchType: 'fake'})).toThrow(
"unknown launchType, must be 'popup', 'new_window', 'same_window'"
)
})

it('uses placement to add to launch url', () => {
handler({url: 'http://localhost/test', placement: 'course_navigation'})
expect(window.location.assign).toHaveBeenCalledWith(
expect.stringContaining('&placement=course_navigation')
)
})

it('uses launchOptions to add width and height to popup', () => {
handler({
url: 'http://localhost/test',
launchType: 'popup',
launchOptions: {width: 420, height: 400}
})
expect(window.open).toHaveBeenCalledWith(
expect.any(String),
'popupLaunch',
expect.stringContaining('width=420,height=400')
)
})
})

describe('with anything other than a string or object provided', () => {
it('errors', () => {
expect(() => handler(['foo', 'bar'])).toThrow(
'message contents must either be a string or an object'
)
})
})
})
Loading

0 comments on commit 811a119

Please sign in to comment.