Extract `Assets` from router with `withAssets` (#674)

Summary:
This is the last piece of major infrastructure for #643. It will enable
components like `Page` and `CredExplorerApp` to receive `Assets` as a
prop.

A previous iteration of the same functionality used the new Context API
in React v16.3. This did a good job of solving the problem in production
code, and was convenient. However, it is currently intractable to test
with the current state of Enzyme. It’s plausible that this might improve
in the future, so if threading down the props becomes to onerous, we
might check in to see how our testing libraries are doing. I expect that
the threading should not be too bad, given that we do the same thing
with `localStore`, which has worked (as far as I’m aware) without a
hitch.

Test Plan:
Unit tests added; `yarn test` suffices.

wchargin-branch: withAssets
This commit is contained in:
William Chargin 2018-08-15 16:44:28 -07:00 committed by GitHub
parent 4bbbfeebdb
commit c4ecb979b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 142 additions and 0 deletions

31
src/app/withAssets.js Normal file
View File

@ -0,0 +1,31 @@
// @flow
import React, {type ComponentType} from "react";
import type {Router} from "react-router";
import {Assets} from "./assets";
// Higher-order component to serve as an adapter between React Router
// and `Assets`.
export default function withAssets<Props: {}>(
C: ComponentType<Props>
): ComponentType<{...$Diff<Props, {assets: Assets | void}>, router: Router}> {
const result = class WithAssets extends React.Component<{
...$Diff<Props, {assets: Assets | void}>,
router: Router,
}> {
_assets: ?Assets;
render() {
const assets: Assets = new Assets(this.props.router.createHref("/"));
if (
this._assets == null ||
this._assets.resolve("") !== assets.resolve("")
) {
this._assets = assets;
}
return <C {...this.props} assets={assets} />;
}
};
result.displayName = `withAssets(${C.displayName || C.name || "Component"})`;
return result;
}

111
src/app/withAssets.test.js Normal file
View File

@ -0,0 +1,111 @@
// @flow
import React, {type Node as ReactNode} from "react";
import {IndexRoute, Link, Router, Route} from "react-router";
import {mount, render} from "enzyme";
import {Assets} from "./assets";
import withAssets from "./withAssets";
import createMemoryHistory from "history/lib/createMemoryHistory";
import createRelativeHistory from "./createRelativeHistory";
require("./testUtil").configureEnzyme();
describe("app/withAssets", () => {
function createHistory(basename, path) {
const memoryHistory = createMemoryHistory(path);
const relativeHistory = createRelativeHistory(memoryHistory, basename);
return {memoryHistory, relativeHistory};
}
class FaviconRenderer extends React.Component<{|+assets: Assets|}> {
render() {
const {assets} = this.props;
return (
<div>
<img alt="favicon" src={assets.resolve("/favicon.png")} />
</div>
);
}
}
class CaptionedFaviconRenderer extends React.Component<{|
+assets: Assets,
+children: ReactNode,
|}> {
render() {
const {assets, children} = this.props;
return (
<div>
<img alt="favicon" src={assets.resolve("/favicon.png")} />
<figcaption>{children}</figcaption>
</div>
);
}
}
it("enhances a component with no extra props", () => {
const {relativeHistory} = createHistory("/foo/", "/foo/bar/");
const component = (
<Router history={relativeHistory}>
<Route path="/bar/" component={withAssets(FaviconRenderer)} />
</Router>
);
const e = render(component);
expect(e.find("img").attr("src")).toEqual("../favicon.png");
});
it("enhances a component with children", () => {
const {relativeHistory} = createHistory("/foo/", "/foo/bar/baz/");
class Caption extends React.Component<{||}> {
render() {
return <span>our favicon</span>;
}
}
const component = (
<Router history={relativeHistory}>
<Route path="/bar/" component={withAssets(CaptionedFaviconRenderer)}>
<Route path="/bar/baz/" component={Caption} />
</Route>
</Router>
);
const e = render(component);
expect(e.find("img").attr("src")).toEqual("../../favicon.png");
expect(e.find("figcaption").text()).toEqual("our favicon");
});
it("updates on page change", () => {
const {relativeHistory} = createHistory("/foo/", "/foo/bar/");
class LinkToCaption extends React.Component<{||}> {
render() {
return <Link to="/bar/captioned/">click here</Link>;
}
}
class Caption extends React.Component<{||}> {
render() {
return <span>our favicon</span>;
}
}
const component = (
<Router history={relativeHistory}>
<Route path="/bar/" component={withAssets(CaptionedFaviconRenderer)}>
<IndexRoute component={LinkToCaption} />
<Route path="/bar/captioned/" component={Caption} />
</Route>
</Router>
);
const e = mount(component);
expect(e.find("img").prop("src")).toEqual("../favicon.png");
expect(e.find("figcaption").text()).toEqual("click here");
function click(link) {
// React Router only transitions if the event appears to be from
// a left-click (button index 0) event on a mouse.
const event = {button: 0};
link.simulate("click", event);
}
click(e.find("a"));
expect(e.find("img").prop("src")).toEqual("../../favicon.png");
expect(e.find("figcaption").text()).toEqual("our favicon");
});
});