From 5d59670ac35851a89ed8081cbcaaf08422ad55e0 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Fri, 22 May 2020 14:44:37 +1200 Subject: [PATCH] Fixed browser URL syncronization with embeded iframe state no issue - Opted in to use explicit `hisotry.replaceState` and setting iframe's `src` using assignment instead of tracking it through computed property. This allows for tighter control over when iframe's history is updated which was causing problems when `src` was bound to computed property - Added billing page metadata. This way browser history records appear with nicer signature - Removed "update button" iframe and rewrote "global iframe" to not use modals. This allows to have single iframe on a page, which simplifies `postMessage` communication and preserve history inside iframe to be able to navigate it after closure - Added route change handler responding to BMA app route changes. Allows to sync browser URL visible to the user with active route in BMA iframe. The sync is based on `hisory.replaceState` method that makes sure singular history records are kept in the browser history - Added nested wildcard billing route. This is meant to catch all the nested routes inside of BMA iframe --- .../app/components/gh-billing-iframe.hbs | 2 +- .../admin/app/components/gh-billing-iframe.js | 22 ++++- .../admin/app/components/gh-billing-modal.hbs | 11 +++ .../admin/app/components/gh-billing-modal.js | 53 ++++++++++++ .../components/gh-billing-update-button.hbs | 2 - .../components/gh-billing-update-button.js | 39 +-------- ghost/admin/app/components/gh-nav-menu.hbs | 6 -- ghost/admin/app/components/gh-nav-menu.js | 4 +- ghost/admin/app/components/modal-billing.js | 12 --- ghost/admin/app/controllers/application.js | 2 + ghost/admin/app/router.js | 5 +- ghost/admin/app/routes/billing.js | 34 +++++++- ghost/admin/app/services/billing.js | 86 ++++++++++++++++--- ghost/admin/app/styles/layouts/billing.css | 38 +++++--- ghost/admin/app/templates/application.hbs | 4 + 15 files changed, 225 insertions(+), 95 deletions(-) create mode 100644 ghost/admin/app/components/gh-billing-modal.hbs create mode 100644 ghost/admin/app/components/gh-billing-modal.js delete mode 100644 ghost/admin/app/components/modal-billing.js diff --git a/ghost/admin/app/components/gh-billing-iframe.hbs b/ghost/admin/app/components/gh-billing-iframe.hbs index 63bae23650..9ba092062c 100644 --- a/ghost/admin/app/components/gh-billing-iframe.hbs +++ b/ghost/admin/app/components/gh-billing-iframe.hbs @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/ghost/admin/app/components/gh-billing-iframe.js b/ghost/admin/app/components/gh-billing-iframe.js index c723772082..96b6b65a2a 100644 --- a/ghost/admin/app/components/gh-billing-iframe.js +++ b/ghost/admin/app/components/gh-billing-iframe.js @@ -7,19 +7,35 @@ export default Component.extend({ ghostPaths: service(), ajax: service(), - didRender() { - let iframe = this.element.querySelector('#billing-frame'); + didInsertElement() { + let fetchingSubscription = false; + this.billing.getBillingIframe().src = this.billing.getIframeURL(); + window.addEventListener('message', (event) => { if (event && event.data && event.data.request === 'token') { const ghostIdentityUrl = this.get('ghostPaths.url').api('identities'); this.ajax.request(ghostIdentityUrl).then((response) => { const token = response && response.identities && response.identities[0] && response.identities[0].token; - iframe.contentWindow.postMessage({ + this.billing.getBillingIframe().contentWindow.postMessage({ request: 'token', response: token }, '*'); }); + + // NOTE: the handler is placed here to avoid additional logic to check if iframe has loaded + // receiving a 'token' request is an indication that page is ready + if (!fetchingSubscription && !this.billing.get('subscription')) { + fetchingSubscription = true; + this.billing.getBillingIframe().contentWindow.postMessage({ + query: 'getSubscription', + response: 'subscription' + }, '*'); + } + } + + if (event && event.data && event.data.subscription) { + this.billing.set('subscription', event.data.subscription); } }); } diff --git a/ghost/admin/app/components/gh-billing-modal.hbs b/ghost/admin/app/components/gh-billing-modal.hbs new file mode 100644 index 0000000000..3535c4214e --- /dev/null +++ b/ghost/admin/app/components/gh-billing-modal.hbs @@ -0,0 +1,11 @@ +
+
+
+ +
+ + +
+
\ No newline at end of file diff --git a/ghost/admin/app/components/gh-billing-modal.js b/ghost/admin/app/components/gh-billing-modal.js new file mode 100644 index 0000000000..4260689630 --- /dev/null +++ b/ghost/admin/app/components/gh-billing-modal.js @@ -0,0 +1,53 @@ +/* global key */ +import Component from '@ember/component'; +import {computed} from '@ember/object'; +import {run} from '@ember/runloop'; +import {inject as service} from '@ember/service'; + +export default Component.extend({ + billing: service(), + + visibilityClass: computed('billing.billingWindowOpen', function () { + return this.billing.get('billingWindowOpen') ? 'gh-billing' : 'gh-billing closed'; + }), + + didInsertElement() { + this._super(...arguments); + this._setupShortcuts(); + }, + + willDestroyElement() { + this._super(...arguments); + this._removeShortcuts(); + }, + + actions: { + closeModal() { + this.billing.closeBillingWindow(); + } + }, + + _setupShortcuts() { + run(function () { + document.activeElement.blur(); + }); + + this._previousKeymasterScope = key.getScope(); + + key('enter', 'modal', () => { + this.send('confirm'); + }); + + key('escape', 'modal', () => { + this.send('closeModal'); + }); + + key.setScope('modal'); + }, + + _removeShortcuts() { + key.unbind('enter', 'modal'); + key.unbind('escape', 'modal'); + key.setScope(this._previousKeymasterScope); + } +}); diff --git a/ghost/admin/app/components/gh-billing-update-button.hbs b/ghost/admin/app/components/gh-billing-update-button.hbs index db0a50d57b..12fbba4c5a 100644 --- a/ghost/admin/app/components/gh-billing-update-button.hbs +++ b/ghost/admin/app/components/gh-billing-update-button.hbs @@ -1,5 +1,3 @@ - - {{#if this.showUpgradeButton}} {{/if}} diff --git a/ghost/admin/app/components/gh-billing-update-button.js b/ghost/admin/app/components/gh-billing-update-button.js index e48d4d5a1c..7000077222 100644 --- a/ghost/admin/app/components/gh-billing-update-button.js +++ b/ghost/admin/app/components/gh-billing-update-button.js @@ -3,6 +3,7 @@ import {computed} from '@ember/object'; import {inject as service} from '@ember/service'; export default Component.extend({ + router: service(), config: service(), ghostPaths: service(), ajax: service(), @@ -10,45 +11,11 @@ export default Component.extend({ subscription: null, - showUpgradeButton: computed.equal('subscription.status', 'trialing'), - - didRender() { - let iframe = this.element.querySelector('#billing-frame-global'); - let fetchingSubscription = false; - - window.addEventListener('message', (event) => { - if (event && event.data && event.data.request === 'token') { - const ghostIdentityUrl = this.get('ghostPaths.url').api('identities'); - - this.ajax.request(ghostIdentityUrl).then((response) => { - const token = response && response.identities && response.identities[0] && response.identities[0].token; - iframe.contentWindow.postMessage({ - request: 'token', - response: token - }, '*'); - }); - - // NOTE: the handler is placed here to avoid additional logic to check if iframe has loaded - // receiving a 'token' request is an indication that page is ready - if (!fetchingSubscription && !this.get('subscription')) { - fetchingSubscription = true; - iframe.contentWindow.postMessage({ - query: 'getSubscription', - response: 'subscription' - }, '*'); - } - } - - if (event && event.data && event.data.subscription) { - this.set('subscription', event.data.subscription); - } - }); - }, + showUpgradeButton: computed.equal('billing.subscription.status', 'trialing'), actions: { openBilling() { - this.billing.set('upgrade', true); - this.billing.toggleProperty('billingWindowOpen'); + this.billing.openBillingWindow(this.router.currentURL, '/billing/plans'); } } }); diff --git a/ghost/admin/app/components/gh-nav-menu.hbs b/ghost/admin/app/components/gh-nav-menu.hbs index 66e05e67ae..e3fb623868 100644 --- a/ghost/admin/app/components/gh-nav-menu.hbs +++ b/ghost/admin/app/components/gh-nav-menu.hbs @@ -14,12 +14,6 @@ @modifier="action wide" /> {{/if}} -{{#if this.showBillingModal}} - -{{/if}} -
    diff --git a/ghost/admin/app/components/gh-nav-menu.js b/ghost/admin/app/components/gh-nav-menu.js index 7dc3a4b64a..bc7949effe 100644 --- a/ghost/admin/app/components/gh-nav-menu.js +++ b/ghost/admin/app/components/gh-nav-menu.js @@ -38,7 +38,6 @@ export default Component.extend(ShortcutsMixin, { showMenuExtension: and('config.clientExtensions.menu', 'session.user.isOwner'), showDropdownExtension: and('config.clientExtensions.dropdown', 'session.user.isOwner'), showScriptExtension: and('config.clientExtensions.script', 'session.user.isOwner'), - showBillingModal: computed.reads('billing.billingWindowOpen'), showBilling: computed.reads('config.billingUrl'), init() { @@ -81,8 +80,7 @@ export default Component.extend(ShortcutsMixin, { this.toggleProperty('showSearchModal'); }, toggleBillingModal() { - this.billing.set('upgrade', false); - this.billing.toggleProperty('billingWindowOpen'); + this.billing.openBillingWindow(this.router.currentURL); } }, diff --git a/ghost/admin/app/components/modal-billing.js b/ghost/admin/app/components/modal-billing.js deleted file mode 100644 index 62de5c250d..0000000000 --- a/ghost/admin/app/components/modal-billing.js +++ /dev/null @@ -1,12 +0,0 @@ -import ModalComponent from 'ghost-admin/components/modal-base'; -import {inject as service} from '@ember/service'; - -export default ModalComponent.extend({ - billing: service(), - - actions: { - closeModal() { - this.billing.closeBillingWindow(); - } - } -}); diff --git a/ghost/admin/app/controllers/application.js b/ghost/admin/app/controllers/application.js index accd03a30e..109d7b8faf 100644 --- a/ghost/admin/app/controllers/application.js +++ b/ghost/admin/app/controllers/application.js @@ -5,12 +5,14 @@ import {inject as service} from '@ember/service'; export default Controller.extend({ customViews: service(), + config: service(), dropdown: service(), router: service(), session: service(), settings: service(), ui: service(), + showBilling: computed.reads('config.billingUrl'), showNavMenu: computed('router.currentRouteName', 'session.{isAuthenticated,user.isFulfilled}', 'ui.isFullScreen', function () { let {router, session, ui} = this; diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 12bdbaa640..67eaa40cf6 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -22,7 +22,10 @@ Router.map(function () { this.route('reset', {path: '/reset/:token'}); this.route('about'); this.route('site'); - this.route('billing'); + + this.route('billing', function () { + this.route('billing-sub', {path: '/*sub'}); + }); this.route('posts'); this.route('pages'); diff --git a/ghost/admin/app/routes/billing.js b/ghost/admin/app/routes/billing.js index 5ff4df978a..c63ec15c62 100644 --- a/ghost/admin/app/routes/billing.js +++ b/ghost/admin/app/routes/billing.js @@ -8,15 +8,41 @@ export default Route.extend({ action: {refreshModel: true} }, + beforeModel(transition) { + this.billing.set('previousTransition', transition); + }, + model(params) { if (params.action) { this.billing.set('action', params.action); } - this.billing.set('billingWindowOpen', true); + this.billing.setBillingWindowOpen(true); + }, - // NOTE: if this route is ever triggered it was opened through external link because - // the route has no underlying templates to render we redirect to root route - this.transitionTo('/'); + actions: { + willTransition(transition) { + let isBillingTransition = false; + + if (transition) { + let destinationUrl = (typeof transition.to === 'string') + ? transition.to + : (transition.intent + ? transition.intent.url + : ''); + + if (destinationUrl.includes('/billing')) { + isBillingTransition = true; + } + } + + this.billing.setBillingWindowOpen(isBillingTransition); + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'Billing' + }; } }); diff --git a/ghost/admin/app/services/billing.js b/ghost/admin/app/services/billing.js index 94fb98ab48..a2d285a0a8 100644 --- a/ghost/admin/app/services/billing.js +++ b/ghost/admin/app/services/billing.js @@ -1,31 +1,91 @@ import Service from '@ember/service'; -import {computed} from '@ember/object'; import {inject as service} from '@ember/service'; export default Service.extend({ + router: service(), config: service(), ghostPaths: service(), + billingRouteRoot: '#/billing', billingWindowOpen: false, - upgrade: false, - action: null, + subscription: null, + previousRoute: null, - closeBillingWindow() { - this.set('billingWindowOpen', false); - this.set('action', null); + init() { + this._super(...arguments); + + if (this.config.get('billingUrl')) { + window.addEventListener('message', (event) => { + if (event && event.data && event.data.route) { + this.handleRouteChangeInIframe(event.data.route); + } + }); + } }, - endpoint: computed('config.billingUrl', 'billingWindowOpen', 'action', function () { + handleRouteChangeInIframe(destinationRoute) { + if (this.get('billingWindowOpen')) { + let billingRoute = this.get('billingRouteRoot'); + + if (destinationRoute !== '/') { + billingRoute += destinationRoute; + } + + if (window.location.hash !== billingRoute) { + window.history.replaceState(window.history.state, '', billingRoute); + } + } + }, + + getIframeURL() { let url = this.config.get('billingUrl'); - if (this.get('upgrade')) { - url = this.ghostPaths.url.join(url, 'plans'); - } + if (window.location.hash && window.location.hash.includes(this.get('billingRouteRoot'))) { + let destinationRoute = window.location.hash.replace(this.get('billingRouteRoot'), ''); - if (this.get('action')) { - url += `?action=${this.get('action')}`; + if (destinationRoute) { + url += destinationRoute; + } } return url; - }) + }, + + // Controls billing window modal visibility and sync of the URL visible in browser + // and the URL opened on the iframe. It is responsible to non user triggered iframe opening, + // for example: by entering "/billing" route in the URL or using history navigation (back and forward) + setBillingWindowOpen(value) { + let billingIframe = this.getBillingIframe(); + + if (billingIframe && value) { + billingIframe.contentWindow.location.replace(this.getIframeURL()); + } + + this.set('billingWindowOpen', value); + }, + + // Controls navigation to billing window modal which is triggered from the application UI. + // For example: pressing "View Billing" link in navigation menu. It's main side effect is + // remembering the route from which the action has been triggered - "previousRoute" so it + // could be reused when closing billing window + openBillingWindow(currentRoute, childRoute) { + this.set('previousRoute', currentRoute); + + // Ensures correct "getIframeURL" calculation when syncing iframe location + // in setBillingWindowOpen + window.location.hash = childRoute || '/billing'; + + this.router.transitionTo(childRoute || '/billing'); + }, + + closeBillingWindow() { + this.set('billingWindowOpen', false); + + let transitionRoute = this.get('previousRoute') || '/'; + this.router.transitionTo(transitionRoute); + }, + + getBillingIframe() { + return document.getElementById('billing-frame'); + } }); diff --git a/ghost/admin/app/styles/layouts/billing.css b/ghost/admin/app/styles/layouts/billing.css index e9588beb54..f6928ee86f 100644 --- a/ghost/admin/app/styles/layouts/billing.css +++ b/ghost/admin/app/styles/layouts/billing.css @@ -1,19 +1,36 @@ -.fullscreen-modal-billing { - margin: 0; - max-width: 100%; +.gh-billing { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + z-index: 9999; + background: var(--main-bg-color); } -.fullscreen-modal-billing .modal-content { +.gh-billing-container { position: relative; height: 100%; - padding: 0; + width: 100%; } -.fullscreen-modal-billing .modal-body { +.gh-billing.closed { + display: none; +} + +.gh-billing .close { + position: absolute; + top: 19px; + right: 19px; + z-index: 9999; margin: 0; + padding: 0; + width: 16px; + height: 16px; + border: none; } -.fullscreen-modal-billing .billing-frame { +.gh-billing .billing-frame { position: absolute; top: 0; right: 0; @@ -35,10 +52,3 @@ transition: all 0.2s ease-in-out; top: 25px; } - -#billing-frame-global { - visibility: hidden; - height:0; - width:0; - border:none; -} diff --git a/ghost/admin/app/templates/application.hbs b/ghost/admin/app/templates/application.hbs index 0d52cfcdb9..a88c98d7d1 100644 --- a/ghost/admin/app/templates/application.hbs +++ b/ghost/admin/app/templates/application.hbs @@ -28,6 +28,10 @@ @modifier="action narrow" /> {{/if}} + + {{#if this.showBilling}} + + {{/if}} \ No newline at end of file