[BeatsCM] use static map of pages vs dynamic from FS (#27998)

* [BeatsCM] use static map of pages vs dynamic from FS

* remove snapshot

* Apply suggestions from code review

* Update x-pack/plugins/beats_management/public/pages/index.ts
This commit is contained in:
Matt Apperson 2019-01-03 15:44:46 -05:00 committed by GitHub
parent c0f6192090
commit 41996bfa4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 54 additions and 485 deletions

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { BeatDetailPage } from './beat/details';
import { BeatDetailsPage } from './beat/index';
import { BeatTagsPage } from './beat/tags';
import { EnforceSecurityPage } from './error/enforce_security';
import { InvalidLicensePage } from './error/invalid_license';
import { NoAccessPage } from './error/no_access';
import { TagsPage } from './overview/configuration_tags';
import { BeatsPage } from './overview/enrolled_beats';
import { MainPage } from './overview/index';
import { TagPage } from './tag';
import { BeatsInitialEnrollmentPage } from './walkthrough/initial/beat';
import { FinishWalkthroughPage } from './walkthrough/initial/finish';
import { InitialWalkthroughPage } from './walkthrough/initial/index';
import { InitialTagPage } from './walkthrough/initial/tag';
export const routeMap = [
{ path: '/tag/:action/:tagid?', component: TagPage },
{
path: '/beat/:beatId',
component: BeatDetailsPage,
routes: [
{ path: '/beat/:beatId/details', component: BeatDetailPage },
{ path: '/beat/:beatId/tags', component: BeatTagsPage },
],
},
{ path: '/error/enforce_security', component: EnforceSecurityPage },
{ path: '/error/invalid_license', component: InvalidLicensePage },
{ path: '/error/no_access', component: NoAccessPage },
{
path: '/overview',
component: MainPage,
routes: [
{ path: '/overview/configuration_tags', component: TagsPage },
{ path: '/overview/enrolled_beats', component: BeatsPage },
],
},
{
path: '/walkthrough/initial',
component: InitialWalkthroughPage,
routes: [
{ path: '/walkthrough/initial/beat', component: BeatsInitialEnrollmentPage },
{ path: '/walkthrough/initial/finish', component: FinishWalkthroughPage },
{ path: '/walkthrough/initial/tag', component: InitialTagPage },
],
},
];

View file

@ -13,18 +13,7 @@ import { BeatsContainer } from './containers/beats';
import { TagsContainer } from './containers/tags';
import { URLStateProps, WithURLState } from './containers/with_url_state';
import { FrontendLibs } from './lib/types';
import { RouteTreeBuilder } from './utils/page_loader/page_loader';
// See ./utils/page_loader/readme.md for details on how this works
// suffice to to say it dynamicly creates routes and pages based on the filesystem
// This is to ensure that the patterns are followed and types assured
// @ts-ignore
const requirePages = require.context('./pages', true, /\.tsx$/);
const routeTreeBuilder = new RouteTreeBuilder(requirePages);
const routesFromFilesystem = routeTreeBuilder.routeTreeFromPaths(requirePages.keys(), {
'/tag': ['action', 'tagid?'],
'/beat': ['beatId'],
});
import { routeMap } from './pages/index';
interface RouterProps {
libs: FrontendLibs;
@ -124,7 +113,7 @@ export class AppRouter extends Component<RouterProps, RouterState> {
<WithURLState>
{(URLProps: URLStateProps) => (
<ChildRoutes
routes={routesFromFilesystem}
routes={routeMap}
{...URLProps}
{...{
libs: this.props.libs,

View file

@ -1,143 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RouteTreeBuilder routeTreeFromPaths Should create a route tree 1`] = `
Array [
Object {
"component": null,
"path": "/tag",
},
Object {
"component": null,
"path": "/beat",
"routes": Array [
Object {
"component": null,
"path": "/beat/detail",
},
Object {
"component": null,
"path": "/beat/tags",
},
],
},
Object {
"component": null,
"path": "/error/enforce_security",
},
Object {
"component": null,
"path": "/error/invalid_license",
},
Object {
"component": null,
"path": "/error/no_access",
},
Object {
"component": null,
"path": "/overview",
"routes": Array [
Object {
"component": null,
"path": "/overview/enrolled_beats",
},
Object {
"component": null,
"path": "/overview/tag_configurations",
},
],
},
Object {
"component": null,
"path": "/walkthrough/initial",
"routes": Array [
Object {
"component": null,
"path": "/walkthrough/initial/beat",
},
Object {
"component": null,
"path": "/walkthrough/initial/finish",
},
Object {
"component": null,
"path": "/walkthrough/initial/tag",
},
],
},
Object {
"component": null,
"path": "*",
},
]
`;
exports[`RouteTreeBuilder routeTreeFromPaths Should create a route tree, with top level route having params 1`] = `
Array [
Object {
"component": null,
"path": "/tag/:action/:tagid?",
},
Object {
"component": null,
"path": "/beat",
"routes": Array [
Object {
"component": null,
"path": "/beat/detail",
},
Object {
"component": null,
"path": "/beat/tags",
},
],
},
Object {
"component": null,
"path": "/error/enforce_security",
},
Object {
"component": null,
"path": "/error/invalid_license",
},
Object {
"component": null,
"path": "/error/no_access",
},
Object {
"component": null,
"path": "/overview",
"routes": Array [
Object {
"component": null,
"path": "/overview/enrolled_beats",
},
Object {
"component": null,
"path": "/overview/tag_configurations",
},
],
},
Object {
"component": null,
"path": "/walkthrough/initial",
"routes": Array [
Object {
"component": null,
"path": "/walkthrough/initial/beat",
},
Object {
"component": null,
"path": "/walkthrough/initial/finish",
},
Object {
"component": null,
"path": "/walkthrough/initial/tag",
},
],
},
Object {
"component": null,
"path": "*",
},
]
`;

View file

@ -1,138 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RouteTreeBuilder } from './page_loader';
const pages = [
'./_404.tsx',
'./beat/detail.tsx',
'./beat/index.tsx',
'./beat/tags.tsx',
'./error/enforce_security.tsx',
'./error/invalid_license.tsx',
'./error/no_access.tsx',
'./overview/enrolled_beats.tsx',
'./overview/index.tsx',
'./overview/tag_configurations.tsx',
'./tag.tsx',
'./walkthrough/initial/beat.tsx',
'./walkthrough/initial/finish.tsx',
'./walkthrough/initial/index.tsx',
'./walkthrough/initial/tag.tsx',
];
describe('RouteTreeBuilder', () => {
describe('routeTreeFromPaths', () => {
it('Should fail to create a route tree due to no exported *Page component', () => {
const mockRequire = jest.fn(path => ({
path,
testComponent: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
expect(() => {
treeBuilder.routeTreeFromPaths(pages);
}).toThrowError(/in the pages folder does not include an exported/);
});
it('Should create a route tree', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
let tree;
expect(() => {
tree = treeBuilder.routeTreeFromPaths(pages);
}).not.toThrow();
expect(tree).toMatchSnapshot();
});
it('Should fail to create a route tree due to no exported custom *Component component', () => {
const mockRequire = jest.fn(path => ({
path,
testComponent: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire, /Component$/);
expect(() => {
treeBuilder.routeTreeFromPaths(pages);
}).not.toThrow();
});
it('Should create a route tree, with top level route having params', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
const tree = treeBuilder.routeTreeFromPaths(pages, {
'/tag': ['action', 'tagid?'],
});
expect(tree).toMatchSnapshot();
});
it('Should create a route tree, with a nested route having params', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
const tree = treeBuilder.routeTreeFromPaths(pages, {
'/beat': ['beatId'],
});
expect(tree[1].path).toEqual('/beat/:beatId');
});
});
it('Should create a route tree, with a deep nested route having params', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
const tree = treeBuilder.routeTreeFromPaths(pages, {
'/beat': ['beatId'],
'/beat/detail': ['other'],
});
expect(tree[1].path).toEqual('/beat/:beatId');
expect(tree[1].routes![0].path).toEqual('/beat/:beatId/detail/:other');
expect(tree[1].routes![1].path).toEqual('/beat/:beatId/tags');
});
it('Should throw an error on invalid mapped path', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
expect(() => {
treeBuilder.routeTreeFromPaths(pages, {
'/non-existant-path': ['beatId'],
});
}).toThrowError(/Invalid overrideMap provided to 'routeTreeFromPaths', \/non-existant-path /);
});
it('Should rended 404.tsx as a 404 route not /404', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
const tree = treeBuilder.routeTreeFromPaths(pages);
const firstPath = tree[0].path;
const lastPath = tree[tree.length - 1].path;
expect(firstPath).not.toBe('/_404');
expect(lastPath).toBe('*');
});
});

View file

@ -1,170 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { difference, flatten, last } from 'lodash';
interface PathTree {
[path: string]: string[];
}
export interface RouteConfig {
path: string;
component: React.ComponentType<any>;
routes?: RouteConfig[];
}
interface RouteParamsMap {
[path: string]: string[];
}
export class RouteTreeBuilder {
constructor(
private readonly requireWithContext: any,
private readonly pageComponentPattern: RegExp = /Page$/
) {}
public routeTreeFromPaths(paths: string[], mapParams: RouteParamsMap = {}): RouteConfig[] {
const pathTree = this.buildTree('./', paths);
const allRoutes = Object.keys(pathTree).reduce((routes: any[], filePath) => {
if (pathTree[filePath].includes('index.tsx')) {
routes.push(this.buildRouteWithChildren(filePath, pathTree[filePath], mapParams));
} else {
routes.concat(
pathTree[filePath].map(file => routes.push(this.buildRoute(filePath, file, mapParams)))
);
}
return routes;
}, []);
// Check that no overide maps are ignored due to being invalid
const flatRoutes = this.flatpackRoutes(allRoutes);
const mappedPaths = Object.keys(mapParams);
const invalidOverrides = difference(mappedPaths, flatRoutes);
if (invalidOverrides.length > 0 && flatRoutes.length > 0) {
throw new Error(
`Invalid overrideMap provided to 'routeTreeFromPaths', ${
invalidOverrides[0]
} is not a valid route. Only the following are: ${flatRoutes.join(', ')}`
);
}
// 404 route MUST be last or it gets used first in a switch
return allRoutes.sort((a: RouteConfig) => {
return a.path === '*' ? 1 : 0;
});
}
private flatpackRoutes(arr: RouteConfig[], pre: string = ''): string[] {
return flatten(
[].concat.apply(
[],
arr.map(item => {
const path = (pre + item.path).trim();
// The flattened route based on files without params added
const route = item.path.includes('/:')
? item.path
.split('/')
.filter(s => s.charAt(0) !== ':')
.join('/')
: item.path;
return item.routes ? [route, this.flatpackRoutes(item.routes, path)] : route;
})
)
);
}
private buildRouteWithChildren(dir: string, files: string[], mapParams: RouteParamsMap) {
const childFiles = files.filter(f => f !== 'index.tsx');
const parentConfig = this.buildRoute(dir, 'index.tsx', mapParams);
parentConfig.routes = childFiles.map(cf => this.buildRoute(dir, cf, mapParams));
return parentConfig;
}
private buildRoute(dir: string, file: string, mapParams: RouteParamsMap): RouteConfig {
// Remove the file extension as we dont want that in the URL... also index resolves to parent route
// so remove that... e.g. /beats/index is not the url we want, /beats should resolve to /beats/index
// just like how files resolve in node
const filePath = `${mapParams[dir] || dir}${file.replace('.tsx', '')}`.replace('/index', '');
const page = this.requireWithContext(`.${dir}${file}`);
const cleanDir = dir.replace(/\/$/, '');
// Make sure the expored variable name matches a pattern. By default it will choose the first
// exported variable that matches *Page
const componentExportName = Object.keys(page).find(varName =>
this.pageComponentPattern.test(varName)
);
if (!componentExportName) {
throw new Error(
`${dir}${file} in the pages folder does not include an exported \`${this.pageComponentPattern.toString()}\` component`
);
}
// _404 route is special and maps to a 404 page
if (filePath === '/_404') {
return {
path: '*',
component: page[componentExportName],
};
}
// mapped route has a parent with mapped params, so we map it here too
// e.g. /beat has a beatid param, so /beat/detail, a child of /beat
// should also have that param resulting in /beat/:beatid/detail/:other
if (mapParams[cleanDir] && filePath !== cleanDir) {
const dirWithParams = `${cleanDir}/:${mapParams[cleanDir].join('/:')}`;
const path = `${dirWithParams}/${file.replace('.tsx', '')}${
mapParams[filePath] ? '/:' : ''
}${(mapParams[filePath] || []).join('/:')}`;
return {
path,
component: page[componentExportName],
};
}
// route matches a mapped param exactly
// e.g. /beat has a beatid param, so it becomes /beat/:beatid
if (mapParams[filePath]) {
return {
path: `${filePath}/:${mapParams[filePath].join('/:')}`,
component: page[componentExportName],
};
}
return {
path: filePath,
component: page[componentExportName],
};
}
// Build tree recursively
private buildTree(basePath: string, paths: string[]): PathTree {
return paths.reduce(
(dir: any, p) => {
const path = {
dir:
p
.replace(basePath, '/') // make path absolute
.split('/')
.slice(0, -1) // remove file from path
.join('/')
.replace(/^\/\//, '') + '/', // should end in a slash but not be only //
file: last(p.split('/')),
};
// take each, remove the file name
if (dir[path.dir]) {
dir[path.dir].push(path.file);
} else {
dir[path.dir] = [path.file];
}
return dir;
},
{}
);
}
}

View file

@ -1,21 +0,0 @@
# Page loader
Routing in React is not easy, nether is ensuring a clean and simple api within pages.
This solves for both without massive config files. It also ensure URL paths match our files to make things easier to find
It works like this...
```ts
// Create a webpack context, ensureing all pages in the pages dir are included in the build
const requirePages = require.context('./pages', true, /\.tsx$/);
// Pass the context based require into the RouteTreeBuilder for require the files as-needed
const routeTreeBuilder = new RouteTreeBuilder(requirePages);
// turn the array of file paths from the require context into a nested tree of routes based on folder structure
const routesFromFilesystem = routeTreeBuilder.routeTreeFromPaths(requirePages.keys(), {
'/tag': ['action', 'tagid?'], // add params to a page. In this case /tag turns into /tag/:action/:tagid?
'/beat': ['beatId'],
'/beat/detail': ['action'], // it nests too, in this case, because of the above line, this is /beat/:beatId/detail/:action
});
```
In the above example to allow for flexability, the `./pages/beat.tsx` page would receve a prop of `routes` that is an array of sub-pages