mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
c0f6192090
commit
41996bfa4b
6 changed files with 54 additions and 485 deletions
52
x-pack/plugins/beats_management/public/pages/index.ts
Normal file
52
x-pack/plugins/beats_management/public/pages/index.ts
Normal 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 },
|
||||
],
|
||||
},
|
||||
];
|
|
@ -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,
|
||||
|
|
|
@ -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": "*",
|
||||
},
|
||||
]
|
||||
`;
|
|
@ -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('*');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue