diff --git a/doc/customizing/skinning.md b/doc/customizing/skinning.md index bbd1bdea5a..f3cedf4fb2 100644 --- a/doc/customizing/skinning.md +++ b/doc/customizing/skinning.md @@ -29,7 +29,7 @@ import React from 'react'; import version from '../../version'; -import StandardUserInterface from 'terriajs/lib/ReactViews/StandardUserInterface/StandardUserInterface.jsx'; +import { StandardUserInterface } from 'terriajs/lib/ReactViews/StandardUserInterface/StandardUserInterface.jsx'; import MenuItem from 'terriajs/lib/ReactViews/StandardUserInterface/customizable/MenuItem'; import RelatedMaps from './RelatedMaps'; import { Menu, Nav } from 'terriajs/lib/ReactViews/StandardUserInterface/customizable/Groups'; diff --git a/doc/deploying/deploying-terriamap.md b/doc/deploying/deploying-terriamap.md index bd0c5b681e..78ba3bd24b 100644 --- a/doc/deploying/deploying-terriamap.md +++ b/doc/deploying/deploying-terriamap.md @@ -28,6 +28,8 @@ And on the server, change to the directory where you copied those files and dire The server will start on port 3001. You can specify a different port by adding `--port 1234` to the command-line above. +Ensure that you specify the `baseHref` path in your `devserverconfig.json` if you are serving your TerriaMap from a directory, and an `appBaseUrl` so a sitemap & other links can be generated. + It is usually a good idea to run another web server, such as [nginx](https://nginx.org/en/) or [Varnish](https://varnish-cache.org/) on port 80 and then reverse-proxy to the Node.js server, rather than running terriajs-server on port 80 directly. You will find a varnish VCL file with the TerriaMap source code in the [deploy/varnish directory](https://github.com/TerriaJS/TerriaMap/tree/master/deploy/varnish). In addition to acting as a reverse proxy for the Node.js server, the supplied Varnish configuration also caches requests to proxied map data in order to improve performance. ### Using any web server @@ -38,6 +40,7 @@ It is usually a good idea to run another web server, such as [nginx](https://ngi 2. It includes a simple service at `/proxy` that allows TerriaJS to access geospatial data servers that don't support [CORS](../connecting-to-data/cross-origin-resource-sharing.md). If this service is not available, TerriaJS won't be able to access any datasets that are on other servers and that don't support CORS. 3. It includes another service at `/convert` that uses [GDAL](http://www.gdal.org/) and OGR to transform otherwise unsupported geospatial vector data (e.g. shapefiles) to GeoJSON for display by the TerriaJS client. If this service is not available, these data formats will not be supported. However, all the [formats that TerriaJS supports directly](../connecting-to-data/catalog-items.md) will work just fine. * When configured correctly, it persists blobs of JSON for use in the sharing feature. If this service is not available, the JSON can be stored in the share URL, instead. However, this makes for some extremely long URLs. +4. It reroutes and serves up a build-time-prerendered index.html of your catalog URLs so they can be indexed by search engines. If these limitations are acceptable, you can run your TerriaMap on virtually any web server by simply copying the TerriaMap `wwwroot` onto the server! diff --git a/doc/deploying/prerender-troubleshooting.md b/doc/deploying/prerender-troubleshooting.md new file mode 100644 index 0000000000..cf39f9c1e8 --- /dev/null +++ b/doc/deploying/prerender-troubleshooting.md @@ -0,0 +1,16 @@ +# Prerender Troubleshooting +TerriaMap prerenders pages through puppeteer. +It should JustWork on MacOS. Linux will require some extra deps. Some more general troubleshooting at: +https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md + +## WSL +You'll need to tell PrerenderSPAPlugin to pass an `executablePath` down to puppeteer. + +via @steve9164's findings + +Add (changing the path to where your chrome is) +`executablePath: '/mnt/c/Program\ Files\ \(x86\)/Google/Chrome/Application/chrome.exe'` + +to + +https://github.com/TerriaJS/TerriaMap/blob/c8675bc62cd5e37b6df490910f6ed7f7ae264d95/buildprocess/webpack.config.js#L155 diff --git a/lib/Core/ConsoleAnalytics.js b/lib/Core/ConsoleAnalytics.js index 80a2b5d752..e09add2074 100644 --- a/lib/Core/ConsoleAnalytics.js +++ b/lib/Core/ConsoleAnalytics.js @@ -28,4 +28,10 @@ ConsoleAnalytics.prototype.logEvent = function(category, action, label, value) { } }; +ConsoleAnalytics.prototype.logPageView = function(location) { + if (this.logToConsole) { + console.log("New pageview at location: ", location); + } +}; + module.exports = ConsoleAnalytics; diff --git a/lib/Core/GoogleAnalytics.js b/lib/Core/GoogleAnalytics.js index 37eaaf33cd..29a86162d5 100644 --- a/lib/Core/GoogleAnalytics.js +++ b/lib/Core/GoogleAnalytics.js @@ -20,6 +20,12 @@ GoogleAnalytics.prototype.logEvent = function(category, action, label, value) { ga("send", "event", category, action, label, value); }; +GoogleAnalytics.prototype.logPageView = function(location) { + initializeGoogleAnalytics(this); + ga("set", "page", location); + ga("send", "pageview"); +}; + function initializeGoogleAnalytics(that) { if (defined(window.ga)) { return; diff --git a/lib/Models/Terria.js b/lib/Models/Terria.js index 1171def07c..b9255ca250 100644 --- a/lib/Models/Terria.js +++ b/lib/Models/Terria.js @@ -47,6 +47,7 @@ var defaultConfigParameters = { defaultMaximumShownFeatureInfos: 100, /* These services are not included within Terria, but this is where we expect them to be, by default. */ regionMappingDefinitionsUrl: "build/TerriaJS/data/regionMapping.json", + appBaseUrl: undefined, conversionServiceBaseUrl: "convert/", proj4ServiceBaseUrl: "proj4/", corsProxyBaseUrl: "proxy/", @@ -524,6 +525,7 @@ Terria.prototype.start = function(options) { that.configParameters = combine(config.parameters, that.configParameters); } var cp = that.configParameters; + cp.appBaseUrl = slashify(cp.appBaseUrl); cp.conversionServiceBaseUrl = slashify(cp.conversionServiceBaseUrl); cp.proj4ServiceBaseUrl = slashify(cp.proj4ServiceBaseUrl); cp.corsProxyBaseUrl = slashify(cp.corsProxyBaseUrl); diff --git a/lib/ReactViewModels/TerriaRouting.js b/lib/ReactViewModels/TerriaRouting.js new file mode 100644 index 0000000000..4b13255505 --- /dev/null +++ b/lib/ReactViewModels/TerriaRouting.js @@ -0,0 +1,14 @@ +// Route paths for the app. + +var ROOT_ROUTE = "/"; + +var CATALOG_ROUTE = `${ROOT_ROUTE}catalog/`; +// var CATALOG_ROUTE = "/catalog/"; + +var CATALOG_MEMBER_ROUTE = `${CATALOG_ROUTE}:catalogMemberId`; + +module.exports = { + ROOT_ROUTE, + CATALOG_ROUTE, + CATALOG_MEMBER_ROUTE +}; diff --git a/lib/ReactViewModels/ViewState.js b/lib/ReactViewModels/ViewState.js index fdeaa20287..8b9e4ed565 100644 --- a/lib/ReactViewModels/ViewState.js +++ b/lib/ReactViewModels/ViewState.js @@ -6,6 +6,8 @@ import addedByUser from "../Core/addedByUser"; import knockout from "terriajs-cesium/Source/ThirdParty/knockout"; import MouseCoords from "./MouseCoords"; import SearchState from "./SearchState"; +import URI from "urijs"; + import "../Models/i18n.js"; export const DATA_CATALOG_NAME = "data-catalog"; export const USER_DATA_NAME = "my-data"; @@ -14,6 +16,9 @@ import { analyticsSetShowGuide } from "../ReactViews/Guide/Guide"; import { SATELLITE_GUIDE_KEY } from "../ReactViews/Guide/SatelliteGuide"; import { LOCAL_PROPERTY_KEY as WELCOME_PROPERTY_KEY } from "../ReactViews/WelcomeMessage/WelcomeMessage"; +// import { ROOT_ROUTE, CATALOG_ROUTE } from "../ReactViewModels/TerriaRouting.js"; +import { ROOT_ROUTE } from "../ReactViewModels/TerriaRouting.js"; + /** * Root of a global view model. Presumably this should get nested as more stuff goes into it. Basically this belongs to * the root of the UI and then it can choose to pass either the whole thing or parts down as props to its children. @@ -39,7 +44,6 @@ export default class ViewState { this.terria = terria; this.previewedItem = undefined; this.userDataPreviewedItem = undefined; - this.explorerPanelIsVisible = false; this.shareModalIsVisible = false; this.activeTabCategory = DATA_CATALOG_NAME; this.activeTabSubCategory = null; // Used to refer to an individual data-catalog tab @@ -47,6 +51,8 @@ export default class ViewState { this.mobileView = null; this.isMapFullScreen = false; this.myDataIsUploadView = true; + this.matchFromExplorer = {}; + this.history = {}; /** * Gets or sets a value indicating whether the small screen (mobile) user interface should be used. @@ -107,6 +113,7 @@ export default class ViewState { this.featurePrompts = []; + this._explorerPanelIsVisible = false; /** * The tool that will appear in the tool panel. */ @@ -143,9 +150,10 @@ export default class ViewState { }); knockout.track(this, [ + "matchFromExplorer", "previewedItem", "catalogSearch", - "explorerPanelIsVisible", + "_explorerPanelIsVisible", "shareModalIsVisible", "activeTabCategory", "activeTabIdInCategory", @@ -174,6 +182,21 @@ export default class ViewState { "_showHelpMenu" ]); + knockout.defineProperty(this, "explorerPanelIsVisible", { + get: function() { + return this._explorerPanelIsVisible; + }, + set: function(bool) { + if (!bool && this.location && this.location.pathname !== ROOT_ROUTE) { + setTimeout(() => { + this.history.push(ROOT_ROUTE); + this._explorerPanelIsVisible = false; + }, 300); + } + this._explorerPanelIsVisible = bool; + } + }); + knockout.defineProperty(this, "chartIsOpen", { get: function() { const chartableItems = this.terria.catalog.chartableItems; @@ -214,7 +237,6 @@ export default class ViewState { }); // Reflect preview id from terria when loaded from an init source - this._sharedFromExplorerPanelSubscription = knockout .getObservable(terria, "sharedFromExplorerPanel") .subscribe(sharedFromExplorerPanel => { @@ -292,6 +314,24 @@ export default class ViewState { this._disclaimerHandler = new DisclaimerHandler(terria, this); + this._matchFromExplorerSubscription = knockout + .getObservable(this, "matchFromExplorer") + .subscribe(matchFromExplorer => { + if ( + matchFromExplorer && + matchFromExplorer.params && + matchFromExplorer.params.catalogMemberId + ) { + // viewCatalogMember handles which tab to activate + const idToFind = URI.decode(matchFromExplorer.params.catalogMemberId); + const catalogMember = terria.catalog.shareKeyIndex[idToFind]; + + if (defined(catalogMember)) { + this.viewCatalogMember(catalogMember); + } + } + }); + this._storyPromptHandler = knockout .getObservable(this, "storyShown") .subscribe(storyShown => { @@ -323,6 +363,7 @@ export default class ViewState { } dispose() { + this._matchFromExplorerSubscription.dispose(); this._sharedFromExplorerPanelSubscription.dispose(); this._previewedItemIdSubscription.dispose(); this._pickedFeaturesSubscription.dispose(); @@ -335,13 +376,26 @@ export default class ViewState { this._shouldStartSatelliteGuidanceSubscription.dispose(); } + checkCatalogRoute() { + // buggy with current implementation of mobile-catalog logic + // if (defined(this.location) && defined(this.history)) { + // if (this.location.pathname === ROOT_ROUTE) { + // this.history.push(CATALOG_ROUTE); + // // would have done this if it syncs up with the mobile catalog, leaving commented out for now + // // this.explorerPanelIsVisible = true; + // } + // } + } + openAddData() { + this.checkCatalogRoute(); this.explorerPanelIsVisible = true; this.activeTabCategory = DATA_CATALOG_NAME; this.switchMobileView(this.mobileViewOptions.data); } openUserData() { + this.checkCatalogRoute(); this.explorerPanelIsVisible = true; this.activeTabCategory = USER_DATA_NAME; } diff --git a/lib/ReactViews/DataCatalog/CatalogGroup.jsx b/lib/ReactViews/DataCatalog/CatalogGroup.jsx index a3d69ac57b..1133d1e6fc 100644 --- a/lib/ReactViews/DataCatalog/CatalogGroup.jsx +++ b/lib/ReactViews/DataCatalog/CatalogGroup.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { Link } from "react-router-dom"; import PropTypes from "prop-types"; import classNames from "classnames"; @@ -17,8 +18,8 @@ function CatalogGroup(props) { const { t } = useTranslation(); return (
  • - + + - +
    diff --git a/lib/ReactViews/StandardUserInterface/RoutingListener.jsx b/lib/ReactViews/StandardUserInterface/RoutingListener.jsx new file mode 100644 index 0000000000..ceb90fae4b --- /dev/null +++ b/lib/ReactViews/StandardUserInterface/RoutingListener.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { withRouter } from "react-router-dom"; + +class RoutingListener extends React.Component { + constructor() { + super(); + this.syncLocation = this.syncLocation.bind(this); + } + /* eslint-disable-next-line camelcase */ + UNSAFE_componentWillMount() { + this.props.viewState.history = this.props.history; + this.syncLocation(); + } + componentDidUpdate() { + this.syncLocation(); + } + syncLocation() { + this.props.viewState.location = this.props.location; + } + render() { + return <>; + } +} +RoutingListener.propTypes = { + history: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + viewState: PropTypes.object.isRequired +}; + +export default withRouter(RoutingListener); diff --git a/lib/ReactViews/StandardUserInterface/StandardUserInterface.jsx b/lib/ReactViews/StandardUserInterface/StandardUserInterface.jsx index 311a179ab9..99a4a57c54 100644 --- a/lib/ReactViews/StandardUserInterface/StandardUserInterface.jsx +++ b/lib/ReactViews/StandardUserInterface/StandardUserInterface.jsx @@ -1,6 +1,13 @@ import React from "react"; import createReactClass from "create-react-class"; import PropTypes from "prop-types"; +import { BrowserRouter as Router, Route, withRouter } from "react-router-dom"; +import { Helmet } from "react-helmet"; + +import { + CATALOG_ROUTE, + CATALOG_MEMBER_ROUTE +} from "../../ReactViewModels/TerriaRouting.js"; import arrayContains from "../../Core/arrayContains"; import Branding from "./../SidePanel/Branding.jsx"; import DragDropFile from "./../DragDropFile.jsx"; @@ -17,6 +24,7 @@ import MobileHeader from "./../Mobile/MobileHeader.jsx"; import Notification from "./../Notification/Notification.jsx"; import ObserveModelMixin from "./../ObserveModelMixin"; import ProgressBar from "../Map/ProgressBar.jsx"; +import RoutingListener from "./RoutingListener.jsx"; import SidePanel from "./../SidePanel/SidePanel.jsx"; import processCustomElements from "./processCustomElements"; import FullScreenButton from "./../SidePanel/FullScreenButton.jsx"; @@ -24,6 +32,7 @@ import StoryPanel from "./../Story/StoryPanel.jsx"; import StoryBuilder from "./../Story/StoryBuilder.jsx"; import ToolPanel from "./../ToolPanel.jsx"; +import withRoutingTracker from "./withRoutingTracker"; import SatelliteGuide from "../Guide/SatelliteGuide.jsx"; import WelcomeMessage from "../WelcomeMessage/WelcomeMessage.jsx"; @@ -43,8 +52,8 @@ export const showStoryPrompt = (viewState, terria) => { }; const animationDuration = 250; /** blah */ -const StandardUserInterface = createReactClass({ - displayName: "StandardUserInterface", +export const StandardUserInterfaceRaw = createReactClass({ + displayName: "StandardUserInterfaceRaw", mixins: [ObserveModelMixin], propTypes: { @@ -170,6 +179,11 @@ const StandardUserInterface = createReactClass({ })} ref={w => (this._wrapper = w)} > + + {this.props.terria.appName} + + +
    - ( + + )} /> ( + + + +); -export default withTranslation()(StandardUserInterface); +export default StandardUserInterface; diff --git a/lib/ReactViews/StandardUserInterface/withRoutingTracker.jsx b/lib/ReactViews/StandardUserInterface/withRoutingTracker.jsx new file mode 100644 index 0000000000..5323e2956c --- /dev/null +++ b/lib/ReactViews/StandardUserInterface/withRoutingTracker.jsx @@ -0,0 +1,37 @@ +import React from "react"; +import PropTypes from "prop-types"; + +// Tracks pageviews on location change +const withRoutingTracker = WrappedComponent => { + // eslint-disable-next-line + class RoutingTracker extends React.Component { + componentDidMount() { + // eslint-disable-next-line + const page = this.props.location.pathname + this.props.location.search; + this.props.terria.analytics.logPageView(page); + } + + componentDidUpdate(prevProps) { + const currentPage = + prevProps.location.pathname + prevProps.location.search; + const newPage = this.props.location.pathname + this.props.location.search; + + if (currentPage !== newPage) { + this.props.terria.analytics.logPageView(newPage); + } + } + + render() { + return ; + } + } + + RoutingTracker.propTypes = { + terria: PropTypes.object.isRequired, + location: PropTypes.object.isRequired + }; + + return RoutingTracker; +}; + +export default withRoutingTracker; diff --git a/lib/ReactViews/Workbench/Controls/ViewingControls.jsx b/lib/ReactViews/Workbench/Controls/ViewingControls.jsx index 58ad675c31..0f2048ebcb 100644 --- a/lib/ReactViews/Workbench/Controls/ViewingControls.jsx +++ b/lib/ReactViews/Workbench/Controls/ViewingControls.jsx @@ -8,6 +8,9 @@ import PickedFeatures from "../../../Map/PickedFeatures"; import React from "react"; import createReactClass from "create-react-class"; import PropTypes from "prop-types"; +import { Link } from "react-router-dom"; +import URI from "urijs"; +import { CATALOG_ROUTE } from "../../../ReactViewModels/TerriaRouting.js"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import when from "terriajs-cesium/Source/ThirdParty/when"; import classNames from "classnames"; @@ -157,14 +160,15 @@ const ViewingControls = createReactClass({
  • - +
  • diff --git a/package.json b/package.json index 8ab18144f5..6abc9b24e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "terriajs", - "version": "7.11.4", + "version": "7.11.4-prerender-1", "description": "Geospatial data visualization platform.", "license": "Apache-2.0", "engines": { @@ -74,8 +74,10 @@ "react-anything-sortable": "^1.5.2", "react-datepicker": "0.53.0", "react-dom": "^16.3.2", + "react-helmet": "^5.2.1", "react-i18next": "^11.2.1", "react-responsive": "^5.0.0", + "react-router-dom": "^5.0.0", "react-swipeable": "^5.1.0", "react-transition-group": "^4.3.0", "resolve-url-loader": "^3.0.1", diff --git a/test/ReactViews/DataCatalog/DataCatalogItemSpec.jsx b/test/ReactViews/DataCatalog/DataCatalogItemSpec.jsx index 911ce95e04..fd5859a14a 100644 --- a/test/ReactViews/DataCatalog/DataCatalogItemSpec.jsx +++ b/test/ReactViews/DataCatalog/DataCatalogItemSpec.jsx @@ -188,7 +188,7 @@ describe("DataCatalogItem", () => { }); }); - describe("isSelected prop as", () => { + describe("selected prop as", () => { describe("true when", () => { it("item is added by user and the current user data previewed item", () => { makeItemUserAdded(item, terria); @@ -200,7 +200,11 @@ describe("DataCatalogItem", () => { }); afterEach(() => { - expect(getRenderedProp("selected")).toBe(true); + expect( + getRenderedProp("selected", { + match: { params: { catalogMemberId: `${item.uniqueId}` } } + }) + ).toBe(true); }); }); @@ -214,7 +218,11 @@ describe("DataCatalogItem", () => { }); afterEach(() => { - expect(getRenderedProp("selected")).toBe(false); + expect( + getRenderedProp("selected", { + match: { params: { catalogMemberId: `not-this-item` } } + }) + ).toBe(false); }); }); }); @@ -249,20 +257,23 @@ describe("DataCatalogItem", () => { }); } - function getRenderedProp(propName) { - return findAllWithType(renderShallow(), CatalogItemComponent)[0].props[ - propName - ]; + function getRenderedProp(propName, additionalProps) { + return findAllWithType( + renderShallow(additionalProps), + CatalogItemComponent + )[0].props[propName]; } - function renderShallow() { + function renderShallow(additionalProps = {}) { return getShallowRenderedOutput( {}} + {...additionalProps} /> ); } diff --git a/test/ReactViews/StandardUserInterfaceSpec.jsx b/test/ReactViews/StandardUserInterfaceSpec.jsx index b742c6de75..6fb138f460 100644 --- a/test/ReactViews/StandardUserInterfaceSpec.jsx +++ b/test/ReactViews/StandardUserInterfaceSpec.jsx @@ -5,7 +5,7 @@ import React from "react"; import { findWithClass } from "react-shallow-testutils"; import { getShallowRenderedOutput } from "./MoreShallowTools"; import { - StandardUserInterfaceWithoutTranslation as StandardUserInterface, + StandardUserInterfaceRaw, showStoryPrompt } from "../../lib/ReactViews/StandardUserInterface/StandardUserInterface"; import Terria from "../../lib/Models/Terria"; @@ -27,7 +27,7 @@ describe("StandardUserInterface", function() { it("has class story-wrapper", function() { const ui = ( - {}} @@ -40,7 +40,7 @@ describe("StandardUserInterface", function() { it("feature info panel has top-element class when it is the top element", function() { viewState.topElement = "FeatureInfo"; const ui = ( - {}} @@ -57,7 +57,7 @@ describe("StandardUserInterface", function() { it("side panel has top-element class when it is the top element", function() { viewState.topElement = "SidePanel"; const ui = ( - {}} @@ -74,7 +74,7 @@ describe("StandardUserInterface", function() { it("feature info panel does not have top-element class when it is not the top element", function() { viewState.topElement = "SidePanel"; const ui = ( - {}}