Added Ghost Explore in Ghost as iframe app behind feature flag (#15495)

no issue

- Added Ghost Explore screen behind alpha flag
- Moved existing /explore route to /explore/connect which we'll redirect to for outside requests
- Added iframe communication with Ghost Explore App
This commit is contained in:
Aileen Booker 2022-10-07 14:32:54 +01:00 committed by GitHub
parent 7e3b41f643
commit c4188c1a9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 406 additions and 33 deletions

View File

@ -1,14 +1,14 @@
<section class="gh-dashboard-section gh-dashboard-explore-feed" {{did-insert this.load}}>
<article class="gh-dashboard-box">
<div class="gh-dashboard-resource-title">
<h4>Featured publications
<h4>Featured publications
{{#if this.meta.category_url}}
in <a class="gh-dasboard-explore-title-link" href={{this.meta.category_url}} target="_blank" rel="noopener noreferrer">{{this.meta.category}}</a>
{{/if}}
</h4>
<div>
<a href="https://ghost.org/explore/" target="_blank" rel="noopener noreferrer">Browse all</a>
<LinkTo @route="explore" class="gh-dashboard-explore-add">Add your site to Explore</LinkTo>
<LinkTo @route="explore.connect" class="gh-dashboard-explore-add">Add your site to Explore</LinkTo>
</div>
</div>
<div class="gh-dashboard-resource-body">

View File

@ -0,0 +1 @@
<iframe id="explore-frame" class="explore-frame" frameborder="0" title="Explore" ...attributes {{did-insert this.setup}}></iframe>

View File

@ -0,0 +1,50 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class GhExploreIframe extends Component {
@service explore;
@service router;
@service feature;
@action
setup() {
this.explore.getExploreIframe().src = this.explore.getIframeURL();
window.addEventListener('message', async (event) => {
if (event?.data) {
if (event.data?.request === 'apiUrl') {
this._handleUrlRequest();
}
if (event.data?.route) {
this._handleRouteUpdate(event.data);
}
if (event.data?.siteData) {
this._handleSiteDataUpdate(event.data);
}
}
});
}
// The iframe can send route updates to navigate to within Admin, as some routes
// have to be rendered within the iframe and others require to break out of it.
_handleRouteUpdate(data) {
const route = data.route;
this.explore.isIframeTransition = route?.includes('/explore');
this.explore.toggleExploreWindow(this.explore.isIframeTransition);
this.router.transitionTo(route);
}
_handleUrlRequest() {
this.explore.getExploreIframe().contentWindow.postMessage({
request: 'apiUrl',
response: {apiUrl: this.explore.apiUrl, darkMode: this.feature.nightShift}
}, '*');
}
_handleSiteDataUpdate(data) {
this.explore.siteData = data.siteData;
}
}

View File

@ -0,0 +1,5 @@
<div class="{{this.visibilityClass}}">
<div class="gh-explore-container">
<GhExploreIframe></GhExploreIframe>
</div>
</div>

View File

@ -0,0 +1,10 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
export default class GhExploreModal extends Component {
@service explore;
get visibilityClass() {
return this.explore.exploreWindowOpen ? 'gh-explore' : 'gh-explore closed';
}
}

View File

@ -21,6 +21,11 @@
<li class="relative">
<LinkTo @route="dashboard" @alt="Dashboard" title="Dashboard" data-test-nav="dashboard">{{svg-jar "house"}} Dashboard</LinkTo>
</li>
{{#if (feature "exploreApp")}}
<li class="relative">
<LinkTo @route="explore" title="Ghost Explore" data-test-nav="explore">{{svg-jar "globe"}} Explore</LinkTo>
</li>
{{/if}}
{{/if}}
<li class="relative">
<span {{action "transitionToOrRefreshSite" on="click"}}>

View File

@ -8,6 +8,7 @@ import Controller from '@ember/controller';
@classic
export default class ApplicationController extends Controller {
@service billing;
@service explore;
@service config;
@service dropdown;
@service feature;

View File

@ -3,16 +3,8 @@ import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class ExploreController extends Controller {
@service ghostPaths;
get apiUrl() {
const origin = new URL(window.location.origin);
const subdir = this.ghostPaths.subdir;
// We want the API URL without protocol
let url = this.ghostPaths.url.join(origin.host, subdir);
return url.replace(/\/$/, '');
}
@service explore;
@service router;
get exploreCredentials() {
const explore = this.model.findBy('slug', 'ghost-explore');
@ -21,20 +13,44 @@ export default class ExploreController extends Controller {
return adminKey.secret;
}
get visibilityClass() {
return this.explore.isIframeTransition ? 'explore iframe-explore-container' : ' explore fullscreen-explore-container';
}
@action
closeConnect() {
if (this.explore.isIframeTransition) {
this.router.transitionTo('/explore');
} else {
this.router.transitionTo('/dashboard');
}
}
@action
submitExploreSite() {
const token = this.exploreCredentials;
const apiUrl = this.apiUrl;
const apiUrl = this.explore.apiUrl;
// Ghost Explore URL to submit a new site
const destination = new URL('https://ghost.org/explore/submit');
const query = new URLSearchParams();
query.append('token', token);
query.append('url', apiUrl);
destination.search = query;
if (this.explore.isIframeTransition) {
this.explore.sendRouteUpdate({path: this.explore.submitRoute, queryParams: query.toString()});
window.location = destination.toString();
// Set a short timeout to give Explore enough time to navigate
// to the submit page and fetch the required site data
setTimeout(() => {
this.explore.toggleExploreWindow(true);
this.router.transitionTo('explore');
}, 500);
} else {
// Ghost Explore URL to submit a new site
const destination = new URL(`${this.explore.exploreUrl}${this.explore.submitRoute}`);
destination.search = query;
window.location = destination.toString();
}
}
}

View File

@ -74,7 +74,17 @@ Router.map(function () {
this.route('user', {path: ':user_slug'});
});
this.route('explore');
this.route('explore', function () {
// actual Ember route, not rendered in iframe
this.route('connect');
// iframe sub pages, used for categories
this.route('explore-sub', {path: '/*sub'}, function () {
// needed to allow search to work, as it uses URL
// params for search queries. They don't need to
// be visible, but may not be cut off.
this.route('explore-query', {path: '/*query'});
});
});
this.route('settings.integrations', {path: '/settings/integrations'}, function () {
this.route('new');

View File

@ -1,10 +0,0 @@
import AdminRoute from 'ghost-admin/routes/admin';
import {inject as service} from '@ember/service';
export default class ExploreRoute extends AdminRoute {
@service store;
model() {
return this.store.findAll('integration');
}
}

View File

@ -0,0 +1,10 @@
import ExploreRoute from './index';
export default class ExploreConnectRoute extends ExploreRoute {
controllerName = 'explore';
// Ensure to always close the iframe, as we're now on an Ember route
beforeModel() {
this.explore.toggleExploreWindow(false);
}
}

View File

@ -0,0 +1,5 @@
import ExploreRoute from './index';
export default class ExploreSubRoute extends ExploreRoute {
controllerName = 'explore';
}

View File

@ -0,0 +1,62 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class ExploreRoute extends AuthenticatedRoute {
@service explore;
@service store;
@service router;
@service feature;
beforeModel(transition) {
super.beforeModel(...arguments);
// Usage of query param to ensure that sites can be submitted across
// older versions of Ghost where the `connect` part lives in the
// explore route directly. By using the query param, we avoid causing
// a 404 and handle the redirect here.
if (transition.to?.queryParams?.new === 'true' || !this.feature.exploreApp) {
this.explore.isIframeTransition = false;
return this.router.transitionTo('explore.connect');
}
// Ensure the explore window is set to open
if (this.feature.get('exploreApp') && transition.to?.localName === 'index' && !this.explore.exploreWindowOpen) {
this.explore.openExploreWindow(this.router.currentURL);
}
}
model() {
return this.store.findAll('integration');
}
@action
willTransition(transition) {
let isExploreTransition = false;
if (transition) {
let destinationUrl = (typeof transition.to === 'string')
? transition.to
: (transition.intent
? transition.intent.url
: '');
if (destinationUrl?.includes('/explore')) {
isExploreTransition = true;
// Send the updated route to the iframe
if (transition?.to?.params?.sub) {
this.explore.sendRouteUpdate({path: transition.to.params.sub});
}
}
}
this.explore.toggleExploreWindow(isExploreTransition);
}
buildRouteInfoMetadata() {
return {
titleToken: 'Explore'
};
}
}

View File

@ -0,0 +1,116 @@
import Service, {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class ExploreService extends Service {
@service router;
@service feature;
@service ghostPaths;
// TODO: make this a config value
exploreUrl = 'https://ghost.org/explore/';
exploreRouteRoot = '#/explore';
submitRoute = 'submit';
@tracked exploreWindowOpen = false;
@tracked siteData = null;
@tracked previousRoute = null;
@tracked isIframeTransition = false;
get apiUrl() {
const origin = new URL(window.location.origin);
const subdir = this.ghostPaths.subdir;
// We want the API URL without protocol
let url = this.ghostPaths.url.join(origin.host, subdir);
return url.replace(/\/$/, '');
}
constructor() {
super(...arguments);
if (this.exploreUrl) {
window.addEventListener('message', (event) => {
if (event && event.data && event.data.route) {
this.handleRouteChangeInIframe(event.data.route);
}
});
}
}
handleRouteChangeInIframe(destinationRoute) {
if (this.exploreWindowOpen) {
let exploreRoute = this.exploreRouteRoot;
if (destinationRoute.match(/^\/explore(\/.*)?/)) {
destinationRoute = destinationRoute.replace(/\/explore/, '');
}
if (destinationRoute !== '/') {
exploreRoute += destinationRoute;
}
if (window.location.hash !== exploreRoute) {
window.history.replaceState(window.history.state, '', exploreRoute);
}
}
}
getIframeURL() {
let url = this.exploreUrl;
if (window.location.hash && window.location.hash.includes(this.exploreRouteRoot)) {
let destinationRoute = window.location.hash.replace(this.exploreRouteRoot, '');
// Connect is an Ember route, do not use it as iframe src
if (destinationRoute && !destinationRoute.includes('connect')) {
url += destinationRoute.replace(/^\//, '');
}
}
return url += '/';
}
// Sends a route update to a child route in the BMA, because we can't control
// navigating to it otherwise
sendRouteUpdate(route) {
this.getExploreIframe().contentWindow.postMessage({
query: 'routeUpdate',
response: route
}, '*');
}
// Controls explore 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 "/explore" route in the URL or using history navigation (back and forward)
toggleExploreWindow(value) {
if (this.exploreWindowOpen && value) {
// don't attempt to open again
return;
}
this.exploreWindowOpen = value;
}
// Controls navigation to explore window modal which is triggered from the application UI.
// For example: pressing "View explore" 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 the explore window
openExploreWindow(currentRoute, childRoute) {
if (this.exploreWindowOpen) {
// don't attempt to open again
return;
}
this.previousRoute = currentRoute;
// Ensures correct "getIframeURL" calculation when syncing iframe location
// in toggleExploreWindow
window.location.hash = childRoute || '/explore';
this.router.transitionTo(childRoute || '/explore');
this.toggleExploreWindow(true);
}
getExploreIframe() {
return document.getElementById('explore-frame');
}
}

View File

@ -65,6 +65,7 @@ export default class FeatureService extends Service {
@feature('emailAlerts') emailAlerts;
@feature('sourceAttribution') sourceAttribution;
@feature('lexicalEditor') lexicalEditor;
@feature('exploreApp') exploreApp;
_user = null;

View File

@ -1,3 +1,77 @@
.gh-explore {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index: 9999;
background: var(--main-bg-color);
}
.gh-explore-container {
position: relative;
height: 100%;
width: 100%;
}
.gh-explore.closed {
display: none;
}
.gh-explore .close {
position: absolute;
top: 19px;
right: 19px;
z-index: 9999;
margin: 0;
padding: 0;
width: 16px;
height: 16px;
border: none;
}
.gh-explore .explore-frame {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
transform: translate3d(0, 0, 0);
}
.gh-explore-close {
width: calc(50vw - 200px)
}
.gh-explore-close button {
stroke: var(--midgrey);
opacity: 0.6;
transition: all 0.2s ease-in-out;
top: 25px;
}
/* Connect */
.explore {
position: relative;
}
.iframe-explore-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
transform: translate3d(0, 0, 0);
height: 100vh;
background: linear-gradient(180deg, var(--white) 0%, #E1E1E1 100%);
}
.fullscreen-explore-container {
position: fixed;
top: 0;

View File

@ -14,6 +14,9 @@
{{#if this.showBilling}}
<GhBillingModal @billingWindowOpen={{this.billing.billingWindowOpen}} />
{{/if}}
{{#if (feature "exploreApp")}}
<GhExploreModal />
{{/if}}
</main>

View File

@ -1,8 +1,8 @@
<div class="explore fullscreen-explore-container">
<div class="{{this.visibilityClass}}">
<div class="explore-close">
<LinkTo data-test-button="close-explore" @route="dashboard">
<a href="javascript:void(0)" {{on "click" this.closeConnect}} data-test-button="close-explore">
{{svg-jar "close"}}<span class="hidden">Close</span>
</LinkTo>
</a>
</div>
<div class="explore-content">

View File

@ -258,6 +258,19 @@
</div>
</div>
</div>
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Explore app</h4>
<p class="gh-expandable-description">
Enable the Explore iframe app.
</p>
</div>
<div class="for-switch">
<GhFeatureFlag @flag="exploreApp" />
</div>
</div>
</div>
</div>
</div>
{{/if}}

View File

@ -6,7 +6,7 @@ describe('Unit | Route | explore', function () {
setupTest();
it('exists', function () {
let route = this.owner.lookup('route:explore');
let route = this.owner.lookup('route:explore.connect');
expect(route).to.be.ok;
});
});

View File

@ -33,7 +33,8 @@ const ALPHA_FEATURES = [
'urlCache',
'beforeAfterCard',
'sourceAttribution',
'lexicalEditor'
'lexicalEditor',
'exploreApp'
];
module.exports.GA_KEYS = [...GA_FEATURES];