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
This commit is contained in:
Nazar Gargol 2020-05-22 14:44:37 +12:00
parent 5a1b3d90fb
commit 5d59670ac3
15 changed files with 225 additions and 95 deletions

View File

@ -1 +1 @@
<iframe id="billing-frame" class="billing-frame" src={{this.billing.endpoint}} frameborder="0" allowtransparency="true"></iframe>
<iframe id="billing-frame" class="billing-frame" frameborder="0"></iframe>

View File

@ -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);
}
});
}

View File

@ -0,0 +1,11 @@
<div class="{{this.visibilityClass}}">
<div class="gh-billing-container">
<div class="gh-billing-close">
<button class="close" href title="Close" {{on "click" (action "closeModal")}}>
{{svg-jar "close"}}
</button>
</div>
<GhBillingIframe></GhBillingIframe>
</div>
</div>

View File

@ -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);
}
});

View File

@ -1,5 +1,3 @@
<iframe id="billing-frame-global" src={{this.billing.endpoint}}></iframe>
{{#if this.showUpgradeButton}}
<button class="gh-btn gh-btn-green" {{action "openBilling"}}><span>Upgrade</span></button>
{{/if}}

View File

@ -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');
}
}
});

View File

@ -14,12 +14,6 @@
@modifier="action wide" />
{{/if}}
{{#if this.showBillingModal}}
<GhFullscreenModal @modal="billing"
@close={{action "toggleBillingModal"}}
@modifier="full-overlay billing" />
{{/if}}
<section class="gh-nav-body">
<div class="gh-nav-top">
<ul class="gh-nav-list gh-nav-main">

View File

@ -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);
}
},

View File

@ -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();
}
}
});

View File

@ -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;

View File

@ -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');

View File

@ -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'
};
}
});

View File

@ -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');
}
});

View File

@ -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;
}

View File

@ -28,6 +28,10 @@
@modifier="action narrow"
/>
{{/if}}
{{#if this.showBilling}}
<GhBillingModal />
{{/if}}
</GhApp>
<EmberLoadRemover />