From d41c97e5db2c9fb7bebfd9c2bf483a41781c93cc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Oct 2023 20:04:21 +0200 Subject: [PATCH] frontend-plugin-api: forklift routing API from core-plugin-api Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/package.json | 4 +- .../src/routing/ExternalRouteRef.test.ts | 112 +++++++++++++ .../src/routing/ExternalRouteRef.ts | 86 ++++++++++ .../src/routing/RouteRef.test.ts | 74 +++++++++ .../src/routing/RouteRef.ts | 73 ++++++++ .../src/routing/SubRouteRef.test.ts | 128 ++++++++++++++ .../src/routing/SubRouteRef.ts | 148 +++++++++++++++++ .../frontend-plugin-api/src/routing/index.ts | 37 +++++ .../frontend-plugin-api/src/routing/types.ts | 146 ++++++++++++++++ .../src/routing/useRouteRef.test.tsx | 157 ++++++++++++++++++ .../src/routing/useRouteRef.tsx | 115 +++++++++++++ .../src/routing/useRouteRefParams.test.tsx | 52 ++++++ .../src/routing/useRouteRefParams.ts | 29 ++++ yarn.lock | 6 +- 14 files changed, 1164 insertions(+), 3 deletions(-) create mode 100644 packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts create mode 100644 packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts create mode 100644 packages/frontend-plugin-api/src/routing/RouteRef.test.ts create mode 100644 packages/frontend-plugin-api/src/routing/RouteRef.ts create mode 100644 packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts create mode 100644 packages/frontend-plugin-api/src/routing/SubRouteRef.ts create mode 100644 packages/frontend-plugin-api/src/routing/index.ts create mode 100644 packages/frontend-plugin-api/src/routing/types.ts create mode 100644 packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx create mode 100644 packages/frontend-plugin-api/src/routing/useRouteRef.tsx create mode 100644 packages/frontend-plugin-api/src/routing/useRouteRefParams.test.tsx create mode 100644 packages/frontend-plugin-api/src/routing/useRouteRefParams.ts diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index 6100e30cf3..e996881611 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -27,7 +27,8 @@ "@backstage/frontend-app-api": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^12.1.3" + "@testing-library/react-hooks": "^8.0.1", + "history": "^5.3.0" }, "files": [ "dist" @@ -39,6 +40,7 @@ "dependencies": { "@backstage/core-plugin-api": "workspace:^", "@backstage/types": "workspace:^", + "@backstage/version-bridge": "workspace:^", "@types/react": "^16.13.1 || ^17.0.0", "lodash": "^4.17.21", "zod": "^3.21.4", diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts new file mode 100644 index 0000000000..f6e072f760 --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { AnyParams, ExternalRouteRef } from './types'; +import { createExternalRouteRef } from './ExternalRouteRef'; + +describe('ExternalRouteRef', () => { + it('should be created', () => { + const routeRef: ExternalRouteRef = createExternalRouteRef({ + id: 'my-route-ref', + }); + expect(routeRef.params).toEqual([]); + expect(routeRef.optional).toBe(false); + expect(String(routeRef)).toBe('routeRef{type=external,id=my-route-ref}'); + }); + + it('should be created as optional', () => { + const routeRef: ExternalRouteRef<{ + x: string; + y: string; + }> = createExternalRouteRef({ + id: 'my-other-route-ref', + params: [], + optional: true, + }); + expect(routeRef.params).toEqual([]); + expect(routeRef.optional).toEqual(true); + }); + + it('should be created with params', () => { + const routeRef: ExternalRouteRef<{ + x: string; + y: string; + }> = createExternalRouteRef({ + id: 'my-other-route-ref', + params: ['x', 'y'], + }); + expect(routeRef.params).toEqual(['x', 'y']); + expect(routeRef.optional).toEqual(false); + }); + + it('should be created as optional with params', () => { + const routeRef: ExternalRouteRef<{ + x: string; + y: string; + }> = createExternalRouteRef({ + id: 'my-other-route-ref', + params: ['x', 'y'], + optional: true, + }); + expect(routeRef.params).toEqual(['x', 'y']); + expect(routeRef.optional).toEqual(true); + }); + + it('should properly infer and validate parameter types and assignments', () => { + function validateType( + _ref: ExternalRouteRef, + ) {} + + const _1 = createExternalRouteRef({ id: '1', params: ['notX'] }); + // @ts-expect-error + validateType<{ x: string }, any>(_1); + validateType<{ notX: string }, any>(_1); + + const _2 = createExternalRouteRef({ + id: '2', + params: ['x'], + optional: true, + }); + // @ts-expect-error + validateType(_2); + validateType<{ x: string }, true>(_2); + + const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] }); + // @ts-expect-error + validateType<{ x: string }, any>(_3); + // extra z, we validate this at runtime instead + validateType<{ x: string; y: string; z: string }, any>(_3); + validateType<{ x: string; y: string }, false>(_3); + + const _4 = createExternalRouteRef({ id: '4', params: [] }); + // @ts-expect-error + validateType<{ x: string }, any>(_4); + validateType(_4); + + const _5 = createExternalRouteRef({ id: '5' }); + // @ts-expect-error + validateType<{ x: string }, any>(_5); + validateType(_5); + + const _6 = createExternalRouteRef({ id: '6', optional: true }); + // @ts-expect-error + validateType(_6); + validateType(_6); + + // To avoid complains about missing expectations and unused vars + expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String)); + }); +}); diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts new file mode 100644 index 0000000000..2ebb81dbfb --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { + ExternalRouteRef, + routeRefType, + AnyParams, + ParamKeys, + OptionalParams, +} from './types'; + +/** + * @internal + */ +export class ExternalRouteRefImpl< + Params extends AnyParams, + Optional extends boolean, +> implements ExternalRouteRef +{ + // The marker is used for type checking while the symbol is used at runtime. + declare $$routeRefType: 'external'; + readonly [routeRefType] = 'external'; + + constructor( + private readonly id: string, + readonly params: ParamKeys, + readonly optional: Optional, + ) {} + + toString() { + return `routeRef{type=external,id=${this.id}}`; + } +} + +/** + * Creates a route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @param options - Description of the route reference to be created. + * @public + */ +export function createExternalRouteRef< + Params extends { [param in ParamKey]: string }, + Optional extends boolean = false, + ParamKey extends string = never, +>(options: { + /** + * An identifier for this route, used to identify it in error messages + */ + id: string; + + /** + * The parameters that will be provided to the external route reference. + */ + params?: ParamKey[]; + + /** + * Whether or not this route is optional, defaults to false. + * + * Optional external routes are not required to be bound in the app, and + * if they aren't, `useRouteRef` will return `undefined`. + */ + optional?: Optional; +}): ExternalRouteRef, Optional> { + return new ExternalRouteRefImpl( + options.id, + (options.params ?? []) as ParamKeys>, + Boolean(options.optional) as Optional, + ); +} diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts new file mode 100644 index 0000000000..5314957a25 --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { AnyParams, RouteRef } from './types'; +import { createRouteRef } from './RouteRef'; + +describe('RouteRef', () => { + it('should be created', () => { + const routeRef: RouteRef = createRouteRef({ + id: 'my-route-ref', + }); + expect(routeRef.params).toEqual([]); + expect(String(routeRef)).toBe('routeRef{type=absolute,id=my-route-ref}'); + }); + + it('should be created with params', () => { + const routeRef: RouteRef<{ + x: string; + y: string; + }> = createRouteRef({ + id: 'my-other-route-ref', + params: ['x', 'y'], + }); + expect(routeRef.params).toEqual(['x', 'y']); + }); + + it('should properly infer and validate parameter types and assignments', () => { + function validateType(_ref: RouteRef) {} + + const _1 = createRouteRef({ id: '1', params: ['x'] }); + // @ts-expect-error + validateType<{ y: string }>(_1); + // @ts-expect-error + validateType(_1); + validateType<{ x: string }>(_1); + + const _2 = createRouteRef({ id: '2', params: ['x', 'y'] }); + // @ts-expect-error + validateType<{ x: string }>(_2); + // @ts-expect-error + validateType(_2); + // @ts-expect-error + validateType<{ x: string; z: string }>(_2); + // extra z, we validate this at runtime instead + validateType<{ x: string; y: string; z: string }>(_2); + validateType<{ x: string; y: string }>(_2); + + const _3 = createRouteRef({ id: '3', params: [] }); + // @ts-expect-error + validateType<{ x: string }>(_3); + validateType(_3); + + const _4 = createRouteRef({ id: '4' }); + // @ts-expect-error + validateType<{ x: string }>(_4); + validateType(_4); + + // To avoid complains about missing expectations and unused vars + expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String)); + }); +}); diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.ts b/packages/frontend-plugin-api/src/routing/RouteRef.ts new file mode 100644 index 0000000000..36ca3f073d --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/RouteRef.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { + RouteRef, + routeRefType, + AnyParams, + ParamKeys, + OptionalParams, +} from './types'; + +/** + * @internal + */ +export class RouteRefImpl + implements RouteRef +{ + // The marker is used for type checking while the symbol is used at runtime. + declare $$routeRefType: 'absolute'; + readonly [routeRefType] = 'absolute'; + + constructor( + private readonly id: string, + readonly params: ParamKeys, + ) {} + + get title() { + return this.id; + } + + toString() { + return `routeRef{type=absolute,id=${this.id}}`; + } +} + +/** + * Create a {@link RouteRef} from a route descriptor. + * + * @param config - Description of the route reference to be created. + * @public + */ +export function createRouteRef< + // Params is the type that we care about and the one to be embedded in the route ref. + // For example, given the params ['name', 'kind'], Params will be {name: string, kind: string} + Params extends { [param in ParamKey]: string }, + // ParamKey is here to make sure the Params type properly has its keys narrowed down + // to only the elements of params. Defaulting to never makes sure we end up with + // Param = {} if the params array is empty. + ParamKey extends string = never, +>(config: { + /** The id of the route ref, used to identify it when printed */ + id: string; + /** A list of parameter names that the path that this route ref is bound to must contain */ + params?: ParamKey[]; +}): RouteRef> { + return new RouteRefImpl( + config.id, + (config.params ?? []) as ParamKeys>, + ); +} diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts new file mode 100644 index 0000000000..cec00de025 --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { AnyParams, SubRouteRef } from './types'; +import { createSubRouteRef } from './SubRouteRef'; +import { createRouteRef } from './RouteRef'; + +const parent = createRouteRef({ id: 'parent' }); +const parentX = createRouteRef({ id: 'parent-x', params: ['x'] }); + +describe('SubRouteRef', () => { + it('should be created', () => { + const routeRef: SubRouteRef = createSubRouteRef({ + parent, + id: 'my-route-ref', + path: '/foo', + }); + expect(routeRef.path).toBe('/foo'); + expect(routeRef.parent).toBe(parent); + expect(routeRef.params).toEqual([]); + expect(String(routeRef)).toBe('routeRef{type=sub,id=my-route-ref}'); + }); + + it('should be created with params', () => { + const routeRef: SubRouteRef<{ bar: string }> = createSubRouteRef({ + parent, + id: 'my-other-route-ref', + path: '/foo/:bar', + }); + expect(routeRef.path).toBe('/foo/:bar'); + expect(routeRef.parent).toBe(parent); + expect(routeRef.params).toEqual(['bar']); + }); + + it('should be created with merged params', () => { + const routeRef: SubRouteRef<{ + x: string; + y: string; + z: string; + }> = createSubRouteRef({ + parent: parentX, + id: 'my-other-route-ref', + path: '/foo/:y/:z', + }); + expect(routeRef.path).toBe('/foo/:y/:z'); + expect(routeRef.parent).toBe(parentX); + expect(routeRef.params).toEqual(['x', 'y', 'z']); + }); + + it('should be created with params from parent', () => { + const routeRef: SubRouteRef<{ x: string }> = createSubRouteRef({ + parent: parentX, + id: 'my-other-route-ref', + path: '/foo/bar', + }); + expect(routeRef.path).toBe('/foo/bar'); + expect(routeRef.parent).toBe(parentX); + expect(routeRef.params).toEqual(['x']); + }); + + it.each([ + ['foo', "SubRouteRef path must start with '/', got 'foo'"], + [':foo', "SubRouteRef path must start with '/', got ':foo'"], + ['', "SubRouteRef path must start with '/', got ''"], + ['/', "SubRouteRef path must not end with '/', got '/'"], + ['/foo/', "SubRouteRef path must not end with '/', got '/foo/'"], + ['/foo/:x', 'SubRouteRef may not have params that overlap with its parent'], + ['/:/foo', "SubRouteRef path has invalid param, got ''"], + ['/:inva:lid/foo', "SubRouteRef path has invalid param, got 'inva:lid'"], + ['/:inva=lid/foo', "SubRouteRef path has invalid param, got 'inva=lid'"], + ])('should throw if path is invalid, %s', (path, message) => { + expect(() => + createSubRouteRef({ path, parent: parentX, id: path }), + ).toThrow(message); + }); + + it('should properly infer and parse path parameters', () => { + function validateType(_ref: SubRouteRef) {} + + const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' }); + // @ts-expect-error + validateType<{ x: string }>(_1); + validateType(_1); + + const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' }); + // @ts-expect-error + validateType(_2); + // @ts-expect-error + validateType<{ x: string; z: string }>(_2); + // @ts-expect-error + validateType<{ y: string }>(_2); + // extra z, we validate this at runtime instead + validateType<{ x: string; y: string; z: string }>(_2); + validateType<{ x: string; y: string }>(_2); + + const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' }); + // @ts-expect-error + validateType(_3); + // @ts-expect-error + validateType<{ y: string }>(_3); + validateType<{ x: string }>(_3); + + const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' }); + // @ts-expect-error + validateType(_4); + // @ts-expect-error + validateType<{ x: string; z: string }>(_4); + // @ts-expect-error + validateType<{ y: string }>(_4); + validateType<{ x: string; y: string }>(_4); + + // To avoid complains about missing expectations and unused vars + expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String)); + }); +}); diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts new file mode 100644 index 0000000000..14a614c2ba --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts @@ -0,0 +1,148 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { + AnyParams, + OptionalParams, + ParamKeys, + RouteRef, + routeRefType, + SubRouteRef, +} from './types'; + +// Should match the pattern in react-router +const PARAM_PATTERN = /^\w+$/; + +/** + * @internal + */ +export class SubRouteRefImpl + implements SubRouteRef +{ + // The marker is used for type checking while the symbol is used at runtime. + declare $$routeRefType: 'sub'; + readonly [routeRefType] = 'sub'; + + constructor( + private readonly id: string, + readonly path: string, + readonly parent: RouteRef, + readonly params: ParamKeys, + ) {} + + toString() { + return `routeRef{type=sub,id=${this.id}}`; + } +} + +/** + * Used in {@link PathParams} type declaration. + * @public + */ +export type ParamPart = S extends `:${infer Param}` + ? Param + : never; + +/** + * Used in {@link PathParams} type declaration. + * @public + */ +export type ParamNames = + S extends `${infer Part}/${infer Rest}` + ? ParamPart | ParamNames + : ParamPart; +/** + * This utility type helps us infer a Param object type from a string path + * For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }` + * @public + */ +export type PathParams = { [name in ParamNames]: string }; + +/** + * Merges a param object type with an optional params type into a params object. + * @public + */ +export type MergeParams< + P1 extends { [param in string]: string }, + P2 extends AnyParams, +> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); + +/** + * Creates a SubRouteRef type given the desired parameters and parent route parameters. + * The parameters types are merged together while ensuring that there is no overlap between the two. + * + * @public + */ +export type MakeSubRouteRef< + Params extends { [param in string]: string }, + ParentParams extends AnyParams, +> = keyof Params & keyof ParentParams extends never + ? SubRouteRef>> + : never; + +/** + * Create a {@link SubRouteRef} from a route descriptor. + * + * @param config - Description of the route reference to be created. + * @public + */ +export function createSubRouteRef< + Path extends string, + ParentParams extends AnyParams = never, +>(config: { + id: string; + path: Path; + parent: RouteRef; +}): MakeSubRouteRef, ParentParams> { + const { id, path, parent } = config; + type Params = PathParams; + + // Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz' + const pathParams = path + .split('/') + .filter(p => p.startsWith(':')) + .map(p => p.substring(1)); + const params = [...parent.params, ...pathParams]; + + if (parent.params.some(p => pathParams.includes(p as string))) { + throw new Error( + 'SubRouteRef may not have params that overlap with its parent', + ); + } + if (!path.startsWith('/')) { + throw new Error(`SubRouteRef path must start with '/', got '${path}'`); + } + if (path.endsWith('/')) { + throw new Error(`SubRouteRef path must not end with '/', got '${path}'`); + } + for (const param of pathParams) { + if (!PARAM_PATTERN.test(param)) { + throw new Error(`SubRouteRef path has invalid param, got '${param}'`); + } + } + + // We ensure that the type of the return type is sane here + const subRouteRef = new SubRouteRefImpl( + id, + path, + parent, + params as ParamKeys>, + ) as SubRouteRef>>; + + // But skip type checking of the return value itself, because the conditional + // type checking of the parent parameter overlap is tricky to express. + return subRouteRef as any; +} diff --git a/packages/frontend-plugin-api/src/routing/index.ts b/packages/frontend-plugin-api/src/routing/index.ts new file mode 100644 index 0000000000..01d69cd4b0 --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/index.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 type { + AnyParams, + RouteRef, + SubRouteRef, + ExternalRouteRef, + OptionalParams, + ParamKeys, + RouteFunc, +} from './types'; +export { createRouteRef } from './RouteRef'; +export { createSubRouteRef } from './SubRouteRef'; +export type { + MakeSubRouteRef, + MergeParams, + ParamNames, + ParamPart, + PathParams, +} from './SubRouteRef'; +export { createExternalRouteRef } from './ExternalRouteRef'; +export { useRouteRef } from './useRouteRef'; +export { useRouteRefParams } from './useRouteRefParams'; diff --git a/packages/frontend-plugin-api/src/routing/types.ts b/packages/frontend-plugin-api/src/routing/types.ts new file mode 100644 index 0000000000..80653518bb --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/types.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; + +/** + * Catch-all type for route params. + * + * @public + */ +export type AnyParams = { [param in string]: string } | undefined; + +/** + * Type describing the key type of a route parameter mapping. + * + * @public + */ +export type ParamKeys = keyof Params extends never + ? [] + : (keyof Params)[]; + +/** + * Optional route params. + * + * @public + */ +export type OptionalParams = + Params[keyof Params] extends never ? undefined : Params; + +/** + * TS magic for handling route parameters. + * + * @remarks + * + * The extra TS magic here is to require a single params argument if the RouteRef + * had at least one param defined, but require 0 arguments if there are no params defined. + * Without this we'd have to pass in empty object to all parameter-less RouteRefs + * just to make TypeScript happy, or we would have to make the argument optional in + * which case you might forget to pass it in when it is actually required. + * + * @public + */ +export type RouteFunc = ( + ...[params]: Params extends undefined ? readonly [] : readonly [Params] +) => string; + +/** + * This symbol is what we use at runtime to determine whether a given object + * is a type of RouteRef or not. It doesn't work well in TypeScript though since + * the `unique symbol` will refer to different values between package versions. + * For that reason we use the marker $$routeRefType to represent the symbol at + * compile-time instead of using the symbol directly. + * + * @internal + */ +export const routeRefType: unique symbol = getOrCreateGlobalSingleton( + 'route-ref-type', + () => Symbol('route-ref-type'), +); + +/** + * Absolute route reference. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @public + */ +export type RouteRef = { + $$routeRefType: 'absolute'; // See routeRefType above + + params: ParamKeys; +}; + +/** + * Descriptor of a route relative to an absolute {@link RouteRef}. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @public + */ +export type SubRouteRef = { + $$routeRefType: 'sub'; // See routeRefType above + + parent: RouteRef; + + path: string; + + params: ParamKeys; +}; + +/** + * Route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @public + */ +export type ExternalRouteRef< + Params extends AnyParams = any, + Optional extends boolean = any, +> = { + $$routeRefType: 'external'; // See routeRefType above + + params: ParamKeys; + + optional?: Optional; +}; + +/** + * @internal + */ +export type AnyRouteRef = + | RouteRef + | SubRouteRef + | ExternalRouteRef; + +/** + * A duplicate of the react-router RouteObject, but with routeRef added + * @internal + */ +export interface BackstageRouteObject { + caseSensitive: boolean; + children?: BackstageRouteObject[]; + element: React.ReactNode; + path: string; + routeRefs: Set; +} diff --git a/packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx b/packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx new file mode 100644 index 0000000000..7a03a1cdc4 --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx @@ -0,0 +1,157 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { MemoryRouter, Router } from 'react-router-dom'; +import { createVersionedContextForTesting } from '@backstage/version-bridge'; +import { useRouteRef } from './useRouteRef'; +import { createRouteRef } from './RouteRef'; +import { createBrowserHistory } from 'history'; + +describe('v1 consumer', () => { + const context = createVersionedContextForTesting('routing-context'); + + afterEach(() => { + context.reset(); + }); + + it('should resolve routes', () => { + const resolve = jest.fn(() => () => '/hello'); + context.set({ 1: { resolve } }); + + const routeRef = createRouteRef({ id: 'ref1' }); + + const renderedHook = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + + ), + }); + + const routeFunc = renderedHook.result.current; + expect(routeFunc()).toBe('/hello'); + expect(resolve).toHaveBeenCalledWith( + routeRef, + expect.objectContaining({ + pathname: '/my-page', + }), + ); + }); + + it('re-resolves the routeFunc when the search parameters change', () => { + const resolve = jest.fn(() => () => '/hello'); + context.set({ 1: { resolve } }); + + const routeRef = createRouteRef({ id: 'ref1' }); + const history = createBrowserHistory(); + history.push('/my-page'); + + const { rerender } = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + + ), + }); + + expect(resolve).toHaveBeenCalledTimes(1); + + history.push('/my-new-page'); + rerender(); + + expect(resolve).toHaveBeenCalledTimes(2); + }); + + it('does not re-resolve the routeFunc the location pathname does not change', () => { + const resolve = jest.fn(() => () => '/hello'); + context.set({ 1: { resolve } }); + + const routeRef = createRouteRef({ id: 'ref1' }); + const history = createBrowserHistory(); + history.push('/my-page'); + + const { rerender } = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + + ), + }); + + expect(resolve).toHaveBeenCalledTimes(1); + + history.push('/my-page'); + rerender(); + + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it('does not re-resolve the routeFunc when the search parameter changes', () => { + const resolve = jest.fn(() => () => '/hello'); + context.set({ 1: { resolve } }); + + const routeRef = createRouteRef({ id: 'ref1' }); + const history = createBrowserHistory(); + history.push('/my-page'); + + const { rerender } = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + + ), + }); + + expect(resolve).toHaveBeenCalledTimes(1); + + history.push('/my-page?foo=bar'); + rerender(); + + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it('does not re-resolve the routeFunc when the hash parameter changes', () => { + const resolve = jest.fn(() => () => '/hello'); + context.set({ 1: { resolve } }); + + const routeRef = createRouteRef({ id: 'ref1' }); + const history = createBrowserHistory(); + history.push('/my-page'); + + const { rerender } = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + + ), + }); + + expect(resolve).toHaveBeenCalledTimes(1); + + history.push('/my-page#foo'); + rerender(); + + expect(resolve).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/frontend-plugin-api/src/routing/useRouteRef.tsx b/packages/frontend-plugin-api/src/routing/useRouteRef.tsx new file mode 100644 index 0000000000..cf292b110f --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/useRouteRef.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { useMemo } from 'react'; +import { matchRoutes, useLocation } from 'react-router-dom'; +import { useVersionedContext } from '@backstage/version-bridge'; +import { + AnyParams, + ExternalRouteRef, + RouteFunc, + RouteRef, + SubRouteRef, +} from './types'; + +/** + * @internal + */ +export interface RouteResolver { + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + sourceLocation: Parameters[1], + ): RouteFunc | undefined; +} + +/** + * React hook for constructing URLs to routes. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system} + * + * @param routeRef - The ref to route that should be converted to URL. + * @returns A function that will in turn return the concrete URL of the `routeRef`. + * @public + */ +export function useRouteRef( + routeRef: ExternalRouteRef, +): Optional extends true ? RouteFunc | undefined : RouteFunc; + +/** + * React hook for constructing URLs to routes. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system} + * + * @param routeRef - The ref to route that should be converted to URL. + * @returns A function that will in turn return the concrete URL of the `routeRef`. + * @public + */ +export function useRouteRef( + routeRef: RouteRef | SubRouteRef, +): RouteFunc; + +/** + * React hook for constructing URLs to routes. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system} + * + * @param routeRef - The ref to route that should be converted to URL. + * @returns A function that will in turn return the concrete URL of the `routeRef`. + * @public + */ +export function useRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): RouteFunc | undefined { + const { pathname } = useLocation(); + const versionedContext = useVersionedContext<{ 1: RouteResolver }>( + 'routing-context', + ); + if (!versionedContext) { + throw new Error('Routing context is not available'); + } + + const resolver = versionedContext.atVersion(1); + const routeFunc = useMemo( + () => resolver && resolver.resolve(routeRef, { pathname }), + [resolver, routeRef, pathname], + ); + + if (!versionedContext) { + throw new Error('useRouteRef used outside of routing context'); + } + if (!resolver) { + throw new Error('RoutingContext v1 not available'); + } + + const isOptional = 'optional' in routeRef && routeRef.optional; + if (!routeFunc && !isOptional) { + throw new Error(`No path for ${routeRef}`); + } + + return routeFunc; +} diff --git a/packages/frontend-plugin-api/src/routing/useRouteRefParams.test.tsx b/packages/frontend-plugin-api/src/routing/useRouteRefParams.test.tsx new file mode 100644 index 0000000000..40ee7219c2 --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/useRouteRefParams.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { useRouteRefParams } from './useRouteRefParams'; +import { createRouteRef } from './RouteRef'; + +describe('useRouteRefParams', () => { + it('should provide types params', () => { + const routeRef = createRouteRef({ + id: 'ref1', + params: ['a', 'b'], + }); + + const Page = () => { + const params: { a: string; b: string } = useRouteRefParams(routeRef); + + return ( +
+ {params.a} + {params.b} +
+ ); + }; + + const { getByText } = render( + + + } /> + + , + ); + + expect(getByText('foo')).toBeInTheDocument(); + expect(getByText('bar')).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts b/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts new file mode 100644 index 0000000000..e24576372b --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { useParams } from 'react-router-dom'; +import { RouteRef, AnyParams, SubRouteRef } from './types'; + +/** + * React hook for retrieving dynamic params from the current URL. + * @param _routeRef - Ref of the current route. + * @public + */ +export function useRouteRefParams( + _routeRef: RouteRef | SubRouteRef, +): Params { + return useParams() as Params; +} diff --git a/yarn.lock b/yarn.lock index cc986877ed..4e47f15fd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4234,9 +4234,11 @@ __metadata: "@backstage/frontend-app-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" + "@backstage/version-bridge": "workspace:^" "@testing-library/jest-dom": ^6.0.0 - "@testing-library/react": ^12.1.3 + "@testing-library/react-hooks": ^8.0.1 "@types/react": ^16.13.1 || ^17.0.0 + history: ^5.3.0 lodash: ^4.17.21 zod: ^3.21.4 zod-to-json-schema: ^3.21.4 @@ -27641,7 +27643,7 @@ __metadata: languageName: node linkType: hard -"history@npm:^5.0.0": +"history@npm:^5.0.0, history@npm:^5.3.0": version: 5.3.0 resolution: "history@npm:5.3.0" dependencies: