diff --git a/x-pack/plugins/beats_management/public/components/enroll_beats.tsx b/x-pack/plugins/beats_management/public/components/enroll_beats.tsx index dba4e243e16d3..896f2cd8ea3be 100644 --- a/x-pack/plugins/beats_management/public/components/enroll_beats.tsx +++ b/x-pack/plugins/beats_management/public/components/enroll_beats.tsx @@ -144,17 +144,15 @@ export class EnrollBeat extends React.Component value={this.state.command} options={[ { - value: `sudo ${this.state.beatType}`, + value: `sudo {{beatType}}`, text: 'DEB / RPM', }, { - value: `PS C:\\Program Files\\${capitalize(this.state.beatType)}> ${ - this.state.beatType - }.exe`, + value: `PS C:\\Program Files\\{{beatTypeInCaps)}}> {{beatType}}.exe`, text: 'Windows', }, { - value: `./${this.state.beatType}`, + value: `./{{beatType}}`, text: 'MacOS', }, ]} @@ -188,17 +186,13 @@ export class EnrollBeat extends React.Component className="euiFieldText euiFieldText--fullWidth" style={{ textAlign: 'left' }} > - - {`//`} - {window.location.host} - {this.props.frameworkBasePath} {this.props.enrollmentToken} + {`$ ${this.state.command + .replace('{{beatType}}', this.state.beatType) + .replace('{{beatTypeInCaps}}', capitalize(this.state.beatType))} enroll ${ + window.location.protocol + }://${window.location.host} ${this.props.frameworkBasePath} ${ + this.props.enrollmentToken + }`}
diff --git a/x-pack/plugins/beats_management/public/utils/__snapshots__/page_loader.test.ts.snap b/x-pack/plugins/beats_management/public/utils/__snapshots__/page_loader.test.ts.snap new file mode 100644 index 0000000000000..91852239ba6fd --- /dev/null +++ b/x-pack/plugins/beats_management/public/utils/__snapshots__/page_loader.test.ts.snap @@ -0,0 +1,143 @@ +// 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": "*", + }, +] +`; diff --git a/x-pack/plugins/beats_management/public/utils/page_loader.test.ts b/x-pack/plugins/beats_management/public/utils/page_loader.test.ts new file mode 100644 index 0000000000000..06db5bf23643b --- /dev/null +++ b/x-pack/plugins/beats_management/public/utils/page_loader.test.ts @@ -0,0 +1,138 @@ +/* + * 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 overideMap 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('*'); + }); +}); diff --git a/x-pack/plugins/beats_management/public/utils/page_loader.ts b/x-pack/plugins/beats_management/public/utils/page_loader.ts new file mode 100644 index 0000000000000..dfb81cb97b7c8 --- /dev/null +++ b/x-pack/plugins/beats_management/public/utils/page_loader.ts @@ -0,0 +1,170 @@ +/* + * 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; + 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 invalidOverides = difference(mappedPaths, flatRoutes); + if (invalidOverides.length > 0 && flatRoutes.length > 0) { + throw new Error( + `Invalid overideMap provided to 'routeTreeFromPaths', ${ + invalidOverides[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; + }, + + {} + ); + } +}