From a247b9da69d3d1a74f4f0714dbefc1d1b5b073b1 Mon Sep 17 00:00:00 2001 From: Jeremy Press Date: Tue, 16 May 2017 15:36:29 -0700 Subject: [PATCH] New: platform excel online fork (#101) * New: platform excel online fork * Update: Updating tests --- src/lib/viewers/office/OfficeViewer.js | 138 ++++++++++++-- .../office/__tests__/OfficeViewer-test.js | 169 +++++++++++++++--- 2 files changed, 272 insertions(+), 35 deletions(-) diff --git a/src/lib/viewers/office/OfficeViewer.js b/src/lib/viewers/office/OfficeViewer.js index 30d99a04b..020a6d7cc 100644 --- a/src/lib/viewers/office/OfficeViewer.js +++ b/src/lib/viewers/office/OfficeViewer.js @@ -11,6 +11,11 @@ const LOAD_TIMEOUT_MS = 120000; const SAFARI_PRINT_TIMEOUT_MS = 1000; // Wait 1s before trying to print const PRINT_DIALOG_TIMEOUT_MS = 500; +// @TODO(jpress): replace with discovery and XML parsing once Microsoft allows CORS +const EXCEL_ONLINE_EMBED_URL = 'https://excel.officeapps.live.com/x/_layouts/xlembed.aspx'; +const OFFICE_ONLINE_IFRAME_NAME = 'office-online-iframe'; +const MESSAGE_HOST_READY = 'Host_PostmessageReady'; + @autobind class OfficeViewer extends BaseViewer { @@ -24,6 +29,9 @@ class OfficeViewer extends BaseViewer { setup() { // Call super() first to set up common layout super.setup(); + // Set to false only in the WebApp, everywhere else we want to avoid hitting a runmode. + // This flag will be removed once we run the entire integration through the client. + this.platformSetup = this.options.viewers.Office ? !!this.options.viewers.Office.shouldUsePlatformSetup : true; this.setupIframe(); this.initPrint(); this.setupPDFUrl(); @@ -147,31 +155,141 @@ class OfficeViewer extends BaseViewer { * @return {void} */ setupIframe() { - this.iframeEl = this.containerEl.appendChild(document.createElement('iframe')); - this.iframeEl.setAttribute('width', '100%'); - this.iframeEl.setAttribute('height', '100%'); - this.iframeEl.setAttribute('frameborder', 0); - this.iframeEl.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups'); + const { appHost, apiHost, file, sharedLink, location: { locale } } = this.options; + const iframeEl = this.createIframeElement(); + this.containerEl.appendChild(iframeEl); + + if (this.platformSetup) { + const formEl = this.createFormElement(apiHost, file.id, sharedLink, locale); + // Submitting the form securely passes a Preview access token to + // Microsoft so they can hit our WOPI endpoints. + formEl.submit(); + + // Tell Office Online that we are ready to receive messages + iframeEl.contentWindow.postMessage(MESSAGE_HOST_READY, window.location.origin); + } else { + iframeEl.src = this.setupRunmodeURL(appHost, file.id, sharedLink); + } + } - const { appHost, file, sharedLink } = this.options; + /** + * Sets up the runmode URL that fills a wrapper iframe around the Office Online iframe. + * + * @private + * @param {string} appHost - Application host + * @param {string} fileId - File ID + * @param {string} sharedLink - Shared link which may be a vanity URL + * @return {string} Runmode URL + */ + setupRunmodeURL(appHost, fileId, sharedLink) { + // @TODO(jpress): Combine with setupWopiSrc Logic when removing the platform fork let src = `${appHost}/integrations/officeonline/openExcelOnlinePreviewer`; + if (sharedLink) { // Find the shared or vanity name const sharedName = sharedLink.split('/s/')[1]; if (sharedName) { - src += `?s=${sharedName}&fileId=${file.id}`; + src = `${src}?s=${sharedName}&fileId=${fileId}`; } else { const tempAnchor = document.createElement('a'); tempAnchor.href = sharedLink; const vanitySubdomain = tempAnchor.hostname.split('.')[0]; const vanityName = tempAnchor.pathname.split('/v/')[1]; - src += `?v=${vanityName}&vanity_subdomain=${vanitySubdomain}&fileId=${file.id}`; + src = `${src}?v=${vanityName}&vanity_subdomain=${vanitySubdomain}&fileId=${fileId}`; } } else { - src += `?fileId=${file.id}`; + src = `${src}?fileId=${fileId}`; + } + + return src; + } + + /** + * Sets up the WOPI src that is used in the Office Online action URL. + * + * @private + * @param {string} apiHost - API Host + * @param {string} fileId - File ID + * @param {string} sharedLink - Shared link which may be a vanity URL + * @return {string} WOPI src URL + */ + setupWOPISrc(apiHost, fileId, sharedLink) { + // @TODO(jpress): add support for vanity URLs once WOPI API is updated + let wopiSrc = `${apiHost}/wopi/files/`; + + if (sharedLink) { + const sharedName = sharedLink.split('/s/')[1]; + if (sharedName) { + wopiSrc = `${wopiSrc}s_${sharedName}_f_`; + } + } + + return `${wopiSrc}${fileId}`; + } + + /** + * Creates the iframe element used for both the platform and webapp setup. + * + * @private + * @return {HTMLElement} Iframe element + */ + createIframeElement() { + const iframeEl = document.createElement('iframe'); + iframeEl.setAttribute('width', '100%'); + iframeEl.setAttribute('height', '100%'); + iframeEl.setAttribute('frameborder', 0); + iframeEl.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups'); + + if (this.platformSetup) { + iframeEl.setAttribute('allowfullscreen', 'true'); + iframeEl.name = OFFICE_ONLINE_IFRAME_NAME; + iframeEl.id = OFFICE_ONLINE_IFRAME_NAME; } - this.iframeEl.src = src; + return iframeEl; + } + + /** + * Sets up the form that will be posted to the Office Online viewer. The form + * includes an access token, the token's time to live, and an action URL that + * Micrsoft uses to hit our WOPI endpoint. + * + * @private + * @param {string} apiHost - API host + * @param {string} fileId - File ID + * @param {string} sharedLink - Shared link which may be a vanity URL + * @param {string} locale - Locale + * @return {HTMLElement} Form element + */ + createFormElement(apiHost, fileId, sharedLink, locale) { + // Setting the action URL + const WOPISrc = this.setupWOPISrc(apiHost, fileId, sharedLink); + const origin = { origin: window.location.origin }; + const formEl = this.containerEl.appendChild(document.createElement('form')); + // @TODO(jpress): add suport for iframe performance logging via App_LoadingStatus message + // We pass our origin in the sessionContext so that Microsoft will pass + // this to the checkFileInfo endpoint. From their we can set it as the + // origin for iframe postMessage communications. + formEl.setAttribute('action', `${EXCEL_ONLINE_EMBED_URL}?ui=${locale}&rs=${locale}&WOPISrc=${WOPISrc}&sc=${JSON.stringify(origin)}`); + formEl.setAttribute('method', 'POST'); + formEl.setAttribute('target', OFFICE_ONLINE_IFRAME_NAME); + + // Setting the token + const tokenInput = document.createElement('input'); + tokenInput.setAttribute('name', 'access_token'); + tokenInput.setAttribute('value', `${this.options.token}`); + tokenInput.setAttribute('type', 'hidden'); + + // Calculating and setting the time to live + const ttlInput = document.createElement('input'); + ttlInput.setAttribute('name', 'access_token_TTL'); + // Setting to 0 disables refresh messages from Microsoft + ttlInput.setAttribute('value', 0); + ttlInput.setAttribute('type', 'hidden'); + + formEl.appendChild(tokenInput); + formEl.appendChild(ttlInput); + return formEl; } /** diff --git a/src/lib/viewers/office/__tests__/OfficeViewer-test.js b/src/lib/viewers/office/__tests__/OfficeViewer-test.js index ff74238d1..9beb0d615 100644 --- a/src/lib/viewers/office/__tests__/OfficeViewer-test.js +++ b/src/lib/viewers/office/__tests__/OfficeViewer-test.js @@ -8,6 +8,9 @@ import { ICON_PRINT_CHECKMARK } from '../../../icons/icons'; const PRINT_TIMEOUT_MS = 1000; // Wait 1s before trying to print const PRINT_DIALOG_TIMEOUT_MS = 500; +const OFFICE_ONLINE_IFRAME_NAME = 'office-online-iframe'; +const EXCEL_ONLINE_URL = 'https://excel.officeapps.live.com/x/_layouts/xlembed.aspx'; + const sandbox = sinon.sandbox.create(); let office; let stubs = {}; @@ -28,7 +31,18 @@ describe('lib/viewers/office/OfficeViewer', () => { container: containerEl, file: { id: '123' - } + }, + viewers: { + Office: { + shouldUsePlatformSetup: false + } + }, + location: { + locale: 'en-US' + }, + appHost: 'app.box.com', + apiHost: 'app.box.com', + token: 'token' }); stubs = { setupPDFUrl: sandbox.stub(office, 'setupPDFUrl') @@ -48,24 +62,34 @@ describe('lib/viewers/office/OfficeViewer', () => { if (office && typeof office.destroy === 'function') { office.destroy(); } + + stubs = null; office = null; }); describe('setup()', () => { + beforeEach(() => { + stubs.setupIframe = sandbox.stub(office, 'setupIframe'); + stubs.initPrint = sandbox.stub(office, 'initPrint'); + }); + it('should set up the Office viewer', () => { - const testStubs = { - setupIframe: sandbox.stub(office, 'setupIframe'), - initPrint: sandbox.stub(office, 'initPrint') - }; office.setup(); - expect(testStubs.setupIframe).to.be.called; - expect(testStubs.initPrint).to.be.called; + expect(stubs.setupIframe).to.be.called; + expect(stubs.initPrint).to.be.called; expect(stubs.setupPDFUrl).to.be.called; }); - }); - beforeEach(() => { - office.setup(); + it('should not use the platform setup if the option is passed in', () => { + office.setup(); + expect(office.platformSetup).to.be.false; + }); + + it('should use the platform setup if no option is passed in', () => { + office.options.viewers = {}; + office.setup(); + expect(office.platformSetup).to.be.true; + }); }); describe('destroy()', () => { @@ -96,38 +120,133 @@ describe('lib/viewers/office/OfficeViewer', () => { describe('setupIframe()', () => { beforeEach(() => { - office.options.appHost = 'https://app.box.com'; + stubs.createIframeElement = sandbox.spy(office, 'createIframeElement'); + stubs.form = { + submit: sandbox.stub() + }; + + stubs.createFormElement = sandbox.stub(office, 'createFormElement').returns(stubs.form); + stubs.setupRunmodeURL = sandbox.stub(office, 'setupRunmodeURL').returns('src'); }); - it('should initialize iframe element and set relevant attributes', () => { - expect(office.iframeEl.width).to.equal('100%'); - expect(office.iframeEl.height).to.equal('100%'); - expect(office.iframeEl.frameBorder).to.equal('0'); - expect(office.iframeEl.sandbox.toString()).to.equal('allow-scripts allow-same-origin allow-forms allow-popups'); - expect(office.loadTimeout).to.equal(120000); + it('should create the iframeEl', () => { + office.setupIframe(); + + expect(stubs.createIframeElement).to.be.called; }); - it('should load a xlsx file and set the file ID in src url on load event when the file is not a shared link', () => { + it('should finish setting up the iframe if using platform setup', () => { + office.platformSetup = true; + office.setupIframe(); + + const iframeEl = office.containerEl.querySelector(`#${OFFICE_ONLINE_IFRAME_NAME}`); + expect(iframeEl.name).to.equal(OFFICE_ONLINE_IFRAME_NAME); + expect(iframeEl.id).to.equal(OFFICE_ONLINE_IFRAME_NAME); + }); + + it('should setup and submit the form if using the platform setup', () => { + office.platformSetup = true; office.setupIframe(); - expect(office.iframeEl.src).to.equal('https://app.box.com/integrations/officeonline/openExcelOnlinePreviewer?fileId=123'); + + expect(stubs.createFormElement).to.be.calledWith(office.options.appHost, office.options.file.id, office.options.sharedLink, office.options.location.locale); + expect(stubs.form.submit).to.be.called; + }); + + + it('should set the iframe source and sandbox attribute if not using the platform setup', () => { + office.setupIframe(); + + const iframeEl = office.containerEl.querySelector('iframe'); + expect(iframeEl.src.endsWith('src')).to.be.true; + }); + }); + describe('setupRunmodeURL()', () => { + it('should load a xlsx file and set the file ID in src url on load event when the file is not a shared link', () => { + const src = office.setupRunmodeURL(office.options.appHost, office.options.file.id, office.options.sharedLink); + expect(src).to.equal('app.box.com/integrations/officeonline/openExcelOnlinePreviewer?fileId=123'); }); it('should load a xlsx file and set the shared name in src url on load event when the file is a shared link', () => { office.options.sharedLink = 'https://app.box.com/s/abcd'; - office.setupIframe(); - expect(office.iframeEl.src).to.equal('https://app.box.com/integrations/officeonline/openExcelOnlinePreviewer?s=abcd&fileId=123'); + const src = office.setupRunmodeURL(office.options.appHost, office.options.file.id, office.options.sharedLink); + expect(src).to.equal('app.box.com/integrations/officeonline/openExcelOnlinePreviewer?s=abcd&fileId=123'); }); it('should load a xlsx file and set the vanity name in src url on load event when the file is a vanity url without a subdomain', () => { office.options.sharedLink = 'https://app.box.com/v/test'; - office.setupIframe(); - expect(office.iframeEl.src).to.equal('https://app.box.com/integrations/officeonline/openExcelOnlinePreviewer?v=test&vanity_subdomain=app&fileId=123'); + const src = office.setupRunmodeURL(office.options.appHost, office.options.file.id, office.options.sharedLink); + expect(src).to.equal('app.box.com/integrations/officeonline/openExcelOnlinePreviewer?v=test&vanity_subdomain=app&fileId=123'); }); it('should load a xlsx file and set the vanity name in src url on load event when the file is a vanity url with a subdomain', () => { office.options.sharedLink = 'https://cloud.app.box.com/v/test'; - office.setupIframe(); - expect(office.iframeEl.src).to.equal('https://app.box.com/integrations/officeonline/openExcelOnlinePreviewer?v=test&vanity_subdomain=cloud&fileId=123'); + const src = office.setupRunmodeURL(office.options.appHost, office.options.file.id, office.options.sharedLink); + expect(src).to.equal('app.box.com/integrations/officeonline/openExcelOnlinePreviewer?v=test&vanity_subdomain=cloud&fileId=123'); + }); + }); + + describe('setupWOPISrc()', () => { + it('should append the file ID if there is no shared link', () => { + const src = office.setupWOPISrc(office.options.apiHost, office.options.file.id, office.options.sharedLink); + expect(src).to.equal('app.box.com/wopi/files/123'); + }); + + it('should append the shared name and file ID if there is a shared link', () => { + office.options.sharedLink = 'https://app.box.com/s/abcd'; + const src = office.setupWOPISrc(office.options.apiHost, office.options.file.id, office.options.sharedLink); + expect(src).to.equal('app.box.com/wopi/files/s_abcd_f_123'); + }); + + it('should not append the shared name if there is a vanity link', () => { + office.options.sharedLink = 'https://app.box.com/v/abcd'; + const src = office.setupWOPISrc(office.options.apiHost, office.options.file.id, office.options.sharedLink); + expect(src.includes('s_')).to.be.false; + }); + }); + + describe('createIframeElement()', () => { + it('should initialize iframe element and set relevant attributes', () => { + const iframeEl = office.createIframeElement(); + + expect(iframeEl.width).to.equal('100%'); + expect(iframeEl.height).to.equal('100%'); + expect(iframeEl.frameBorder).to.equal('0'); + expect(iframeEl.getAttribute('sandbox')).to.equal('allow-scripts allow-same-origin allow-forms allow-popups'); + }); + + it('should allow fullscreen if using the platform setup', () => { + office.platformSetup = true; + const iframeEl = office.createIframeElement(); + + expect(iframeEl.getAttribute('allowfullscreen')).to.equal('true'); + }); + }); + + describe('createFormElement()', () => { + beforeEach(() => { + stubs.setupWOPISrc = sandbox.stub(office, 'setupWOPISrc').returns('src'); + stubs.sessionContext = JSON.stringify({ origin: window.location.origin }); + stubs.formEl = office.createFormElement(office.options.apiHost, office.options.file.id, office.options.sharedLink, office.options.location.locale); + }); + + it('should correctly set the action URL', () => { + expect(stubs.formEl.getAttribute('action')).to.equal(`${EXCEL_ONLINE_URL}?ui=${office.options.location.locale}&rs=${office.options.location.locale}&WOPISrc=src&sc=${stubs.sessionContext}`); + expect(stubs.formEl.getAttribute('method')).to.equal('POST'); + expect(stubs.formEl.getAttribute('target')).to.equal(OFFICE_ONLINE_IFRAME_NAME); + }); + + it('should correctly set the token', () => { + const tokenInputEl = stubs.formEl.querySelector('input[name="access_token"]'); + expect(tokenInputEl.getAttribute('name')).to.equal('access_token'); + expect(tokenInputEl.getAttribute('value')).to.equal('token'); + expect(tokenInputEl.getAttribute('type')).to.equal('hidden'); + }); + + it('should correctly set the token time to live', () => { + const tokenInputEl = stubs.formEl.querySelector('input[name="access_token_TTL"]'); + expect(tokenInputEl.getAttribute('name')).to.equal('access_token_TTL'); + expect(tokenInputEl.getAttribute('value')).to.not.equal(undefined); + expect(tokenInputEl.getAttribute('type')).to.equal('hidden'); }); });