diff --git a/docs/getting-started/Plugin development.md b/docs/getting-started/Plugin development.md index 774a02434e..0f578151a2 100644 --- a/docs/getting-started/Plugin development.md +++ b/docs/getting-started/Plugin development.md @@ -39,14 +39,19 @@ Each plugin is responsible for registering its components to corresponding route The app will call the `createPlugin` method on each plugin, passing in a `router` object with a set of methods on it. -```typescript -import { createPlugin } from '@backstage/core'; +```jsx +import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; -export default createPlugin({ - id: 'my-plugin', +export const rootRouteRef = createRouteRef({ + path: '/new-plugin', + title: 'New plugin', +}); + +export const plugin = createPlugin({ + id: 'new-plugin', register({ router }) { - router.registerRoute('/my-plugin', ExampleComponent); + router.addRoute(rootRouteRef, ExampleComponent); }, }); ``` @@ -54,17 +59,18 @@ export default createPlugin({ #### `router` API ```typescript -type RouterHooks = { - registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, - ): void; +addRoute( + target: RouteRef, + Component: ComponentType, + options?: RouteOptions, +): void; - registerRedirect( - path: RoutePath, - target: RoutePath, - options?: RouteOptions, - ): void; -}; +/** + * @deprecated See the `addRoute` method + */ +registerRoute( + path: RoutePath, + Component: ComponentType, + options?: RouteOptions, +): void; ``` diff --git a/docs/getting-started/structure-of-a-plugin.md b/docs/getting-started/structure-of-a-plugin.md index 361546a1b6..462b2eb5f4 100644 --- a/docs/getting-started/structure-of-a-plugin.md +++ b/docs/getting-started/structure-of-a-plugin.md @@ -43,13 +43,18 @@ In the root folder you have some configuration for typescript and jest, the test In the `src` folder we get to the interesting bits. Check out the `plugin.ts`: ```jsx -import { createPlugin } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; -export default createPlugin({ +export const rootRouteRef = createRouteRef({ + path: '/new-plugin', + title: 'New plugin', +}); + +export const plugin = createPlugin({ id: 'new-plugin', register({ router }) { - router.registerRoute('/new-plugin', ExampleComponent); + router.addRoute(rootRouteRef, ExampleComponent); }, }); ``` diff --git a/docs/reference/createPlugin-router.md b/docs/reference/createPlugin-router.md index fa1a9cfa5b..88958a5093 100644 --- a/docs/reference/createPlugin-router.md +++ b/docs/reference/createPlugin-router.md @@ -1,21 +1,36 @@ # createPlugin - router -The router that is passed to the `register` function includes makes it possible for plugins to hook into routing of the Backstage app and provide the end users with new views to navigate to. +The router that is passed to the `register` function makes it possible for plugins to hook into routing of the Backstage app and provide the end users with new views to navigate to. +This is done by utilising the following methods on the `router`: ```typescript -type RouterHooks = { - registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, - ): void; +addRoute( + target: RouteRef, + Component: ComponentType, + options?: RouteOptions, +): void; - registerRedirect( - path: RoutePath, - target: RoutePath, - options?: RouteOptions, - ): void; -}; +/** + * @deprecated See the `addRoute` method + */ +registerRoute( + path: RoutePath, + Component: ComponentType, + options?: RouteOptions, +): void; +``` + +## RouteRef + +`addRoute` method is using mutable RouteRefs, which can be created as following: + +```ts +import { createRouteRef } from '@backstage/core'; + +const myPluginRouteRef = createRouteRef({ + path: '/my-plugin', + title: 'My Plugin', +}); ``` [Back to References](README.md) diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index 61d82fca53..01df20a06a 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -1,25 +1,30 @@ /* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +* Copyright 2020 Spotify AB +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ -import { createPlugin } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; -export const plugin = createPlugin({ - id: '{{ id }}', - register({ router }) { - router.registerRoute('/{{ id }}', ExampleComponent); - }, +export const rootRouteRef = createRouteRef({ +path: '/{{ id }}', +title: '{{ id }}', +}); + +export const plugin = createPlugin({ +id: '{{ id }}', +register({ router }) { +router.addRoute(rootRouteRef, ExampleComponent); +}, }); diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 282e0aa474..575dcfc59d 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -110,7 +110,7 @@ export class PrivateAppImpl implements BackstageApp { ); break; } - case 'redirect-route': { + case 'legacy-redirect-route': { const { path, target, options = {} } = output; const { exact = true } = options; routes.push( @@ -118,6 +118,19 @@ export class PrivateAppImpl implements BackstageApp { ); break; } + case 'redirect-route': { + const { from, to, options = {} } = output; + const { exact = true } = options; + routes.push( + , + ); + break; + } case 'feature-flag': { registeredFeatureFlags.push({ pluginId: plugin.getId(), diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx index ed3179296f..bf98919c2b 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -42,17 +42,14 @@ export type RouterHooks = { options?: RouteOptions, ): void; + /** + * @deprecated See the `addRoute` method + */ registerRoute( path: RoutePath, Component: ComponentType, options?: RouteOptions, ): void; - - registerRedirect( - path: RoutePath, - target: RoutePath, - options?: RouteOptions, - ): void; }; export type FeatureFlagsHooks = { @@ -91,9 +88,6 @@ export class PluginImpl { registerRoute(path, component, options) { outputs.push({ type: 'legacy-route', path, component, options }); }, - registerRedirect(path, target, options) { - outputs.push({ type: 'redirect-route', path, target, options }); - }, }, featureFlags: { register(name) { diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index 8dcbcda0a3..fabe97434e 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -41,6 +41,13 @@ export type RouteOutput = { export type RedirectRouteOutput = { type: 'redirect-route'; + from: RouteRef; + to: RouteRef; + options?: RouteOptions; +}; + +export type LegacyRedirectRouteOutput = { + type: 'legacy-redirect-route'; path: RoutePath; target: RoutePath; options?: RouteOptions; @@ -56,6 +63,7 @@ export type FeatureFlagOutput = { export type PluginOutput = | LegacyRouteOutput | RouteOutput + | LegacyRedirectRouteOutput | RedirectRouteOutput | FeatureFlagOutput; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 491665bbcf..515dc31de6 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -18,13 +18,13 @@ import { IconComponent } from '../icons'; export type RouteRef = { path: string; - icon: IconComponent; + icon?: IconComponent; title: string; }; export type RouteRefConfig = { path: string; - icon: IconComponent; + icon?: IconComponent; title: string; }; diff --git a/packages/core/package.json b/packages/core/package.json index 452633a4e5..05986a6b1e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@types/react": "^16.9", + "@types/react-router-dom": "^5.1.5", "@types/react-sparklines": "^1.7.0", "classnames": "^2.2.6", "clsx": "^1.1.0", diff --git a/packages/core/src/components/Button/Button.stories.tsx b/packages/core/src/components/Button/Button.stories.tsx new file mode 100644 index 0000000000..f7bbe45abd --- /dev/null +++ b/packages/core/src/components/Button/Button.stories.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FunctionComponentFactory } from 'react'; +import { Button } from './Button'; +import { + MemoryRouter, + Route, + useLocation, + Link as RouterLink, +} from 'react-router-dom'; +import { createRouteRef } from '@backstage/core-api'; + +const Location = () => { + const location = useLocation(); + return
Current location: {location.pathname}
; +}; + +export default { + title: 'Button', + component: Button, + decorators: [ + (storyFn: FunctionComponentFactory<{}>) => ( + +
+
+ +
+ {storyFn()} +
+
+ ), + ], +}; + +export const Default = () => { + const routeRef = createRouteRef({ + path: '/hello', + title: 'Hi there!', + }); + + return ( + <> +  will utilise the + react-router MemoryRouter's navigation + +

{routeRef.title}

+
+ + ); +}; + +export const PassProps = () => { + const routeRef = createRouteRef({ + path: '/hello', + title: 'Hi there!', + }); + + return ( + <> + +  has props for both material-ui's component as well as for + react-router-dom's + +

{routeRef.title}

+
+ + ); +}; +PassProps.story = { + name: `Accepts material-ui Button's and react-router-dom Link's props`, +}; diff --git a/packages/core/src/components/Button/Button.test.jsx b/packages/core/src/components/Button/Button.test.jsx new file mode 100644 index 0000000000..2563d367c2 --- /dev/null +++ b/packages/core/src/components/Button/Button.test.jsx @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Button } from './Button'; +import { MemoryRouter, Route } from 'react-router'; +import { act } from 'react-dom/test-utils'; + +describe(' + {testString}{' '} + , + ), + ); + expect(() => getByText(testString)).toThrow(); + await act(async () => fireEvent.click(getByText(buttonLabel))); + expect(getByText(testString)).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Button/Button.tsx b/packages/core/src/components/Button/Button.tsx new file mode 100644 index 0000000000..ca45b3da7f --- /dev/null +++ b/packages/core/src/components/Button/Button.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps } from 'react'; +import { Button as MaterialButton } from '@material-ui/core'; +import { Link as RouterLink } from 'react-router-dom'; + +type Props = ComponentProps & + ComponentProps; + +/** + * Thin wrapper on top of material-ui's Button component + * Makes the Button to utilise react-router + */ +export const Button = React.forwardRef((props, ref) => ( + +)); diff --git a/packages/core/src/components/Button/index.ts b/packages/core/src/components/Button/index.ts new file mode 100644 index 0000000000..7b584ed799 --- /dev/null +++ b/packages/core/src/components/Button/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Button } from './Button'; diff --git a/packages/core/src/components/Link/Link.stories.tsx b/packages/core/src/components/Link/Link.stories.tsx new file mode 100644 index 0000000000..2bd499fa33 --- /dev/null +++ b/packages/core/src/components/Link/Link.stories.tsx @@ -0,0 +1,92 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FunctionComponentFactory } from 'react'; +import { Link } from './Link'; +import { + MemoryRouter, + Route, + useLocation, + NavLink as RouterNavLink, +} from 'react-router-dom'; +import { createRouteRef } from '@backstage/core-api'; + +const Location = () => { + const location = useLocation(); + return
Current location: {location.pathname}
; +}; + +export default { + title: 'Link', + component: Link, + decorators: [ + (storyFn: FunctionComponentFactory<{}>) => ( + +
+
+ +
+ {storyFn()} +
+
+ ), + ], +}; + +export const Default = () => { + const routeRef = createRouteRef({ + path: '/hello', + title: 'Hi there!', + }); + + return ( + <> + This link will utilise the + react-router MemoryRouter's navigation + +

{routeRef.title}

+
+ + ); +}; + +export const PassProps = () => { + const routeRef = createRouteRef({ + path: '/hello', + title: 'Hi there!', + }); + + return ( + <> + + This link + +  has props for both material-ui's component as well as for + react-router-dom's + +

{routeRef.title}

+
+ + ); +}; +PassProps.story = { + name: `Accepts material-ui Link's and react-router-dom Link's props`, +}; diff --git a/packages/core/src/components/Link/Link.test.jsx b/packages/core/src/components/Link/Link.test.jsx new file mode 100644 index 0000000000..fcbc92a67a --- /dev/null +++ b/packages/core/src/components/Link/Link.test.jsx @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Link } from './Link'; +import { MemoryRouter, Route } from 'react-router'; +import { act } from 'react-dom/test-utils'; + +describe('', () => { + it('navigates using react-router', async () => { + const testString = 'This is test string'; + const linkText = 'Navigate!'; + const { getByText } = render( + wrapInTestApp( + + {linkText} + {testString} + , + ), + ); + expect(() => getByText(testString)).toThrow(); + await act(async () => fireEvent.click(getByText(linkText))); + expect(getByText(testString)).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Link/Link.tsx b/packages/core/src/components/Link/Link.tsx new file mode 100644 index 0000000000..859650e6b0 --- /dev/null +++ b/packages/core/src/components/Link/Link.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps } from 'react'; +import { Link as MaterialLink } from '@material-ui/core'; +import { Link as RouterLink } from 'react-router-dom'; + +type Props = ComponentProps & + ComponentProps; + +/** + * Thin wrapper on top of material-ui's Link component + * Makes the Link to utilise react-router + */ +export const Link = React.forwardRef((props, ref) => ( + +)); diff --git a/packages/core/src/components/Link/index.ts b/packages/core/src/components/Link/index.ts new file mode 100644 index 0000000000..18e990181c --- /dev/null +++ b/packages/core/src/components/Link/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Link } from './Link'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2ceea470f4..f3c5c5c778 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,4 +38,6 @@ export { default as StructuredMetadataTable } from './components/StructuredMetad export { default as TrendLine } from './components/TrendLine'; export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular'; export * from './components/Status'; +export * from './components/Button'; +export * from './components/Link'; export { default as WarningPanel } from './components/WarningPanel'; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index b174a0447f..aa952f1ca5 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -33,6 +33,7 @@ import { OAuthRequestDialog, } from '@backstage/core'; import * as defaultApiFactories from './apiFactories'; +import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied'; // TODO(rugvip): export proper plugin type from core that isn't the plugin class type BackstagePlugin = ReturnType; @@ -148,7 +149,7 @@ class DevAppBuilder { key={target.path} to={target.path} text={target.title} - icon={target.icon} + icon={target.icon ?? SentimentDissatisfiedIcon} />, ); break; diff --git a/yarn.lock b/yarn.lock index 266e7f2a52..4f6e9ab06e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3896,7 +3896,7 @@ "@types/react" "*" immutable ">=3.8.2" -"@types/react-router-dom@^5.1.3": +"@types/react-router-dom@^5.1.3", "@types/react-router-dom@^5.1.5": version "5.1.5" resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.5.tgz#7c334a2ea785dbad2b2dcdd83d2cf3d9973da090" integrity sha512-ArBM4B1g3BWLGbaGvwBGO75GNFbLDUthrDojV2vHLih/Tq8M+tgvY1DSwkuNrPSwdp/GUL93WSEpTZs8nVyJLw==