From 640fe3dbef01529263f3793d1d984740b7c61eb4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Oct 2023 15:47:00 +0200 Subject: [PATCH 01/34] frontend-plugin-api: extract Expand to top-level types Signed-off-by: Patrik Oldsberg --- .../src/extensions/createApiExtension.ts | 3 ++- .../src/extensions/createPageExtension.tsx | 3 ++- packages/frontend-plugin-api/src/types.ts | 22 +++++++++++++++++++ .../src/wiring/createExtension.ts | 8 +------ 4 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 packages/frontend-plugin-api/src/types.ts diff --git a/packages/frontend-plugin-api/src/extensions/createApiExtension.ts b/packages/frontend-plugin-api/src/extensions/createApiExtension.ts index 8e5a9e9f33..f728261072 100644 --- a/packages/frontend-plugin-api/src/extensions/createApiExtension.ts +++ b/packages/frontend-plugin-api/src/extensions/createApiExtension.ts @@ -21,7 +21,8 @@ import { createExtension, coreExtensionData, } from '../wiring'; -import { AnyExtensionInputMap, Expand } from '../wiring/createExtension'; +import { AnyExtensionInputMap } from '../wiring/createExtension'; +import { Expand } from '../types'; /** @public */ export function createApiExtension< diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx index 712c55f896..43edfaa36d 100644 --- a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx @@ -24,7 +24,8 @@ import { Extension, ExtensionInputValues, } from '../wiring'; -import { AnyExtensionInputMap, Expand } from '../wiring/createExtension'; +import { AnyExtensionInputMap } from '../wiring/createExtension'; +import { Expand } from '../types'; /** * Helper for creating extensions for a routable React page component. diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts new file mode 100644 index 0000000000..8b6f02a942 --- /dev/null +++ b/packages/frontend-plugin-api/src/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2023 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. + */ + +// TODO(Rugvip): This might be a quite useful utility type, maybe add to @backstage/types? +/** + * Utility type to expand type aliases into their equivalent type. + * @ignore + */ +export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 01d4962965..fa24a24797 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -15,6 +15,7 @@ */ import { PortableSchema } from '../schema'; +import { Expand } from '../types'; import { ExtensionDataRef } from './createExtensionDataRef'; import { ExtensionInput } from './createExtensionInput'; import { BackstagePlugin } from './createPlugin'; @@ -32,13 +33,6 @@ export type AnyExtensionInputMap = { >; }; -// TODO(Rugvip): This might be a quite useful utility type, maybe add to @backstage/types? -/** - * Utility type to expand type aliases into their equivalent type. - * @ignore - */ -export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; - /** * Converts an extension data map into the matching concrete data values type. * @public From d41c97e5db2c9fb7bebfd9c2bf483a41781c93cc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Oct 2023 20:04:21 +0200 Subject: [PATCH 02/34] 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: From 18882a9bbe87591f1ff6b5e0254da4546beff0b7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Oct 2023 20:25:00 +0200 Subject: [PATCH 03/34] frontend-plugin-api: refactor RouteRef to modern structure Signed-off-by: Patrik Oldsberg --- .../src/routing/RouteRef.test.ts | 54 ++++----- .../src/routing/RouteRef.ts | 103 ++++++++++++------ .../frontend-plugin-api/src/routing/types.ts | 48 ++------ 3 files changed, 107 insertions(+), 98 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts index 5314957a25..b8e86d1b99 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts @@ -14,16 +14,14 @@ * limitations under the License. */ -import { AnyParams, RouteRef } from './types'; -import { createRouteRef } from './RouteRef'; +import { AnyRouteParams } from './types'; +import { RouteRef, 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}'); + const routeRef: RouteRef = createRouteRef(); + expect(() => routeRef.T).toThrow(); + expect(String(routeRef)).toBe('RouteRef{}'); }); it('should be created with params', () => { @@ -31,42 +29,44 @@ describe('RouteRef', () => { x: string; y: string; }> = createRouteRef({ - id: 'my-other-route-ref', params: ['x', 'y'], }); - expect(routeRef.params).toEqual(['x', 'y']); + expect(() => routeRef.T).toThrow(); }); it('should properly infer and validate parameter types and assignments', () => { - function validateType(_ref: RouteRef) {} + function checkRouteRef( + _ref: RouteRef, + _params: T extends undefined ? undefined : T, + ) {} - const _1 = createRouteRef({ id: '1', params: ['x'] }); + const _1 = createRouteRef({ params: ['x'] }); + checkRouteRef(_1, { x: '' }); // @ts-expect-error - validateType<{ y: string }>(_1); + checkRouteRef(_1, { y: '' }); // @ts-expect-error - validateType(_1); - validateType<{ x: string }>(_1); + checkRouteRef(_1, undefined); - const _2 = createRouteRef({ id: '2', params: ['x', 'y'] }); + const _2 = createRouteRef({ params: ['x', 'y'] }); + checkRouteRef(_2, { x: '', y: '' }); // @ts-expect-error - validateType<{ x: string }>(_2); + checkRouteRef(_2, { x: '' }); // @ts-expect-error - validateType(_2); + checkRouteRef(_2, undefined); // @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); + checkRouteRef(_2, { x: '', z: '' }); + // @ts-expect-error + checkRouteRef(_2, { x: '', y: '', z: '' }); - const _3 = createRouteRef({ id: '3', params: [] }); + const _3 = createRouteRef({ params: [] }); + checkRouteRef(_3, undefined); // @ts-expect-error - validateType<{ x: string }>(_3); - validateType(_3); + checkRouteRef(_3, { x: '' }); - const _4 = createRouteRef({ id: '4' }); + const _4 = createRouteRef(); + checkRouteRef(_4, undefined); // @ts-expect-error - validateType<{ x: string }>(_4); - validateType(_4); + checkRouteRef(_4, { x: '' }); // 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 index 36ca3f073d..b19b77d298 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.ts @@ -14,35 +14,68 @@ * limitations under the License. */ -import { - RouteRef, - routeRefType, - AnyParams, - ParamKeys, - OptionalParams, -} from './types'; +import { AnyRouteParams } from './types'; /** - * @internal + * Absolute route reference. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @public */ -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'; +export interface RouteRef { + readonly $$type: '@backstage/RouteRef'; + readonly T: TParams; +} - constructor( - private readonly id: string, - readonly params: ParamKeys, - ) {} +/** @internal */ +export interface InternalRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, +> extends RouteRef { + readonly version: 'v1'; + getParams(): string[]; +} - get title() { - return this.id; +/** @internal */ +export function toInternalRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, +>(resource: RouteRef): InternalRouteRef { + const r = resource as InternalRouteRef; + if (r.$$type !== '@backstage/RouteRef') { + throw new Error(`Invalid RouteRef, bad type '${r.$$type}'`); } - toString() { - return `routeRef{type=absolute,id=${this.id}}`; + return r; +} + +/** @internal */ +export function isRouteRef(opaque: { $$type: string }): opaque is RouteRef { + return opaque.$$type === '@backstage/RouteRef'; +} + +/** @internal */ +export class RouteRefImpl implements InternalRouteRef { + readonly $$type = '@backstage/RouteRef'; + readonly version = 'v1'; + + #params: string[]; + + constructor(readonly params: string[] = []) { + this.#params = params; + } + + get T(): never { + throw new Error(`tried to read RouteRef.T of ${this}`); + } + + getParams(): string[] { + return this.#params; + } + + toString(): string { + return `RouteRef{}`; } } @@ -55,19 +88,19 @@ export class RouteRefImpl 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; + TParams extends { [param in TParamKeys]: string } | undefined = undefined, + TParamKeys extends string = string, +>(config?: { /** A list of parameter names that the path that this route ref is bound to must contain */ - params?: ParamKey[]; -}): RouteRef> { + readonly params: string extends TParamKeys ? (keyof TParams)[] : TParamKeys[]; +}): RouteRef< + keyof TParams extends never + ? undefined + : string extends TParamKeys + ? TParams + : { [param in TParamKeys]: string } +> { return new RouteRefImpl( - config.id, - (config.params ?? []) as ParamKeys>, - ); + config?.params as string[] | undefined, + ) as RouteRef; } diff --git a/packages/frontend-plugin-api/src/routing/types.ts b/packages/frontend-plugin-api/src/routing/types.ts index 80653518bb..b9893c1fdf 100644 --- a/packages/frontend-plugin-api/src/routing/types.ts +++ b/packages/frontend-plugin-api/src/routing/types.ts @@ -21,24 +21,7 @@ import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; * * @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; +export type AnyRouteParams = { [param in string]: string } | undefined; /** * TS magic for handling route parameters. @@ -72,19 +55,20 @@ export const routeRefType: unique symbol = getOrCreateGlobalSingleton( ); /** - * Absolute route reference. + * Optional route params. * - * @remarks - * - * See {@link https://backstage.io/docs/plugins/composability#routing-system}. - * - * @public + * @internal */ -export type RouteRef = { - $$routeRefType: 'absolute'; // See routeRefType above +export type OptionalParams = + Params[keyof Params] extends never ? undefined : Params; - params: ParamKeys; -}; +/** + * Type describing the key type of a route parameter mapping. + * + * @ignore + */ +export type ParamKeys = + keyof TParams extends never ? [] : (keyof TParams)[]; /** * Descriptor of a route relative to an absolute {@link RouteRef}. @@ -125,14 +109,6 @@ export type ExternalRouteRef< optional?: Optional; }; -/** - * @internal - */ -export type AnyRouteRef = - | RouteRef - | SubRouteRef - | ExternalRouteRef; - /** * A duplicate of the react-router RouteObject, but with routeRef added * @internal From 08bf1db3fe21c6f10c27abf54ea9bef8e5b9aa17 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Oct 2023 21:06:06 +0200 Subject: [PATCH 04/34] frontend-plugin-api: refactor SubRouteRef to modern structure Signed-off-by: Patrik Oldsberg --- .../src/routing/SubRouteRef.test.ts | 79 ++++++----- .../src/routing/SubRouteRef.ts | 133 +++++++++++++----- .../frontend-plugin-api/src/routing/types.ts | 19 --- 3 files changed, 141 insertions(+), 90 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts index cec00de025..54d54f9cf8 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts @@ -14,24 +14,29 @@ * limitations under the License. */ -import { AnyParams, SubRouteRef } from './types'; -import { createSubRouteRef } from './SubRouteRef'; +import { AnyRouteParams } from './types'; +import { + SubRouteRef, + createSubRouteRef, + toInternalSubRouteRef, +} from './SubRouteRef'; import { createRouteRef } from './RouteRef'; -const parent = createRouteRef({ id: 'parent' }); -const parentX = createRouteRef({ id: 'parent-x', params: ['x'] }); +const parent = createRouteRef(); +const parentX = createRouteRef({ params: ['x'] }); describe('SubRouteRef', () => { it('should be created', () => { - const routeRef: SubRouteRef = createSubRouteRef({ + 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}'); + const internal = toInternalSubRouteRef(routeRef); + expect(internal.path).toBe('/foo'); + expect(internal.getParent()).toBe(parent); + expect(internal.getParams()).toEqual([]); + expect(String(internal)).toBe('SubRouteRef{}'); }); it('should be created with params', () => { @@ -40,9 +45,10 @@ describe('SubRouteRef', () => { id: 'my-other-route-ref', path: '/foo/:bar', }); - expect(routeRef.path).toBe('/foo/:bar'); - expect(routeRef.parent).toBe(parent); - expect(routeRef.params).toEqual(['bar']); + const internal = toInternalSubRouteRef(routeRef); + expect(internal.path).toBe('/foo/:bar'); + expect(internal.getParent()).toBe(parent); + expect(internal.getParams()).toEqual(['bar']); }); it('should be created with merged params', () => { @@ -55,9 +61,10 @@ describe('SubRouteRef', () => { 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']); + const internal = toInternalSubRouteRef(routeRef); + expect(internal.path).toBe('/foo/:y/:z'); + expect(internal.getParent()).toBe(parentX); + expect(internal.getParams()).toEqual(['x', 'y', 'z']); }); it('should be created with params from parent', () => { @@ -66,9 +73,10 @@ describe('SubRouteRef', () => { id: 'my-other-route-ref', path: '/foo/bar', }); - expect(routeRef.path).toBe('/foo/bar'); - expect(routeRef.parent).toBe(parentX); - expect(routeRef.params).toEqual(['x']); + const internal = toInternalSubRouteRef(routeRef); + expect(internal.path).toBe('/foo/bar'); + expect(internal.getParent()).toBe(parentX); + expect(internal.getParams()).toEqual(['x']); }); it.each([ @@ -88,39 +96,42 @@ describe('SubRouteRef', () => { }); it('should properly infer and parse path parameters', () => { - function validateType(_ref: SubRouteRef) {} + function checkSubRouteRef( + _ref: SubRouteRef, + _params: T extends undefined ? undefined : T, + ) {} const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' }); // @ts-expect-error - validateType<{ x: string }>(_1); - validateType(_1); + checkSubRouteRef(_1, { x: '' }); + checkSubRouteRef(_1, undefined); const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' }); // @ts-expect-error - validateType(_2); + checkSubRouteRef(_2, undefined); // @ts-expect-error - validateType<{ x: string; z: string }>(_2); + checkSubRouteRef(_2, { x: '', z: '' }); // @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); + checkSubRouteRef(_2, { y: '' }); + // @ts-expect-error + checkSubRouteRef(_2, { x: '', y: '', z: '' }); + checkSubRouteRef(_2, { x: '', y: '' }); const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' }); // @ts-expect-error - validateType(_3); + checkSubRouteRef(_3, undefined); // @ts-expect-error - validateType<{ y: string }>(_3); - validateType<{ x: string }>(_3); + checkSubRouteRef(_3, { y: '' }); + checkSubRouteRef(_3, { x: '' }); const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' }); // @ts-expect-error - validateType(_4); + checkSubRouteRef(_4, undefined); // @ts-expect-error - validateType<{ x: string; z: string }>(_4); + checkSubRouteRef(_4, { x: '', z: '' }); // @ts-expect-error - validateType<{ y: string }>(_4); - validateType<{ x: string; y: string }>(_4); + checkSubRouteRef(_4, { y: '' }); + checkSubRouteRef(_4, { x: '', y: '' }); // 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 index 14a614c2ba..ed03c17ada 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts @@ -14,43 +14,93 @@ * limitations under the License. */ -import { - AnyParams, - OptionalParams, - ParamKeys, - RouteRef, - routeRefType, - SubRouteRef, -} from './types'; +import { RouteRef, toInternalRouteRef } from './RouteRef'; +import { AnyRouteParams } from './types'; // Should match the pattern in react-router const PARAM_PATTERN = /^\w+$/; /** - * @internal + * Descriptor of a route relative to an absolute {@link RouteRef}. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @public */ -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'; +export interface SubRouteRef { + readonly $$type: '@backstage/SubRouteRef'; - constructor( - private readonly id: string, - readonly path: string, - readonly parent: RouteRef, - readonly params: ParamKeys, - ) {} + readonly T: TParams; + + readonly path: string; +} + +/** @internal */ +export interface InternalSubRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, +> extends SubRouteRef { + readonly version: 'v1'; + + getParams(): string[]; + getParent(): RouteRef; +} + +/** @internal */ +export function toInternalSubRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, +>(resource: SubRouteRef): InternalSubRouteRef { + const r = resource as InternalSubRouteRef; + if (r.$$type !== '@backstage/SubRouteRef') { + throw new Error(`Invalid SubRouteRef, bad type '${r.$$type}'`); + } + + return r; +} + +/** @internal */ +export function isSubRouteRef(opaque: { + $$type: string; +}): opaque is SubRouteRef { + return opaque.$$type === '@backstage/SubRouteRef'; +} + +/** @internal */ +export class SubRouteRefImpl + implements SubRouteRef +{ + readonly $$type = '@backstage/SubRouteRef'; + readonly version = 'v1'; + + #params: string[]; + #parent: RouteRef; + + constructor(readonly path: string, params: string[], parent: RouteRef) { + this.#params = params; + this.#parent = parent; + } + + get T(): never { + throw new Error(`tried to read RouteRef.T of ${this}`); + } + + getParams(): string[] { + return this.#params; + } + + getParent(): RouteRef { + return this.#parent; + } toString() { - return `routeRef{type=sub,id=${this.id}}`; + return `SubRouteRef{}`; } } /** * Used in {@link PathParams} type declaration. - * @public + * @ignore */ export type ParamPart = S extends `:${infer Param}` ? Param @@ -58,7 +108,7 @@ export type ParamPart = S extends `:${infer Param}` /** * Used in {@link PathParams} type declaration. - * @public + * @ignore */ export type ParamNames = S extends `${infer Part}/${infer Rest}` @@ -67,30 +117,37 @@ export type ParamNames = /** * 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 + * @ignore */ export type PathParams = { [name in ParamNames]: string }; /** * Merges a param object type with an optional params type into a params object. - * @public + * @ignore */ export type MergeParams< P1 extends { [param in string]: string }, - P2 extends AnyParams, + P2 extends AnyRouteParams, > = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); +/** + * Convert empty params to undefined. + * @ignore + */ +export type TrimEmptyParams = + keyof Params extends never ? undefined : Params; + /** * 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 + * @ignore */ export type MakeSubRouteRef< Params extends { [param in string]: string }, - ParentParams extends AnyParams, + ParentParams extends AnyRouteParams, > = keyof Params & keyof ParentParams extends never - ? SubRouteRef>> + ? SubRouteRef>> : never; /** @@ -101,23 +158,26 @@ export type MakeSubRouteRef< */ export function createSubRouteRef< Path extends string, - ParentParams extends AnyParams = never, + ParentParams extends AnyRouteParams = never, >(config: { id: string; path: Path; parent: RouteRef; }): MakeSubRouteRef, ParentParams> { - const { id, path, parent } = config; + const { path, parent } = config; type Params = PathParams; + const internalParent = toInternalRouteRef(parent); + const parentParams = internalParent.getParams(); + // 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]; + const params = [...parentParams, ...pathParams]; - if (parent.params.some(p => pathParams.includes(p as string))) { + if (parentParams.some(p => pathParams.includes(p as string))) { throw new Error( 'SubRouteRef may not have params that overlap with its parent', ); @@ -136,11 +196,10 @@ export function createSubRouteRef< // We ensure that the type of the return type is sane here const subRouteRef = new SubRouteRefImpl( - id, path, + params as string[], parent, - params as ParamKeys>, - ) as SubRouteRef>>; + ) 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. diff --git a/packages/frontend-plugin-api/src/routing/types.ts b/packages/frontend-plugin-api/src/routing/types.ts index b9893c1fdf..d5afc9213c 100644 --- a/packages/frontend-plugin-api/src/routing/types.ts +++ b/packages/frontend-plugin-api/src/routing/types.ts @@ -70,25 +70,6 @@ export type OptionalParams = export type ParamKeys = keyof TParams extends never ? [] : (keyof TParams)[]; -/** - * 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. * From 720b6cda3db10ae16aab51a0659914735aaca5b1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 00:52:41 +0200 Subject: [PATCH 05/34] frontend-plugin-api: refactor ExternalRouteRef to modern structure Signed-off-by: Patrik Oldsberg --- .../src/routing/ExternalRouteRef.test.ts | 105 +++++++-------- .../src/routing/ExternalRouteRef.ts | 127 ++++++++++++------ .../frontend-plugin-api/src/routing/types.ts | 20 --- 3 files changed, 138 insertions(+), 114 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts index f6e072f760..a38937775c 100644 --- a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts @@ -14,97 +14,94 @@ * limitations under the License. */ -import { AnyParams, ExternalRouteRef } from './types'; -import { createExternalRouteRef } from './ExternalRouteRef'; +import { + ExternalRouteRef, + createExternalRouteRef, + toInternalExternalRouteRef, +} from './ExternalRouteRef'; +import { AnyRouteParams } from './types'; 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}'); + const routeRef: ExternalRouteRef = createExternalRouteRef(); + const internal = toInternalExternalRouteRef(routeRef); + expect(internal.getParams()).toEqual([]); + expect(internal.optional).toBe(false); + expect(String(internal)).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', + const routeRef: ExternalRouteRef = createExternalRouteRef({ params: [], optional: true, }); - expect(routeRef.params).toEqual([]); - expect(routeRef.optional).toEqual(true); + const internal = toInternalExternalRouteRef(routeRef); + expect(internal.getParams()).toEqual([]); + expect(internal.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); + }> = createExternalRouteRef({ params: ['x', 'y'] }); + const internal = toInternalExternalRouteRef(routeRef); + expect(internal.getParams()).toEqual(['x', 'y']); + expect(internal.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); + }> = createExternalRouteRef({ params: ['x', 'y'], optional: true }); + const internal = toInternalExternalRouteRef(routeRef); + expect(internal.getParams()).toEqual(['x', 'y']); + expect(internal.optional).toEqual(true); }); it('should properly infer and validate parameter types and assignments', () => { - function validateType( - _ref: ExternalRouteRef, + function checkRouteRef< + T extends AnyRouteParams, + TOptional extends boolean, + TCheck extends TOptional, + >( + _ref: ExternalRouteRef, + _params: T extends undefined ? undefined : T, + _optional: TCheck, ) {} - const _1 = createExternalRouteRef({ id: '1', params: ['notX'] }); + const _1 = createExternalRouteRef({ params: ['notX'] }); + checkRouteRef(_1, { notX: '' }, false); // @ts-expect-error - validateType<{ x: string }, any>(_1); - validateType<{ notX: string }, any>(_1); + checkRouteRef(_1, { x: '' }, false); - const _2 = createExternalRouteRef({ - id: '2', - params: ['x'], - optional: true, - }); + const _2 = createExternalRouteRef({ params: ['x'], optional: true }); + checkRouteRef(_2, { x: '' }, true); // @ts-expect-error - validateType(_2); - validateType<{ x: string }, true>(_2); + checkRouteRef(_2, undefined, false); - const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] }); + const _3 = createExternalRouteRef({ params: ['x', 'y'] }); + checkRouteRef(_3, { x: '', y: '' }, false); // @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); + checkRouteRef(_3, { x: '' }, false); + // @ts-expect-error + checkRouteRef(_3, { x: '', y: '', z: '' }, false); - const _4 = createExternalRouteRef({ id: '4', params: [] }); + const _4 = createExternalRouteRef({ params: [] }); + checkRouteRef(_4, undefined, false); // @ts-expect-error - validateType<{ x: string }, any>(_4); - validateType(_4); + checkRouteRef(_4, { x: '' }); - const _5 = createExternalRouteRef({ id: '5' }); + const _5 = createExternalRouteRef(); + checkRouteRef(_5, undefined, false); // @ts-expect-error - validateType<{ x: string }, any>(_5); - validateType(_5); + checkRouteRef(_5, { x: '' }); - const _6 = createExternalRouteRef({ id: '6', optional: true }); + const _6 = createExternalRouteRef({ optional: true }); + checkRouteRef(_6, undefined, true); // @ts-expect-error - validateType(_6); - validateType(_6); + checkRouteRef(_6, undefined, false); // 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 index 2ebb81dbfb..edc546ee23 100644 --- a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts @@ -14,34 +14,78 @@ * limitations under the License. */ -import { - ExternalRouteRef, - routeRefType, - AnyParams, - ParamKeys, - OptionalParams, -} from './types'; +import { AnyRouteParams } from './types'; /** - * @internal + * 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 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'; +export interface ExternalRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, + TOptional extends boolean = boolean, +> { + readonly $$type: '@backstage/ExternalRouteRef'; + readonly T: TParams; + readonly optional: TOptional; +} - constructor( - private readonly id: string, - readonly params: ParamKeys, - readonly optional: Optional, - ) {} +/** @internal */ +export interface InternalExternalRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, + TOptional extends boolean = boolean, +> extends ExternalRouteRef { + readonly version: 'v1'; + getParams(): string[]; +} - toString() { - return `routeRef{type=external,id=${this.id}}`; +/** @internal */ +export function toInternalExternalRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, + TOptional extends boolean = boolean, +>( + resource: ExternalRouteRef, +): InternalExternalRouteRef { + const r = resource as InternalExternalRouteRef; + if (r.$$type !== '@backstage/ExternalRouteRef') { + throw new Error(`Invalid ExternalRouteRef, bad type '${r.$$type}'`); + } + + return r; +} + +/** @internal */ +export function isExternalRouteRef(opaque: { + $$type: string; +}): opaque is ExternalRouteRef { + return opaque.$$type === '@backstage/ExternalRouteRef'; +} + +/** @internal */ +export class ExternalRouteRefImpl implements InternalExternalRouteRef { + readonly $$type = '@backstage/ExternalRouteRef'; + readonly version = 'v1'; + + #params: string[]; + + constructor(readonly optional: boolean, readonly params: string[] = []) { + this.#params = params; + } + + get T(): never { + throw new Error(`tried to read ExternalRouteRef.T of ${this}`); + } + + getParams(): string[] { + return this.#params; + } + + toString(): string { + return `ExternalRouteRef{}`; } } @@ -56,31 +100,34 @@ export class ExternalRouteRefImpl< * @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; - + TParams extends { [param in TParamKeys]: string } | undefined = undefined, + TOptional extends boolean = false, + TParamKeys extends string = string, +>(options?: { /** * The parameters that will be provided to the external route reference. */ - params?: ParamKey[]; + readonly params?: string extends TParamKeys + ? (keyof TParams)[] + : TParamKeys[]; /** * 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`. + * if they aren't, `useExternalRouteRef` will return `undefined`. */ - optional?: Optional; -}): ExternalRouteRef, Optional> { + optional?: TOptional; +}): ExternalRouteRef< + keyof TParams extends never + ? undefined + : string extends TParamKeys + ? TParams + : { [param in TParamKeys]: string }, + TOptional +> { return new ExternalRouteRefImpl( - options.id, - (options.params ?? []) as ParamKeys>, - Boolean(options.optional) as Optional, - ); + Boolean(options?.optional), + options?.params as string[] | undefined, + ) as ExternalRouteRef; } diff --git a/packages/frontend-plugin-api/src/routing/types.ts b/packages/frontend-plugin-api/src/routing/types.ts index d5afc9213c..2eaa25ba94 100644 --- a/packages/frontend-plugin-api/src/routing/types.ts +++ b/packages/frontend-plugin-api/src/routing/types.ts @@ -70,26 +70,6 @@ export type OptionalParams = export type ParamKeys = keyof TParams extends never ? [] : (keyof TParams)[]; -/** - * 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; -}; - /** * A duplicate of the react-router RouteObject, but with routeRef added * @internal From 49b0d0dfa9c4dcd641de738f7bf15a6001050210 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 11:01:10 +0200 Subject: [PATCH 06/34] frontend-plugin-api: add helper for locating the callsite of a function Signed-off-by: Patrik Oldsberg --- .../routing/describeParentCallSite.test.ts | 84 +++++++++++++++++++ .../src/routing/describeParentCallSite.ts | 59 +++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 packages/frontend-plugin-api/src/routing/describeParentCallSite.test.ts create mode 100644 packages/frontend-plugin-api/src/routing/describeParentCallSite.ts diff --git a/packages/frontend-plugin-api/src/routing/describeParentCallSite.test.ts b/packages/frontend-plugin-api/src/routing/describeParentCallSite.test.ts new file mode 100644 index 0000000000..03a94dcb45 --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/describeParentCallSite.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2023 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 { describeParentCallSite } from './describeParentCallSite'; + +class ChromeError { + name = 'Error'; + message = 'eHgtF5hmbrXyiEvo'; + get stack() { + return `Error: eHgtF5hmbrXyiEvo + at describeParentCallSite (describeParentCallSite.js:1:11) + at parent (parent.js:2:22) + at caller (parent-caller.js:3:33) + at other.js:3:33`; + } +} + +class SafariError { + name = 'Error'; + message = 'eHgtF5hmbrXyiEvo'; + get stack() { + return `describeParentCallSite@describeParentCallSite.js:1:11 +parent@parent.js:2:22 +caller@parent-caller.js:3:33 +code@other.js:3:33`; + } +} + +class FirefoxError { + name = 'Error'; + message = 'eHgtF5hmbrXyiEvo'; + get stack() { + return `describeParentCallSite@describeParentCallSite.js:1:11 +parent@parent.js:2:22 +caller@parent-caller.js:3:33 +@other.js:3:33 +`; + } +} + +describe('describeParentCallSite', () => { + it('should work in Chrome', () => { + function myFactory() { + return describeParentCallSite(ChromeError); + } + function myCaller() { + return myFactory(); + } + expect(myCaller()).toBe('parent-caller.js:3:33'); + }); + + it('should work in Safari', () => { + function myFactory() { + return describeParentCallSite(SafariError); + } + function myCaller() { + return myFactory(); + } + expect(myCaller()).toBe('parent-caller.js:3:33'); + }); + + it('should work in Firefox', () => { + function myFactory() { + return describeParentCallSite(FirefoxError); + } + function myCaller() { + return myFactory(); + } + expect(myCaller()).toBe('parent-caller.js:3:33'); + }); +}); diff --git a/packages/frontend-plugin-api/src/routing/describeParentCallSite.ts b/packages/frontend-plugin-api/src/routing/describeParentCallSite.ts new file mode 100644 index 0000000000..72997e07db --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/describeParentCallSite.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2023 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. + */ + +const MESSAGE_MARKER = 'eHgtF5hmbrXyiEvo'; + +/** + * Internal helper that describes the location of the parent caller. + * @internal + */ +export function describeParentCallSite( + ErrorConstructor: { new (message: string): Error } = Error, +): string { + const { stack } = new ErrorConstructor(MESSAGE_MARKER); + if (!stack) { + return ''; + } + + // Safari and Firefox don't include the error itself in the stack + const startIndex = stack.includes(MESSAGE_MARKER) + ? stack.indexOf('\n') + 1 + : 0; + const secondEntryStart = + stack.indexOf('\n', stack.indexOf('\n', startIndex) + 1) + 1; + const secondEntryEnd = stack.indexOf('\n', secondEntryStart); + + const line = stack.substring(secondEntryStart, secondEntryEnd).trim(); + if (!line) { + return 'unknown'; + } + + // Below we try to extract the location for different browsers. + // Since RouteRefs are declared at the top-level of modules the caller name isn't interesting. + + // Chrome + if (line.includes('(')) { + return line.substring(line.indexOf('(') + 1, line.indexOf(')')); + } + + // Safari & Firefox + if (line.includes('@')) { + return line.substring(line.indexOf('@') + 1); + } + + // Give up + return line; +} From a0c90a23ad0e4cec39149f306a28f4f50acde3c2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 11:35:10 +0200 Subject: [PATCH 07/34] frontend-plugin-api: add ID and description for RouteRef Signed-off-by: Patrik Oldsberg --- .../src/routing/RouteRef.test.ts | 30 ++++++++++++++--- .../src/routing/RouteRef.ts | 32 +++++++++++++++++-- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts index b8e86d1b99..cb1c85ccea 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts @@ -15,13 +15,30 @@ */ import { AnyRouteParams } from './types'; -import { RouteRef, createRouteRef } from './RouteRef'; +import { RouteRef, createRouteRef, toInternalRouteRef } from './RouteRef'; describe('RouteRef', () => { - it('should be created', () => { + it('should be created and have a mutable ID', () => { const routeRef: RouteRef = createRouteRef(); - expect(() => routeRef.T).toThrow(); - expect(String(routeRef)).toBe('RouteRef{}'); + const internal = toInternalRouteRef(routeRef); + expect(() => internal.T).toThrow(); + expect(internal.getParams()).toEqual([]); + expect(internal.getDescription()).toMatch(/RouteRef\.test\.ts/); + + expect(String(internal)).toMatch( + /^RouteRef\{created at .*RouteRef\.test\.ts.*\}$/, + ); + + expect(() => internal.setId('')).toThrow( + 'RouteRef id must be a non-empty string', + ); + + internal.setId('some-id'); + expect(String(internal)).toBe('RouteRef{some-id}'); + + expect(() => internal.setId('some-other-id')).toThrow( + "RouteRef was referenced twice as both 'some-id' and 'some-other-id'", + ); }); it('should be created with params', () => { @@ -31,7 +48,10 @@ describe('RouteRef', () => { }> = createRouteRef({ params: ['x', 'y'], }); - expect(() => routeRef.T).toThrow(); + const internal = toInternalRouteRef(routeRef); + expect(internal.getParams()).toEqual(['x', 'y']); + expect(internal.getDescription()).toMatch(/RouteRef\.test\.ts/); + expect(() => internal.T).toThrow(); }); it('should properly infer and validate parameter types and assignments', () => { diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.ts b/packages/frontend-plugin-api/src/routing/RouteRef.ts index b19b77d298..5f1f7a135d 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { describeParentCallSite } from './describeParentCallSite'; import { AnyRouteParams } from './types'; /** @@ -36,6 +37,9 @@ export interface InternalRouteRef< > extends RouteRef { readonly version: 'v1'; getParams(): string[]; + getDescription(): string; + + setId(id: string): void; } /** @internal */ @@ -60,10 +64,13 @@ export class RouteRefImpl implements InternalRouteRef { readonly $$type = '@backstage/RouteRef'; readonly version = 'v1'; + #id?: string; #params: string[]; + #creationSite: string; - constructor(readonly params: string[] = []) { + constructor(readonly params: string[] = [], creationSite: string) { this.#params = params; + this.#creationSite = creationSite; } get T(): never { @@ -74,8 +81,27 @@ export class RouteRefImpl implements InternalRouteRef { return this.#params; } + getDescription(): string { + if (this.#id) { + return this.#id; + } + return `created at '${this.#creationSite}'`; + } + + setId(id: string): void { + if (!id) { + throw new Error('RouteRef id must be a non-empty string'); + } + if (this.#id) { + throw new Error( + `RouteRef was referenced twice as both '${this.#id}' and '${id}'`, + ); + } + this.#id = id; + } + toString(): string { - return `RouteRef{}`; + return `RouteRef{${this.getDescription()}}`; } } @@ -100,7 +126,9 @@ export function createRouteRef< ? TParams : { [param in TParamKeys]: string } > { + const creationSite = describeParentCallSite(); return new RouteRefImpl( config?.params as string[] | undefined, + creationSite, ) as RouteRef; } From c82477ae81ec55237b59da1e86b144269eb2e918 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 11:53:34 +0200 Subject: [PATCH 08/34] frontend-plugin-api: add description for SubRouteRefs Signed-off-by: Patrik Oldsberg --- .../src/routing/SubRouteRef.test.ts | 13 +++++++++---- .../frontend-plugin-api/src/routing/SubRouteRef.ts | 10 ++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts index 54d54f9cf8..bf38bacbf5 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts @@ -20,23 +20,28 @@ import { createSubRouteRef, toInternalSubRouteRef, } from './SubRouteRef'; -import { createRouteRef } from './RouteRef'; +import { createRouteRef, toInternalRouteRef } from './RouteRef'; const parent = createRouteRef(); const parentX = createRouteRef({ params: ['x'] }); describe('SubRouteRef', () => { it('should be created', () => { + const internalParent = toInternalRouteRef(createRouteRef()); const routeRef: SubRouteRef = createSubRouteRef({ - parent, + parent: internalParent, id: 'my-route-ref', path: '/foo', }); const internal = toInternalSubRouteRef(routeRef); expect(internal.path).toBe('/foo'); - expect(internal.getParent()).toBe(parent); + expect(internal.getParent()).toBe(internalParent); expect(internal.getParams()).toEqual([]); - expect(String(internal)).toBe('SubRouteRef{}'); + expect(String(internal)).toMatch( + /SubRouteRef\{at \/foo with parent created at '.*SubRouteRef\.test\.ts.*'\}/, + ); + internalParent.setId('some-id'); + expect(String(internal)).toBe('SubRouteRef{at /foo with parent some-id}'); }); it('should be created with params', () => { diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts index ed03c17ada..a4becf5e44 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts @@ -45,6 +45,7 @@ export interface InternalSubRouteRef< getParams(): string[]; getParent(): RouteRef; + getDescription(): string; } /** @internal */ @@ -93,8 +94,13 @@ export class SubRouteRefImpl return this.#parent; } - toString() { - return `SubRouteRef{}`; + getDescription(): string { + const parent = toInternalRouteRef(this.#parent); + return `at ${this.path} with parent ${parent.getDescription()}`; + } + + toString(): string { + return `SubRouteRef{${this.getDescription()}}`; } } From 3ad24292094dbcdca99c1a821dbeacc854aad1dc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 12:03:53 +0200 Subject: [PATCH 09/34] frontend-plugin-api: add description and ID for ExternalRouteRef Signed-off-by: Patrik Oldsberg --- .../src/routing/ExternalRouteRef.test.ts | 7 +++- .../src/routing/ExternalRouteRef.ts | 36 +++++++++---------- .../src/routing/RouteRef.ts | 13 ++++--- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts index a38937775c..ad47941833 100644 --- a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts @@ -27,7 +27,12 @@ describe('ExternalRouteRef', () => { const internal = toInternalExternalRouteRef(routeRef); expect(internal.getParams()).toEqual([]); expect(internal.optional).toBe(false); - expect(String(internal)).toBe('routeRef{type=external,id=my-route-ref}'); + + expect(String(internal)).toMatch( + /^ExternalRouteRef\{created at '.*ExternalRouteRef\.test\.ts.*'\}$/, + ); + internal.setId('some-id'); + expect(String(internal)).toBe('ExternalRouteRef{some-id}'); }); it('should be created as optional', () => { diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts index edc546ee23..ad5ad05f05 100644 --- a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { RouteRefImpl } from './RouteRef'; +import { describeParentCallSite } from './describeParentCallSite'; import { AnyRouteParams } from './types'; /** @@ -41,6 +43,9 @@ export interface InternalExternalRouteRef< > extends ExternalRouteRef { readonly version: 'v1'; getParams(): string[]; + getDescription(): string; + + setId(id: string): void; } /** @internal */ @@ -66,26 +71,18 @@ export function isExternalRouteRef(opaque: { } /** @internal */ -export class ExternalRouteRefImpl implements InternalExternalRouteRef { - readonly $$type = '@backstage/ExternalRouteRef'; - readonly version = 'v1'; +class ExternalRouteRefImpl + extends RouteRefImpl + implements InternalExternalRouteRef +{ + readonly $$type = '@backstage/ExternalRouteRef' as any; - #params: string[]; - - constructor(readonly optional: boolean, readonly params: string[] = []) { - this.#params = params; - } - - get T(): never { - throw new Error(`tried to read ExternalRouteRef.T of ${this}`); - } - - getParams(): string[] { - return this.#params; - } - - toString(): string { - return `ExternalRouteRef{}`; + constructor( + readonly optional: boolean, + readonly params: string[] = [], + creationSite: string, + ) { + super(params, creationSite); } } @@ -129,5 +126,6 @@ export function createExternalRouteRef< return new ExternalRouteRefImpl( Boolean(options?.optional), options?.params as string[] | undefined, + describeParentCallSite(), ) as ExternalRouteRef; } diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.ts b/packages/frontend-plugin-api/src/routing/RouteRef.ts index 5f1f7a135d..f8ca088d0f 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.ts @@ -88,20 +88,24 @@ export class RouteRefImpl implements InternalRouteRef { return `created at '${this.#creationSite}'`; } + get #name() { + return this.$$type.slice('@backstage/'.length); + } + setId(id: string): void { if (!id) { - throw new Error('RouteRef id must be a non-empty string'); + throw new Error(`${this.#name} id must be a non-empty string`); } if (this.#id) { throw new Error( - `RouteRef was referenced twice as both '${this.#id}' and '${id}'`, + `${this.#name} was referenced twice as both '${this.#id}' and '${id}'`, ); } this.#id = id; } toString(): string { - return `RouteRef{${this.getDescription()}}`; + return `${this.#name}{${this.getDescription()}}`; } } @@ -126,9 +130,8 @@ export function createRouteRef< ? TParams : { [param in TParamKeys]: string } > { - const creationSite = describeParentCallSite(); return new RouteRefImpl( config?.params as string[] | undefined, - creationSite, + describeParentCallSite(), ) as RouteRef; } From ae212c91699bafeb96b65cd827572da88c6ba294 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 12:05:41 +0200 Subject: [PATCH 10/34] frontend-plugin-api: cleanup common and useRouteRef type declarations Signed-off-by: Patrik Oldsberg --- .../frontend-plugin-api/src/routing/types.ts | 49 -------------- .../src/routing/useRouteRef.tsx | 65 ++++++++++++------- .../src/routing/useRouteRefParams.ts | 6 +- 3 files changed, 46 insertions(+), 74 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/types.ts b/packages/frontend-plugin-api/src/routing/types.ts index 2eaa25ba94..583bc4f815 100644 --- a/packages/frontend-plugin-api/src/routing/types.ts +++ b/packages/frontend-plugin-api/src/routing/types.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; - /** * Catch-all type for route params. * @@ -23,53 +21,6 @@ import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; */ export type AnyRouteParams = { [param in string]: string } | undefined; -/** - * 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'), -); - -/** - * Optional route params. - * - * @internal - */ -export type OptionalParams = - Params[keyof Params] extends never ? undefined : Params; - -/** - * Type describing the key type of a route parameter mapping. - * - * @ignore - */ -export type ParamKeys = - keyof TParams extends never ? [] : (keyof TParams)[]; - /** * A duplicate of the react-router RouteObject, but with routeRef added * @internal diff --git a/packages/frontend-plugin-api/src/routing/useRouteRef.tsx b/packages/frontend-plugin-api/src/routing/useRouteRef.tsx index cf292b110f..3754d2e6d5 100644 --- a/packages/frontend-plugin-api/src/routing/useRouteRef.tsx +++ b/packages/frontend-plugin-api/src/routing/useRouteRef.tsx @@ -17,25 +17,41 @@ 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'; +import { AnyRouteParams } from './types'; +import { RouteRef } from './RouteRef'; +import { SubRouteRef } from './SubRouteRef'; +import { ExternalRouteRef } from './ExternalRouteRef'; + +/** + * 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]: TParams extends undefined + ? readonly [] + : readonly [params: TParams] +) => string; /** * @internal */ export interface RouteResolver { - resolve( + resolve( anyRouteRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, + | RouteRef + | SubRouteRef + | ExternalRouteRef, sourceLocation: Parameters[1], - ): RouteFunc | undefined; + ): RouteFunc | undefined; } /** @@ -49,9 +65,12 @@ export interface RouteResolver { * @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; +export function useRouteRef< + TOptional extends boolean, + TParams extends AnyRouteParams, +>( + routeRef: ExternalRouteRef, +): TParams extends true ? RouteFunc | undefined : RouteFunc; /** * React hook for constructing URLs to routes. @@ -64,9 +83,9 @@ export function useRouteRef( * @returns A function that will in turn return the concrete URL of the `routeRef`. * @public */ -export function useRouteRef( - routeRef: RouteRef | SubRouteRef, -): RouteFunc; +export function useRouteRef( + routeRef: RouteRef | SubRouteRef, +): RouteFunc; /** * React hook for constructing URLs to routes. @@ -79,12 +98,12 @@ export function useRouteRef( * @returns A function that will in turn return the concrete URL of the `routeRef`. * @public */ -export function useRouteRef( +export function useRouteRef( routeRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, -): RouteFunc | undefined { + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): RouteFunc | undefined { const { pathname } = useLocation(); const versionedContext = useVersionedContext<{ 1: RouteResolver }>( 'routing-context', diff --git a/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts b/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts index e24576372b..dbe34a4d01 100644 --- a/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts +++ b/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts @@ -15,14 +15,16 @@ */ import { useParams } from 'react-router-dom'; -import { RouteRef, AnyParams, SubRouteRef } from './types'; +import { AnyRouteParams } from './types'; +import { RouteRef } from './RouteRef'; +import { SubRouteRef } from './SubRouteRef'; /** * React hook for retrieving dynamic params from the current URL. * @param _routeRef - Ref of the current route. * @public */ -export function useRouteRefParams( +export function useRouteRefParams( _routeRef: RouteRef | SubRouteRef, ): Params { return useParams() as Params; From 61c59aadd9c9fb330affdc3bfeec130f446871f7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 12:07:17 +0200 Subject: [PATCH 11/34] frontend-plugin-api: cleanup routing index exports Signed-off-by: Patrik Oldsberg --- .../frontend-plugin-api/src/routing/index.ts | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/index.ts b/packages/frontend-plugin-api/src/routing/index.ts index 01d69cd4b0..d262bbed58 100644 --- a/packages/frontend-plugin-api/src/routing/index.ts +++ b/packages/frontend-plugin-api/src/routing/index.ts @@ -14,24 +14,12 @@ * 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 type { AnyRouteParams } from './types'; +export { createRouteRef, type RouteRef } from './RouteRef'; +export { createSubRouteRef, type SubRouteRef } from './SubRouteRef'; +export { + createExternalRouteRef, + type ExternalRouteRef, +} from './ExternalRouteRef'; +export { useRouteRef, type RouteFunc } from './useRouteRef'; export { useRouteRefParams } from './useRouteRefParams'; From b0e3809acc5485d7381f7895a4026756ca92f39d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 13:57:53 +0200 Subject: [PATCH 12/34] frontend-plugin-api: remove id from SubRouteRef Signed-off-by: Patrik Oldsberg --- .../src/routing/SubRouteRef.test.ts | 16 +++++----------- .../src/routing/SubRouteRef.ts | 1 - 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts index bf38bacbf5..7e8efe7842 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts @@ -30,7 +30,6 @@ describe('SubRouteRef', () => { const internalParent = toInternalRouteRef(createRouteRef()); const routeRef: SubRouteRef = createSubRouteRef({ parent: internalParent, - id: 'my-route-ref', path: '/foo', }); const internal = toInternalSubRouteRef(routeRef); @@ -47,7 +46,6 @@ describe('SubRouteRef', () => { it('should be created with params', () => { const routeRef: SubRouteRef<{ bar: string }> = createSubRouteRef({ parent, - id: 'my-other-route-ref', path: '/foo/:bar', }); const internal = toInternalSubRouteRef(routeRef); @@ -63,7 +61,6 @@ describe('SubRouteRef', () => { z: string; }> = createSubRouteRef({ parent: parentX, - id: 'my-other-route-ref', path: '/foo/:y/:z', }); const internal = toInternalSubRouteRef(routeRef); @@ -75,7 +72,6 @@ describe('SubRouteRef', () => { it('should be created with params from parent', () => { const routeRef: SubRouteRef<{ x: string }> = createSubRouteRef({ parent: parentX, - id: 'my-other-route-ref', path: '/foo/bar', }); const internal = toInternalSubRouteRef(routeRef); @@ -95,9 +91,7 @@ describe('SubRouteRef', () => { ['/: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); + expect(() => createSubRouteRef({ path, parent: parentX })).toThrow(message); }); it('should properly infer and parse path parameters', () => { @@ -106,12 +100,12 @@ describe('SubRouteRef', () => { _params: T extends undefined ? undefined : T, ) {} - const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' }); + const _1 = createSubRouteRef({ parent, path: '/foo/bar' }); // @ts-expect-error checkSubRouteRef(_1, { x: '' }); checkSubRouteRef(_1, undefined); - const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' }); + const _2 = createSubRouteRef({ parent, path: '/foo/:x/:y' }); // @ts-expect-error checkSubRouteRef(_2, undefined); // @ts-expect-error @@ -122,14 +116,14 @@ describe('SubRouteRef', () => { checkSubRouteRef(_2, { x: '', y: '', z: '' }); checkSubRouteRef(_2, { x: '', y: '' }); - const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' }); + const _3 = createSubRouteRef({ parent: parentX, path: '/foo' }); // @ts-expect-error checkSubRouteRef(_3, undefined); // @ts-expect-error checkSubRouteRef(_3, { y: '' }); checkSubRouteRef(_3, { x: '' }); - const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' }); + const _4 = createSubRouteRef({ parent: parentX, path: '/foo/:y' }); // @ts-expect-error checkSubRouteRef(_4, undefined); // @ts-expect-error diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts index a4becf5e44..3fb3778d6f 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts @@ -166,7 +166,6 @@ export function createSubRouteRef< Path extends string, ParentParams extends AnyRouteParams = never, >(config: { - id: string; path: Path; parent: RouteRef; }): MakeSubRouteRef, ParentParams> { From b22245369547ebbb9015daa3aca63d9108815d51 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 14:02:14 +0200 Subject: [PATCH 13/34] frontend-plugin-api: forward routing exports Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts index 512723b5d4..73abf28ce4 100644 --- a/packages/frontend-plugin-api/src/index.ts +++ b/packages/frontend-plugin-api/src/index.ts @@ -22,5 +22,6 @@ export * from './components'; export * from './extensions'; +export * from './routing'; export * from './schema'; export * from './wiring'; From b9d0b80a98de23cc7563f7d8a167a1275dbe440d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 14:02:47 +0200 Subject: [PATCH 14/34] core-plugin-api: added utility for converting legacy API refs into new format Signed-off-by: Patrik Oldsberg --- packages/core-plugin-api/src/alpha.ts | 1 + .../src/routing/convertLegacyRouteRef.ts | 145 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts diff --git a/packages/core-plugin-api/src/alpha.ts b/packages/core-plugin-api/src/alpha.ts index ebcbcf34c0..540b4ed22a 100644 --- a/packages/core-plugin-api/src/alpha.ts +++ b/packages/core-plugin-api/src/alpha.ts @@ -16,3 +16,4 @@ export * from './translation'; export * from './apis/alpha'; +export { convertLegacyRouteRef } from './routing/convertLegacyRouteRef'; diff --git a/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts b/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts new file mode 100644 index 0000000000..2b84440108 --- /dev/null +++ b/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2023 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 { + routeRefType, + RouteRef as LegacyRouteRef, + SubRouteRef as LegacySubRouteRef, + ExternalRouteRef as LegacyExternalRouteRef, +} from './types'; + +// Relative imports to avoid dependency, at least for now + +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + RouteRef, + SubRouteRef, + ExternalRouteRef, + AnyRouteParams, + createRouteRef, + createSubRouteRef, + createExternalRouteRef, +} from '../../../frontend-plugin-api/src/routing'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalSubRouteRef } from '../../../frontend-plugin-api/src/routing/SubRouteRef'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; + +export function convertLegacyRouteRef( + ref: LegacyRouteRef, +): RouteRef; +export function convertLegacyRouteRef( + ref: LegacySubRouteRef, +): SubRouteRef; +export function convertLegacyRouteRef< + TParams extends AnyRouteParams, + TOptional extends boolean, +>( + ref: LegacyExternalRouteRef, +): ExternalRouteRef; +export function convertLegacyRouteRef( + ref: LegacyRouteRef | LegacySubRouteRef | LegacyExternalRouteRef, +): RouteRef | SubRouteRef | ExternalRouteRef { + // Ref has already been converted + if ('$$type' in ref) { + return ref as unknown as RouteRef | SubRouteRef | ExternalRouteRef; + } + + const type = (ref as unknown as { [routeRefType]: unknown })[routeRefType]; + + if (type === 'absolute') { + const legacyRef = ref as LegacyRouteRef; + const newRef = toInternalRouteRef( + createRouteRef<{ [key in string]: string }>({ + params: legacyRef.params as string[], + }), + ); + return Object.assign(legacyRef, { + $$type: '@backstage/RouteRef' as const, + version: 'v1', + T: newRef.T, + getParams() { + return newRef.getParams(); + }, + getDescription() { + return newRef.getDescription(); + }, + setId(id: string) { + newRef.setId(id); + }, + toString() { + return newRef.toString(); + }, + }); + } + if (type === 'sub') { + const legacyRef = ref as LegacySubRouteRef; + const newRef = toInternalSubRouteRef( + createSubRouteRef({ + path: legacyRef.path, + parent: convertLegacyRouteRef(legacyRef.parent), + }), + ); + return Object.assign(legacyRef, { + $$type: '@backstage/SubRouteRef' as const, + version: 'v1', + T: newRef.T, + getParams() { + return newRef.getParams(); + }, + getParent() { + return newRef.getParent(); + }, + getDescription() { + return newRef.getDescription(); + }, + toString() { + return newRef.toString(); + }, + }); + } + if (type === 'external') { + const legacyRef = ref as LegacyExternalRouteRef; + const newRef = toInternalExternalRouteRef( + createExternalRouteRef<{ [key in string]: string }>({ + params: legacyRef.params as string[], + optional: legacyRef.optional, + }), + ); + return Object.assign(legacyRef, { + $$type: '@backstage/ExternalRouteRef' as const, + version: 'v1', + T: newRef.T, + optional: newRef.optional, + getParams() { + return newRef.getParams(); + }, + getDescription() { + return newRef.getDescription(); + }, + setId(id: string) { + newRef.setId(id); + }, + toString() { + return newRef.toString(); + }, + }); + } + + throw new Error(`Failed to convert legacy route ref, unknown type '${type}'`); +} From bad46b25e1d70ca446b3f996f94bcb915b1e7da4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 14:29:17 +0200 Subject: [PATCH 15/34] core-plugin-api: added docs for convertLegacyRouteRef and note that it's temporary Signed-off-by: Patrik Oldsberg --- .../src/routing/convertLegacyRouteRef.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts b/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts index 2b84440108..9778625355 100644 --- a/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts +++ b/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts @@ -40,12 +40,38 @@ import { toInternalSubRouteRef } from '../../../frontend-plugin-api/src/routing/ // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; +/** + * A temporary helper to convert a legacy route ref to the new system. + * + * @public + * @remarks + * + * In the future the legacy createRouteRef will instead create refs compatible with both systems. + */ export function convertLegacyRouteRef( ref: LegacyRouteRef, ): RouteRef; + +/** + * A temporary helper to convert a legacy sub route ref to the new system. + * + * @public + * @remarks + * + * In the future the legacy createSubRouteRef will instead create refs compatible with both systems. + */ export function convertLegacyRouteRef( ref: LegacySubRouteRef, ): SubRouteRef; + +/** + * A temporary helper to convert a legacy external route ref to the new system. + * + * @public + * @remarks + * + * In the future the legacy createExternalRouteRef will instead create refs compatible with both systems. + */ export function convertLegacyRouteRef< TParams extends AnyRouteParams, TOptional extends boolean, From f4efd3d30a9cce49d1cafc7816ffb6cb0d861998 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 14:29:50 +0200 Subject: [PATCH 16/34] frontend-plugin-api: updates to use new routing API Signed-off-by: Patrik Oldsberg --- .../src/extensions/createNavItemExtension.tsx | 5 +++-- .../src/extensions/createPageExtension.tsx | 2 +- packages/frontend-plugin-api/src/routing/types.ts | 12 ------------ .../src/routing/useRouteRef.test.tsx | 10 +++++----- .../src/routing/useRouteRefParams.test.tsx | 1 - .../src/wiring/coreExtensionData.ts | 4 ++-- .../frontend-plugin-api/src/wiring/createPlugin.ts | 12 +++++++++--- 7 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx b/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx index f208c975b7..ee38dda016 100644 --- a/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ -import { IconComponent, RouteRef } from '@backstage/core-plugin-api'; +import { IconComponent } from '@backstage/core-plugin-api'; import { createSchemaFromZod } from '../schema/createSchemaFromZod'; import { coreExtensionData, createExtension } from '../wiring'; +import { RouteRef } from '../routing'; /** * Helper for creating extensions for a nav item. @@ -24,7 +25,7 @@ import { coreExtensionData, createExtension } from '../wiring'; */ export function createNavItemExtension(options: { id: string; - routeRef: RouteRef; + routeRef: RouteRef; title: string; icon: IconComponent; }) { diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx index 43edfaa36d..217fb33ce5 100644 --- a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { RouteRef } from '@backstage/core-plugin-api'; import React from 'react'; import { ExtensionBoundary } from '../components'; import { createSchemaFromZod, PortableSchema } from '../schema'; @@ -26,6 +25,7 @@ import { } from '../wiring'; import { AnyExtensionInputMap } from '../wiring/createExtension'; import { Expand } from '../types'; +import { RouteRef } from '../routing'; /** * Helper for creating extensions for a routable React page component. diff --git a/packages/frontend-plugin-api/src/routing/types.ts b/packages/frontend-plugin-api/src/routing/types.ts index 583bc4f815..8fe0d7e8a4 100644 --- a/packages/frontend-plugin-api/src/routing/types.ts +++ b/packages/frontend-plugin-api/src/routing/types.ts @@ -20,15 +20,3 @@ * @public */ export type AnyRouteParams = { [param in string]: string } | undefined; - -/** - * 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 index 7a03a1cdc4..c786cd71fa 100644 --- a/packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx +++ b/packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx @@ -33,7 +33,7 @@ describe('v1 consumer', () => { const resolve = jest.fn(() => () => '/hello'); context.set({ 1: { resolve } }); - const routeRef = createRouteRef({ id: 'ref1' }); + const routeRef = createRouteRef(); const renderedHook = renderHook(() => useRouteRef(routeRef), { wrapper: ({ children }: React.PropsWithChildren<{}>) => ( @@ -55,7 +55,7 @@ describe('v1 consumer', () => { const resolve = jest.fn(() => () => '/hello'); context.set({ 1: { resolve } }); - const routeRef = createRouteRef({ id: 'ref1' }); + const routeRef = createRouteRef(); const history = createBrowserHistory(); history.push('/my-page'); @@ -81,7 +81,7 @@ describe('v1 consumer', () => { const resolve = jest.fn(() => () => '/hello'); context.set({ 1: { resolve } }); - const routeRef = createRouteRef({ id: 'ref1' }); + const routeRef = createRouteRef(); const history = createBrowserHistory(); history.push('/my-page'); @@ -107,7 +107,7 @@ describe('v1 consumer', () => { const resolve = jest.fn(() => () => '/hello'); context.set({ 1: { resolve } }); - const routeRef = createRouteRef({ id: 'ref1' }); + const routeRef = createRouteRef(); const history = createBrowserHistory(); history.push('/my-page'); @@ -133,7 +133,7 @@ describe('v1 consumer', () => { const resolve = jest.fn(() => () => '/hello'); context.set({ 1: { resolve } }); - const routeRef = createRouteRef({ id: 'ref1' }); + const routeRef = createRouteRef(); const history = createBrowserHistory(); history.push('/my-page'); diff --git a/packages/frontend-plugin-api/src/routing/useRouteRefParams.test.tsx b/packages/frontend-plugin-api/src/routing/useRouteRefParams.test.tsx index 40ee7219c2..37432b294e 100644 --- a/packages/frontend-plugin-api/src/routing/useRouteRefParams.test.tsx +++ b/packages/frontend-plugin-api/src/routing/useRouteRefParams.test.tsx @@ -23,7 +23,6 @@ import { createRouteRef } from './RouteRef'; describe('useRouteRefParams', () => { it('should provide types params', () => { const routeRef = createRouteRef({ - id: 'ref1', params: ['a', 'b'], }); diff --git a/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts b/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts index 6913501907..023eab57e1 100644 --- a/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts +++ b/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts @@ -19,15 +19,15 @@ import { AnyApiFactory, AppTheme, IconComponent, - RouteRef, } from '@backstage/core-plugin-api'; import { createExtensionDataRef } from './createExtensionDataRef'; +import { RouteRef } from '../routing'; /** @public */ export type NavTarget = { title: string; icon: IconComponent; - routeRef: RouteRef<{}>; + routeRef: RouteRef; }; /** @public */ diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.ts index 3afb5cedff..782e5134ec 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.ts @@ -14,8 +14,14 @@ * limitations under the License. */ -import { AnyExternalRoutes, AnyRoutes } from '@backstage/core-plugin-api'; import { Extension } from './createExtension'; +import { ExternalRouteRef, RouteRef } from '../routing'; + +/** @internal */ +export type AnyRoutes = { [name in string]: RouteRef }; + +/** @internal */ +export type AnyExternalRoutes = { [name in string]: ExternalRouteRef }; /** @public */ export interface PluginOptions< @@ -42,8 +48,8 @@ export interface BackstagePlugin< /** @public */ export function createPlugin< - Routes extends AnyRoutes = AnyRoutes, - ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes, + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes, >( options: PluginOptions, ): BackstagePlugin { From 2c09f44ccef88f539c88caea8f742684e9ab599e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 14:30:32 +0200 Subject: [PATCH 17/34] frontend-app-api: updates to use new routing API Signed-off-by: Patrik Oldsberg --- .../src/extensions/CoreNav.tsx | 4 +- .../extractRouteInfoFromInstanceTree.test.ts | 21 +++---- .../extractRouteInfoFromInstanceTree.ts | 18 +++++- .../src/wiring/createApp.test.tsx | 6 +- .../frontend-app-api/src/wiring/createApp.tsx | 63 +++++++++++++++++-- 5 files changed, 88 insertions(+), 24 deletions(-) diff --git a/packages/frontend-app-api/src/extensions/CoreNav.tsx b/packages/frontend-app-api/src/extensions/CoreNav.tsx index 3406c1d637..cd12ca129a 100644 --- a/packages/frontend-app-api/src/extensions/CoreNav.tsx +++ b/packages/frontend-app-api/src/extensions/CoreNav.tsx @@ -20,8 +20,8 @@ import { coreExtensionData, createExtensionInput, NavTarget, + useRouteRef, } from '@backstage/frontend-plugin-api'; -import { useRouteRef } from '@backstage/core-plugin-api'; import { makeStyles } from '@material-ui/core'; import { Sidebar, @@ -66,7 +66,7 @@ const SidebarLogo = () => { const SidebarNavItem = (props: NavTarget) => { const { icon: Icon, title, routeRef } = props; - const to = useRouteRef(routeRef)({}); + const to = useRouteRef(routeRef)(); // TODO: Support opening modal, for example, the search one return ; }; diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts index e70f588e0e..86a071df79 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts @@ -15,28 +15,27 @@ */ import React from 'react'; -import { - BackstagePlugin, - RouteRef, - createRouteRef, -} from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; import { extractRouteInfoFromInstanceTree } from './extractRouteInfoFromInstanceTree'; import { + AnyRouteParams, Extension, + RouteRef, coreExtensionData, createExtension, createExtensionInput, createPlugin, + createRouteRef, } from '@backstage/frontend-plugin-api'; import { createInstances } from '../wiring/createApp'; import { MockConfigApi } from '@backstage/test-utils'; -const ref1 = createRouteRef({ id: 'page1' }); -const ref2 = createRouteRef({ id: 'page2' }); -const ref3 = createRouteRef({ id: 'page3' }); -const ref4 = createRouteRef({ id: 'page4' }); -const ref5 = createRouteRef({ id: 'page5' }); -const refOrder = [ref1, ref2, ref3, ref4, ref5]; +const ref1 = createRouteRef(); +const ref2 = createRouteRef(); +const ref3 = createRouteRef(); +const ref4 = createRouteRef(); +const ref5 = createRouteRef(); +const refOrder: RouteRef[] = [ref1, ref2, ref3, ref4, ref5]; function createTestExtension(options: { id: string; diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts index fa9f87dce0..e6e714e3d1 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts @@ -14,12 +14,24 @@ * limitations under the License. */ -import { RouteRef } from '@backstage/core-plugin-api'; -import { coreExtensionData } from '@backstage/frontend-plugin-api'; +import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api'; import { ExtensionInstance } from '../wiring/createExtensionInstance'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { BackstageRouteObject } from '../../../core-app-api/src/routing/types'; import { toLegacyPlugin } from '../wiring/createApp'; +import { BackstagePlugin as LegacyBackstagePlugin } from '@backstage/core-plugin-api'; + +/** + * 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; + plugins: Set; +} // We always add a child that matches all subroutes but without any route refs. This makes // sure that we're always able to match each route no matter how deep the navigation goes. diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index 65f278b49b..0f2a6d38ce 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -19,13 +19,13 @@ import { createExtensionOverrides, createPageExtension, createPlugin, + createRouteRef, createThemeExtension, } from '@backstage/frontend-plugin-api'; import { createApp, createInstances } from './createApp'; import { screen } from '@testing-library/react'; import { MockConfigApi, renderWithEffects } from '@backstage/test-utils'; import React from 'react'; -import { createRouteRef } from '@backstage/core-plugin-api'; const extBaseConfig = { id: 'test', @@ -83,14 +83,14 @@ describe('createInstances', () => { const ExtensionA = createPageExtension({ id: 'A', defaultPath: '/', - routeRef: createRouteRef({ id: 'A.route' }), + routeRef: createRouteRef(), loader: async () =>
Extension A
, }); const ExtensionB = createPageExtension({ id: 'B', defaultPath: '/', - routeRef: createRouteRef({ id: 'B.route' }), + routeRef: createRouteRef(), loader: async () =>
Extension B
, }); diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 4b7e4985f3..beb29eacff 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -21,6 +21,10 @@ import { coreExtensionData, ExtensionDataRef, ExtensionOverrides, + ExternalRouteRef, + RouteRef, + SubRouteRef, + useRouteRef, } from '@backstage/frontend-plugin-api'; import { Core } from '../extensions/Core'; import { CoreRoutes } from '../extensions/CoreRoutes'; @@ -44,11 +48,9 @@ import { ConfigApi, configApiRef, IconComponent, - RouteRef, BackstagePlugin as LegacyBackstagePlugin, featureFlagsApiRef, attachComponentData, - useRouteRef, identityApiRef, AppTheme, } from '@backstage/core-plugin-api'; @@ -57,7 +59,6 @@ import { ApiFactoryRegistry, ApiProvider, ApiResolver, - AppRouteBinder, AppThemeSelector, } from '@backstage/core-app-api'; @@ -98,6 +99,57 @@ import { translationApiRef, } from '@backstage/core-plugin-api/alpha'; +/** + * Extracts a union of the keys in a map whose value extends the given type + * + * @ignore + */ +type KeysWithType = { + [key in keyof Obj]: Obj[key] extends Type ? key : never; +}[keyof Obj]; + +/** + * Takes a map Map required values and makes all keys matching Keys optional + * + * @ignore + */ +type PartialKeys< + Map extends { [name in string]: any }, + Keys extends keyof Map, +> = Partial> & Required>; + +/** + * Creates a map of target routes with matching parameters based on a map of external routes. + * + * @ignore + */ +type TargetRouteMap< + ExternalRoutes extends { [name: string]: ExternalRouteRef }, +> = { + [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< + infer Params, + any + > + ? RouteRef | SubRouteRef + : never; +}; + +/** + * A function that can bind from external routes of a given plugin, to concrete + * routes of other plugins. See {@link createApp}. + * + * @public + */ +export type AppRouteBinder = < + TExternalRoutes extends { [name: string]: ExternalRouteRef }, +>( + externalRoutes: TExternalRoutes, + targetRoutes: PartialKeys< + TargetRouteMap, + KeysWithType> + >, +) => void; + /** @public */ export interface ExtensionTreeNode { id: string; @@ -312,8 +364,9 @@ export function createApp(options: { {/* TODO: set base path using the logic from AppRouter */} From d84f5efa60f40736ab7af62d71c35d8f366a290f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 14:31:12 +0200 Subject: [PATCH 18/34] plugins: updates to convert legacy route refs to new system Signed-off-by: Patrik Oldsberg --- plugins/graphiql/src/alpha.tsx | 17 ++++++++--------- plugins/search/src/alpha.tsx | 12 +++++++----- plugins/tech-radar/src/alpha.tsx | 11 +++++++---- plugins/tech-radar/src/plugin.ts | 2 +- plugins/user-settings/src/alpha.tsx | 15 ++++++--------- 5 files changed, 29 insertions(+), 28 deletions(-) diff --git a/plugins/graphiql/src/alpha.tsx b/plugins/graphiql/src/alpha.tsx index 0a27f6151b..58b6686daa 100644 --- a/plugins/graphiql/src/alpha.tsx +++ b/plugins/graphiql/src/alpha.tsx @@ -32,19 +32,15 @@ import { GraphQLEndpoint, GraphiQLIcon, } from '@backstage/plugin-graphiql'; -import { - createApiFactory, - createRouteRef, - IconComponent, -} from '@backstage/core-plugin-api'; - -const graphiqlRouteRef = createRouteRef({ id: 'plugin.graphiql.page' }); +import { createApiFactory, IconComponent } from '@backstage/core-plugin-api'; +import { graphiQLRouteRef } from './route-refs'; +import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; /** @alpha */ export const GraphiqlPage = createPageExtension({ id: 'plugin.graphiql.page', defaultPath: '/graphiql', - routeRef: graphiqlRouteRef, + routeRef: convertLegacyRouteRef(graphiQLRouteRef), loader: () => import('./components').then(m => ), }); @@ -53,7 +49,7 @@ export const graphiqlPageSidebarItem = createNavItemExtension({ id: 'plugin.graphiql.nav.index', title: 'GraphiQL', icon: GraphiQLIcon as IconComponent, - routeRef: graphiqlRouteRef, + routeRef: convertLegacyRouteRef(graphiQLRouteRef), }); /** @internal */ @@ -125,4 +121,7 @@ export default createPlugin({ gitlabGraphiQLBrowseExtension, graphiqlPageSidebarItem, ], + routes: { + root: convertLegacyRouteRef(graphiQLRouteRef), + }, }); diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx index f1c4f68d02..a471cf730d 100644 --- a/plugins/search/src/alpha.tsx +++ b/plugins/search/src/alpha.tsx @@ -28,7 +28,6 @@ import { useSidebarPinState, } from '@backstage/core-components'; import { - createRouteRef, useApi, DiscoveryApi, IdentityApi, @@ -64,9 +63,11 @@ import { SearchResult } from '@backstage/plugin-search-common'; import { searchApiRef } from '@backstage/plugin-search-react'; import { searchResultItemExtensionData } from '@backstage/plugin-search-react/alpha'; +import { rootRouteRef } from './plugin'; import { SearchClient } from './apis'; import { SearchType } from './components/SearchType'; import { UrlUpdater } from './components/SearchPage/SearchPage'; +import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; /** @alpha */ export const SearchApi = createApiExtension({ @@ -95,12 +96,10 @@ const useSearchPageStyles = makeStyles((theme: Theme) => ({ }, })); -const searchRouteRef = createRouteRef({ id: 'plugin.search.page' }); - /** @alpha */ export const SearchPage = createPageExtension({ id: 'plugin.search.page', - routeRef: searchRouteRef, + routeRef: convertLegacyRouteRef(rootRouteRef), configSchema: createSchemaFromZod(z => z.object({ path: z.string().default('/search'), @@ -237,7 +236,7 @@ export const SearchPage = createPageExtension({ /** @alpha */ export const SearchNavItem = createNavItemExtension({ id: 'plugin.search.nav.index', - routeRef: searchRouteRef, + routeRef: convertLegacyRouteRef(rootRouteRef), title: 'Search', icon: SearchIcon, }); @@ -246,4 +245,7 @@ export const SearchNavItem = createNavItemExtension({ export default createPlugin({ id: 'plugin.search', extensions: [SearchApi, SearchPage, SearchNavItem], + routes: { + root: convertLegacyRouteRef(rootRouteRef), + }, }); diff --git a/plugins/tech-radar/src/alpha.tsx b/plugins/tech-radar/src/alpha.tsx index 6a2ec882ba..78230c0337 100644 --- a/plugins/tech-radar/src/alpha.tsx +++ b/plugins/tech-radar/src/alpha.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiFactory, createRouteRef } from '@backstage/core-plugin-api'; +import { createApiFactory } from '@backstage/core-plugin-api'; import { createApiExtension, createPageExtension, @@ -24,14 +24,14 @@ import { import React from 'react'; import { techRadarApiRef } from './api'; import { SampleTechRadarApi } from './sample'; - -const techRadarRouteRef = createRouteRef({ id: 'plugin.techradar.page' }); +import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; +import { rootRouteRef } from './plugin'; /** @alpha */ export const TechRadarPage = createPageExtension({ id: 'plugin.techradar.page', defaultPath: '/tech-radar', - routeRef: techRadarRouteRef, + routeRef: convertLegacyRouteRef(rootRouteRef), configSchema: createSchemaFromZod(z => z.object({ title: z.string().default('Tech Radar'), @@ -59,4 +59,7 @@ const sampleTechRadarApi = createApiExtension({ export default createPlugin({ id: 'tech-radar', extensions: [TechRadarPage, sampleTechRadarApi], + routes: { + root: convertLegacyRouteRef(rootRouteRef), + }, }); diff --git a/plugins/tech-radar/src/plugin.ts b/plugins/tech-radar/src/plugin.ts index 7f042a1b6f..3a8e0a5f2d 100644 --- a/plugins/tech-radar/src/plugin.ts +++ b/plugins/tech-radar/src/plugin.ts @@ -23,7 +23,7 @@ import { createApiFactory, } from '@backstage/core-plugin-api'; -const rootRouteRef = createRouteRef({ +export const rootRouteRef = createRouteRef({ id: 'tech-radar', }); diff --git a/plugins/user-settings/src/alpha.tsx b/plugins/user-settings/src/alpha.tsx index 8d68ccf259..07cc8ce547 100644 --- a/plugins/user-settings/src/alpha.tsx +++ b/plugins/user-settings/src/alpha.tsx @@ -13,29 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createRouteRef } from '@backstage/core-plugin-api'; import { coreExtensionData, createExtensionInput, createPageExtension, createPlugin, } from '@backstage/frontend-plugin-api'; +import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; +import { settingsRouteRef } from './plugin'; import React from 'react'; export * from './translation'; -/** - * @alpha - */ -export const userSettingsRouteRef = createRouteRef({ - id: 'plugin.user-settings.page', -}); - const UserSettingsPage = createPageExtension({ id: 'plugin.user-settings.page', defaultPath: '/settings', - routeRef: userSettingsRouteRef, + routeRef: convertLegacyRouteRef(settingsRouteRef), inputs: { providerSettings: createExtensionInput( { @@ -56,4 +50,7 @@ const UserSettingsPage = createPageExtension({ export default createPlugin({ id: 'user-settings', extensions: [UserSettingsPage], + routes: { + root: convertLegacyRouteRef(settingsRouteRef), + }, }); From 6b4ae484adba5d08d8f6fb2cab20c08501442668 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 14:36:54 +0200 Subject: [PATCH 19/34] frontend-plugin-api: implement .T on route refs as declared props Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/src/routing/RouteRef.test.ts | 3 +-- packages/frontend-plugin-api/src/routing/RouteRef.ts | 5 +---- packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts | 1 + packages/frontend-plugin-api/src/routing/SubRouteRef.ts | 5 +---- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts index cb1c85ccea..c8d7c22e35 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts @@ -21,7 +21,7 @@ describe('RouteRef', () => { it('should be created and have a mutable ID', () => { const routeRef: RouteRef = createRouteRef(); const internal = toInternalRouteRef(routeRef); - expect(() => internal.T).toThrow(); + expect(internal.T).toBe(undefined); expect(internal.getParams()).toEqual([]); expect(internal.getDescription()).toMatch(/RouteRef\.test\.ts/); @@ -51,7 +51,6 @@ describe('RouteRef', () => { const internal = toInternalRouteRef(routeRef); expect(internal.getParams()).toEqual(['x', 'y']); expect(internal.getDescription()).toMatch(/RouteRef\.test\.ts/); - expect(() => internal.T).toThrow(); }); it('should properly infer and validate parameter types and assignments', () => { diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.ts b/packages/frontend-plugin-api/src/routing/RouteRef.ts index f8ca088d0f..1085644993 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.ts @@ -63,6 +63,7 @@ export function isRouteRef(opaque: { $$type: string }): opaque is RouteRef { export class RouteRefImpl implements InternalRouteRef { readonly $$type = '@backstage/RouteRef'; readonly version = 'v1'; + declare readonly T: never; #id?: string; #params: string[]; @@ -73,10 +74,6 @@ export class RouteRefImpl implements InternalRouteRef { this.#creationSite = creationSite; } - get T(): never { - throw new Error(`tried to read RouteRef.T of ${this}`); - } - getParams(): string[] { return this.#params; } diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts index 7e8efe7842..5b1ef6d836 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts @@ -34,6 +34,7 @@ describe('SubRouteRef', () => { }); const internal = toInternalSubRouteRef(routeRef); expect(internal.path).toBe('/foo'); + expect(internal.T).toBe(undefined); expect(internal.getParent()).toBe(internalParent); expect(internal.getParams()).toEqual([]); expect(String(internal)).toMatch( diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts index 3fb3778d6f..b71ceb6a95 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts @@ -73,6 +73,7 @@ export class SubRouteRefImpl { readonly $$type = '@backstage/SubRouteRef'; readonly version = 'v1'; + declare readonly T: never; #params: string[]; #parent: RouteRef; @@ -82,10 +83,6 @@ export class SubRouteRefImpl this.#parent = parent; } - get T(): never { - throw new Error(`tried to read RouteRef.T of ${this}`); - } - getParams(): string[] { return this.#params; } From 44c9b738c27ec1bde23412bf62a6c15e86218862 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 14:38:51 +0200 Subject: [PATCH 20/34] app-next: updates for new routing APIs Signed-off-by: Patrik Oldsberg --- packages/app-next/src/App.tsx | 3 ++- packages/app-next/src/examples/pagesPlugin.tsx | 14 ++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 4d72cca14f..03fa03cfb3 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -26,6 +26,7 @@ import { } from '@backstage/frontend-plugin-api'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; import techdocsPlugin from '@backstage/plugin-techdocs/alpha'; +import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; /* @@ -59,7 +60,7 @@ TODO: const entityPageExtension = createPageExtension({ id: 'catalog:entity', defaultPath: '/catalog/:namespace/:kind/:name', - routeRef: entityRouteRef, + routeRef: convertLegacyRouteRef(entityRouteRef), loader: async () =>
Just a temporary mocked entity page
, }); diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx index ef6b55ff4c..6e040c2faf 100644 --- a/packages/app-next/src/examples/pagesPlugin.tsx +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -19,18 +19,16 @@ import { Link } from '@backstage/core-components'; import { createPageExtension, createPlugin, -} from '@backstage/frontend-plugin-api'; -import { - useRouteRef, createRouteRef, createExternalRouteRef, -} from '@backstage/core-plugin-api'; + useRouteRef, +} from '@backstage/frontend-plugin-api'; import { Route, Routes } from 'react-router-dom'; -const indexRouteRef = createRouteRef({ id: 'index' }); -const page1RouteRef = createRouteRef({ id: 'page1' }); -export const externalPageXRouteRef = createExternalRouteRef({ id: 'pageX' }); -export const pageXRouteRef = createRouteRef({ id: 'pageX' }); +const indexRouteRef = createRouteRef(); +const page1RouteRef = createRouteRef(); +export const externalPageXRouteRef = createExternalRouteRef(); +export const pageXRouteRef = createRouteRef(); // const page2RouteRef = createSubRouteRef({ // id: 'page2', // parent: page1RouteRef, From 275dd573bc3716091e9d11ab58b676430e3f33ea Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 14:42:47 +0200 Subject: [PATCH 21/34] frontend-app-api: no longer force route refs to have same ID as page extensions Signed-off-by: Patrik Oldsberg --- .../src/routing/extractRouteInfoFromInstanceTree.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts index e6e714e3d1..13602b3c6d 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts @@ -121,13 +121,6 @@ export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): { // Whenever a route ref is encountered, we need to give it a route path and position in the ref tree. if (routeRef) { - const routeRefId = (routeRef as any).id; // TODO: properly - if (routeRefId !== current.id) { - throw new Error( - `Route ref '${routeRefId}' must have the same ID as extension '${current.id}'`, - ); - } - // The first route ref we find after encountering a route path is selected to be used as the // parent ref further down the tree. We don't start using this candidate ref until we encounter // another route path though, at which point we repeat the process and select another candidate. From aee98148c06ebdb719814cf906fb33aab9ae0fb5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 15:46:31 +0200 Subject: [PATCH 22/34] frontend-app-api: lift over and migrate RouteResolver Signed-off-by: Patrik Oldsberg --- .../src/routing/RouteResolver.test.ts | 366 ++++++++++++++++++ .../src/routing/RouteResolver.ts | 268 +++++++++++++ .../extractRouteInfoFromInstanceTree.ts | 15 +- .../frontend-app-api/src/routing/types.ts | 38 ++ 4 files changed, 673 insertions(+), 14 deletions(-) create mode 100644 packages/frontend-app-api/src/routing/RouteResolver.test.ts create mode 100644 packages/frontend-app-api/src/routing/RouteResolver.ts create mode 100644 packages/frontend-app-api/src/routing/types.ts diff --git a/packages/frontend-app-api/src/routing/RouteResolver.test.ts b/packages/frontend-app-api/src/routing/RouteResolver.test.ts new file mode 100644 index 0000000000..ef7a438734 --- /dev/null +++ b/packages/frontend-app-api/src/routing/RouteResolver.test.ts @@ -0,0 +1,366 @@ +/* + * 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 { + createRouteRef, + createSubRouteRef, + createExternalRouteRef, + ExternalRouteRef, + RouteRef, + SubRouteRef, +} from '@backstage/frontend-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { RouteResolver } from './RouteResolver'; +import { MATCH_ALL_ROUTE } from './extractRouteInfoFromInstanceTree'; + +const element = () => null; +const rest = { + element, + caseSensitive: false, + children: [MATCH_ALL_ROUTE], + plugins: new Set(), +}; + +const ref1 = createRouteRef(); +const ref2 = createRouteRef({ params: ['x'] }); +const ref3 = createRouteRef({ params: ['y'] }); +const subRef1 = createSubRouteRef({ parent: ref1, path: '/foo' }); +const subRef2 = createSubRouteRef({ parent: ref1, path: '/foo/:a' }); +const subRef3 = createSubRouteRef({ parent: ref2, path: '/bar' }); +const subRef4 = createSubRouteRef({ parent: ref2, path: '/bar/:a' }); +const externalRef1 = createExternalRouteRef(); +const externalRef2 = createExternalRouteRef({ optional: true }); +const externalRef3 = createExternalRouteRef({ params: ['x'] }); +const externalRef4 = createExternalRouteRef({ optional: true, params: ['x'] }); + +describe('RouteResolver', () => { + it('should not resolve anything with an empty resolver', () => { + const r = new RouteResolver(new Map(), new Map(), [], new Map(), ''); + + expect(r.resolve(ref1, '/')?.()).toBe(undefined); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); + expect(r.resolve(subRef1, '/')?.()).toBe(undefined); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe(undefined); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); + expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); + }); + + it('should resolve an absolute route', () => { + const r = new RouteResolver( + new Map([[ref1, 'my-route']]), + new Map(), + [{ routeRefs: new Set([ref1]), path: 'my-route', ...rest }], + new Map(), + '', + ); + + expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); + expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); + expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); + }); + + it('should resolve an absolute route and sub route with an app base path', () => { + const r = new RouteResolver( + new Map([ + [ref2, 'my-parent/:x'], + [ref1, 'my-route'], + ]), + new Map([[ref1, ref2]]), + [ + { + routeRefs: new Set([ref2]), + path: 'my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: 'my-route', ...rest }, + ], + }, + ], + new Map(), + '/base', + ); + + expect(r.resolve(ref1, '/my-parent/1x')?.()).toBe( + '/base/my-parent/1x/my-route', + ); + expect(r.resolve(ref1, '/base/my-parent/1x')?.()).toBe( + '/base/my-parent/1x/my-route', + ); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/base/my-parent/1x'); + expect(r.resolve(ref2, '/base')?.({ x: '1x' })).toBe('/base/my-parent/1x'); + expect(r.resolve(ref3, '/')?.({ y: '1y' })).toBe(undefined); + expect(r.resolve(subRef1, '/my-parent/2x')?.()).toBe( + '/base/my-parent/2x/my-route/foo', + ); + expect(r.resolve(subRef1, '/base/my-parent/2x')?.()).toBe( + '/base/my-parent/2x/my-route/foo', + ); + expect(r.resolve(subRef2, '/my-parent/3x')?.({ a: '2a' })).toBe( + '/base/my-parent/3x/my-route/foo/2a', + ); + expect(r.resolve(subRef2, '/base/my-parent/3x')?.({ a: '2a' })).toBe( + '/base/my-parent/3x/my-route/foo/2a', + ); + expect(r.resolve(subRef3, '/')?.({ x: '5x' })).toBe( + '/base/my-parent/5x/bar', + ); + expect(r.resolve(subRef4, '/')?.({ x: '6x', a: '4a' })).toBe( + '/base/my-parent/6x/bar/4a', + ); + expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); + }); + + it('should resolve an absolute route with a param and with a parent', () => { + const r = new RouteResolver( + new Map([ + [ref1, 'my-route'], + [ref2, 'my-parent/:x'], + ]), + new Map([[ref2, ref1]]), + [ + { + routeRefs: new Set([ref2]), + path: 'my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: 'my-route', ...rest }, + ], + }, + ], + new Map([ + [externalRef1, ref1], + [externalRef3, ref2], + [externalRef4, subRef3], + ]), + '', + ); + + expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/my-route/my-parent/1x'); + expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe( + '/my-route/my-parent/3x/bar', + ); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe( + '/my-route/my-parent/4x/bar/4a', + ); + expect(r.resolve(externalRef1, '/')?.()).toBe('/my-route'); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe( + '/my-route/my-parent/5x', + ); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe( + '/my-route/my-parent/6x/bar', + ); + }); + + it('should resolve the most specific match', () => { + const r = new RouteResolver( + new Map([ + [ref1, 'deep'], + [ref2, 'root/:x'], + [ref3, 'sub/:y'], + ]), + new Map([ + [ref3, ref2], + [ref1, ref3], + ]), + [ + { + routeRefs: new Set([ref2]), + path: 'root/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { + routeRefs: new Set([ref3]), + path: 'sub/:y', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { + routeRefs: new Set([ref1]), + path: 'deep', + ...rest, + }, + ], + }, + ], + }, + ], + new Map(), + '', + ); + + expect(r.resolve(ref2, '/')?.({ x: 'x' })).toBe('/root/x'); + expect(r.resolve(ref3, '/root/x')?.({ y: 'y' })).toBe('/root/x/sub/y'); + + expect(() => r.resolve(ref1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(() => r.resolve(ref1, '/root/x')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(ref1, '/root/x/sub/y')?.()).toBe('/root/x/sub/y/deep'); + // Without the MATCH_ALL_ROUTE, we wouldn't properly match the route here + expect(r.resolve(ref1, '/root/x/sub/y/any/nested/path/here')?.()).toBe( + '/root/x/sub/y/deep', + ); + }); + + it('should resolve an absolute route with multiple parents', () => { + const r = new RouteResolver( + new Map([ + [ref1, 'my-route'], + [ref2, 'my-parent/:x'], + [ref3, 'my-grandparent/:y'], + ]), + new Map([ + [ref1, ref2], + [ref2, ref3], + ]), + [ + { + routeRefs: new Set([ref3]), + path: 'my-grandparent/:y', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { + routeRefs: new Set([ref2]), + path: 'my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: 'my-route', ...rest }, + ], + }, + ], + }, + ], + new Map([ + [externalRef1, ref1], + [externalRef3, ref2], + [externalRef4, subRef3], + ]), + '', + ); + + const l = '/my-grandparent/my-y/my-parent/my-x'; + expect(r.resolve(ref1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route', + ); + expect(() => r.resolve(ref1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(ref2, l)?.({ x: '1x' })).toBe( + '/my-grandparent/my-y/my-parent/1x', + ); + expect(r.resolve(ref2, '/my-grandparent/my-y')?.({ x: '1x' })).toBe( + '/my-grandparent/my-y/my-parent/1x', + ); + expect(() => r.resolve(ref2, '/')?.({ x: '1x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route/foo', + ); + expect(() => r.resolve(subRef1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef2, l)?.({ a: '2a' })).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route/foo/2a', + ); + expect(() => r.resolve(subRef2, '/')?.({ a: '2a' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef3, l)?.({ x: '3x' })).toBe( + '/my-grandparent/my-y/my-parent/3x/bar', + ); + expect(r.resolve(subRef3, '/my-grandparent/my-y')?.({ x: '3x' })).toBe( + '/my-grandparent/my-y/my-parent/3x/bar', + ); + expect(r.resolve(subRef4, l)?.({ x: '4x', a: '4a' })).toBe( + '/my-grandparent/my-y/my-parent/4x/bar/4a', + ); + expect( + r.resolve(subRef4, '/my-grandparent/my-y')?.({ x: '4x', a: '4a' }), + ).toBe('/my-grandparent/my-y/my-parent/4x/bar/4a'); + expect(r.resolve(externalRef1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route', + ); + expect(() => r.resolve(externalRef1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(externalRef2, l)?.()).toBe(undefined); + expect(r.resolve(externalRef3, l)?.({ x: '5x' })).toBe( + '/my-grandparent/my-y/my-parent/5x', + ); + expect(() => r.resolve(externalRef3, '/')?.({ x: '5x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(externalRef4, l)?.({ x: '6x' })).toBe( + '/my-grandparent/my-y/my-parent/6x/bar', + ); + expect(() => r.resolve(externalRef4, '/')?.({ x: '6x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + }); + + it('should encode some characters in params', () => { + const r = new RouteResolver( + new Map([ + [ref2, 'my-parent/:x'], + [ref1, 'my-route'], + ]), + new Map([[ref1, ref2]]), + [ + { + routeRefs: new Set([ref2]), + path: 'my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: 'my-route', ...rest }, + ], + }, + ], + new Map(), + '/base', + ); + + expect(r.resolve(ref2, '/')?.({ x: 'a/#&?b' })).toBe( + '/base/my-parent/a%2F%23%26%3Fb', + ); + }); +}); diff --git a/packages/frontend-app-api/src/routing/RouteResolver.ts b/packages/frontend-app-api/src/routing/RouteResolver.ts new file mode 100644 index 0000000000..9da5836be4 --- /dev/null +++ b/packages/frontend-app-api/src/routing/RouteResolver.ts @@ -0,0 +1,268 @@ +/* + * 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 { generatePath, matchRoutes } from 'react-router-dom'; +import { + RouteRef, + ExternalRouteRef, + SubRouteRef, + AnyRouteParams, + RouteFunc, +} from '@backstage/frontend-plugin-api'; +import mapValues from 'lodash/mapValues'; +import { AnyRouteRef, BackstageRouteObject } from './types'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { isRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + isSubRouteRef, + toInternalSubRouteRef, +} from '../../../frontend-plugin-api/src/routing/SubRouteRef'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { isExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; + +// Joins a list of paths together, avoiding trailing and duplicate slashes +export function joinPaths(...paths: string[]): string { + const normalized = paths.join('/').replace(/\/\/+/g, '/'); + if (normalized !== '/' && normalized.endsWith('/')) { + return normalized.slice(0, -1); + } + return normalized; +} + +/** + * Resolves the absolute route ref that our target route ref is pointing pointing to, as well + * as the relative target path. + * + * Returns an undefined target ref if one could not be fully resolved. + */ +function resolveTargetRef( + anyRouteRef: AnyRouteRef, + routePaths: Map, + routeBindings: Map, +): readonly [RouteRef | undefined, string] { + // First we figure out which absolute route ref we're dealing with, an if there was an sub route path to append. + // For sub routes it will be the parent path, while for external routes it will be the bound route. + let targetRef: RouteRef; + let subRoutePath = ''; + if (isRouteRef(anyRouteRef)) { + targetRef = anyRouteRef; + } else if (isSubRouteRef(anyRouteRef)) { + const internal = toInternalSubRouteRef(anyRouteRef); + targetRef = internal.getParent(); + subRoutePath = internal.path; + } else if (isExternalRouteRef(anyRouteRef)) { + const resolvedRoute = routeBindings.get(anyRouteRef); + if (!resolvedRoute) { + return [undefined, '']; + } + if (isRouteRef(resolvedRoute)) { + targetRef = resolvedRoute; + } else if (isSubRouteRef(resolvedRoute)) { + const internal = toInternalSubRouteRef(resolvedRoute); + targetRef = internal.getParent(); + subRoutePath = resolvedRoute.path; + } else { + throw new Error( + `ExternalRouteRef was bound to invalid target, ${resolvedRoute}`, + ); + } + } else { + throw new Error(`Unknown object passed to useRouteRef, got ${anyRouteRef}`); + } + + // Bail if no absolute path could be resolved + if (!targetRef) { + return [undefined, '']; + } + + // Find the path that our target route is bound to + const resolvedPath = routePaths.get(targetRef); + if (resolvedPath === undefined) { + return [undefined, '']; + } + + // SubRouteRefs join the path from the parent route with its own path + const targetPath = joinPaths(resolvedPath, subRoutePath); + return [targetRef, targetPath]; +} + +/** + * Resolves the complete base path for navigating to the target RouteRef. + */ +function resolveBasePath( + targetRef: RouteRef, + sourceLocation: Parameters[1], + routePaths: Map, + routeParents: Map, + routeObjects: BackstageRouteObject[], +) { + // While traversing the app element tree we build up the routeObjects structure + // used here. It is the same kind of structure that react-router creates, with the + // addition that associated route refs are stored throughout the tree. This lets + // us look up all route refs that can be reached from our source location. + // Because of the similar route object structure, we can use `matchRoutes` from + // react-router to do the lookup of our current location. + const match = matchRoutes(routeObjects, sourceLocation) ?? []; + + // While we search for a common routing root between our current location and + // the target route, we build a list of all route refs we find that we need + // to traverse to reach the target. + const refDiffList = Array(); + + let matchIndex = -1; + for ( + let targetSearchRef: RouteRef | undefined = targetRef; + targetSearchRef; + targetSearchRef = routeParents.get(targetSearchRef) + ) { + // The match contains a list of all ancestral route refs present at our current location + // Starting at the desired target ref and traversing back through its parents, we search + // for a target ref that is present in the match for our current location. When a match + // is found it means we have found a common base to resolve the route from. + matchIndex = match.findIndex(m => + (m.route as BackstageRouteObject).routeRefs.has(targetSearchRef!), + ); + if (matchIndex !== -1) { + break; + } + + // Every time we move a step up in the ancestry of the target ref, we add the current ref + // to the diff list, which ends up being the list of route refs to traverse form the common base + // in order to reach our target. + refDiffList.unshift(targetSearchRef); + } + + // If our target route is present in the initial match we need to construct the final path + // from the parent of the matched route segment. That's to allow the caller of the route + // function to supply their own params. + if (refDiffList.length === 0) { + matchIndex -= 1; + } + + // This is the part of the route tree that the target and source locations have in common. + // We re-use the existing pathname directly along with all params. + const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; + + // This constructs the mid section of the path using paths resolved from all route refs + // we need to traverse to reach our target except for the very last one. None of these + // paths are allowed to require any parameters, as the caller would have no way of knowing + // what parameters those are. + const diffPaths = refDiffList.slice(0, -1).map(ref => { + const path = routePaths.get(ref); + if (path === undefined) { + throw new Error(`No path for ${ref}`); + } + if (path.includes(':')) { + throw new Error( + `Cannot route to ${targetRef} with parent ${ref} as it has parameters`, + ); + } + return path; + }); + + return `${joinPaths(parentPath, ...diffPaths)}/`; +} + +export class RouteResolver { + constructor( + private readonly routePaths: Map, + private readonly routeParents: Map, + private readonly routeObjects: BackstageRouteObject[], + private readonly routeBindings: Map< + ExternalRouteRef, + RouteRef | SubRouteRef + >, + private readonly appBasePath: string, // base path without a trailing slash + ) {} + + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + sourceLocation: Parameters[1], + ): RouteFunc | undefined { + // First figure out what our target absolute ref is, as well as our target path. + const [targetRef, targetPath] = resolveTargetRef( + anyRouteRef, + this.routePaths, + this.routeBindings, + ); + if (!targetRef) { + return undefined; + } + + // The location that we get passed in uses the full path, so start by trimming off + // the app base path prefix in case we're running the app on a sub-path. + let relativeSourceLocation: Parameters[1]; + if (typeof sourceLocation === 'string') { + relativeSourceLocation = this.trimPath(sourceLocation); + } else if (sourceLocation.pathname) { + relativeSourceLocation = { + ...sourceLocation, + pathname: this.trimPath(sourceLocation.pathname), + }; + } else { + relativeSourceLocation = sourceLocation; + } + + // Next we figure out the base path, which is the combination of the common parent path + // between our current location and our target location, as well as the additional path + // that is the difference between the parent path and the base of our target location. + const basePath = + this.appBasePath + + resolveBasePath( + targetRef, + relativeSourceLocation, + this.routePaths, + this.routeParents, + this.routeObjects, + ); + + const routeFunc: RouteFunc = (...[params]) => { + // We selectively encode some some known-dangerous characters in the + // params. The reason that we don't perform a blanket `encodeURIComponent` + // here is that this encoding was added defensively long after the initial + // release of this code. There's likely to be many users of this code that + // already encode their parameters knowing that this code didn't do this + // for them in the past. Therefore, we are extra careful NOT to include + // the percent character in this set, even though that might seem like a + // bad idea. + const encodedParams = + params && + mapValues(params, value => { + if (typeof value === 'string') { + return value.replaceAll(/[&?#;\/]/g, c => encodeURIComponent(c)); + } + return value; + }); + return joinPaths(basePath, generatePath(targetPath, encodedParams)); + }; + return routeFunc; + } + + private trimPath(targetPath: string) { + if (!targetPath) { + return targetPath; + } + + if (targetPath.startsWith(this.appBasePath)) { + return targetPath.slice(this.appBasePath.length); + } + return targetPath; + } +} diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts index 13602b3c6d..0bf9c2d1dd 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts @@ -18,20 +18,7 @@ import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api'; import { ExtensionInstance } from '../wiring/createExtensionInstance'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toLegacyPlugin } from '../wiring/createApp'; -import { BackstagePlugin as LegacyBackstagePlugin } from '@backstage/core-plugin-api'; - -/** - * 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; - plugins: Set; -} +import { BackstageRouteObject } from './types'; // We always add a child that matches all subroutes but without any route refs. This makes // sure that we're always able to match each route no matter how deep the navigation goes. diff --git a/packages/frontend-app-api/src/routing/types.ts b/packages/frontend-app-api/src/routing/types.ts new file mode 100644 index 0000000000..5f1ed9be84 --- /dev/null +++ b/packages/frontend-app-api/src/routing/types.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2023 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, + RouteRef, + SubRouteRef, +} from '@backstage/frontend-plugin-api'; +import { BackstagePlugin as LegacyBackstagePlugin } from '@backstage/core-plugin-api'; + +/** @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; + plugins: Set; +} From d4400f5e276bb1c0bbc1bb5828c95d1ed91fbb54 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 16:26:38 +0200 Subject: [PATCH 23/34] frontend-app-api: lift over resolveRouteBindings and RoutingProvider Signed-off-by: Patrik Oldsberg --- .../src/routing/RoutingProvider.tsx | 66 +++++++++++ .../frontend-app-api/src/routing/index.ts | 17 +++ .../src/routing/resolveRouteBindings.test.ts | 43 ++++++++ .../src/routing/resolveRouteBindings.ts | 104 ++++++++++++++++++ .../frontend-app-api/src/wiring/createApp.tsx | 64 +---------- 5 files changed, 235 insertions(+), 59 deletions(-) create mode 100644 packages/frontend-app-api/src/routing/RoutingProvider.tsx create mode 100644 packages/frontend-app-api/src/routing/index.ts create mode 100644 packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts create mode 100644 packages/frontend-app-api/src/routing/resolveRouteBindings.ts diff --git a/packages/frontend-app-api/src/routing/RoutingProvider.tsx b/packages/frontend-app-api/src/routing/RoutingProvider.tsx new file mode 100644 index 0000000000..965faa3655 --- /dev/null +++ b/packages/frontend-app-api/src/routing/RoutingProvider.tsx @@ -0,0 +1,66 @@ +/* + * 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 React, { ReactNode } from 'react'; +import { + ExternalRouteRef, + RouteRef, + SubRouteRef, +} from '@backstage/frontend-plugin-api'; +import { + createVersionedValueMap, + createVersionedContext, +} from '@backstage/version-bridge'; +import { RouteResolver } from './RouteResolver'; +import { BackstageRouteObject } from './types'; + +const RoutingContext = createVersionedContext<{ 1: RouteResolver }>( + 'routing-context', +); + +type ProviderProps = { + routePaths: Map; + routeParents: Map; + routeObjects: BackstageRouteObject[]; + routeBindings: Map; + basePath?: string; + children: ReactNode; +}; + +// TODO(Rugvip): Migrate to a routing API instead +export const RoutingProvider = ({ + routePaths, + routeParents, + routeObjects, + routeBindings, + basePath = '', + children, +}: ProviderProps) => { + const resolver = new RouteResolver( + routePaths, + routeParents, + routeObjects, + routeBindings, + basePath, + ); + + const versionedValue = createVersionedValueMap({ 1: resolver }); + return ( + + {children} + + ); +}; diff --git a/packages/frontend-app-api/src/routing/index.ts b/packages/frontend-app-api/src/routing/index.ts new file mode 100644 index 0000000000..8c26a732d3 --- /dev/null +++ b/packages/frontend-app-api/src/routing/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 AppRouteBinder } from './resolveRouteBindings'; diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts new file mode 100644 index 0000000000..9ea288c196 --- /dev/null +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts @@ -0,0 +1,43 @@ +/* + * 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 { + createExternalRouteRef, + createRouteRef, +} from '@backstage/frontend-plugin-api'; +import { resolveRouteBindings } from './resolveRouteBindings'; + +describe('resolveRouteBindings', () => { + it('runs happy path', () => { + const external = { myRoute: createExternalRouteRef() }; + const ref = createRouteRef(); + const result = resolveRouteBindings(({ bind }) => { + bind(external, { myRoute: ref }); + }); + + expect(result.get(external.myRoute)).toBe(ref); + }); + + it('throws on unknown keys', () => { + const external = { myRoute: createExternalRouteRef() }; + const ref = createRouteRef(); + expect(() => + resolveRouteBindings(({ bind }) => { + bind(external, { someOtherRoute: ref } as any); + }), + ).toThrow('Key someOtherRoute is not an existing external route'); + }); +}); diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts new file mode 100644 index 0000000000..968da005ec --- /dev/null +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts @@ -0,0 +1,104 @@ +/* + * 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, + SubRouteRef, + ExternalRouteRef, +} from '@backstage/frontend-plugin-api'; + +/** + * Extracts a union of the keys in a map whose value extends the given type + * + * @ignore + */ +type KeysWithType = { + [key in keyof Obj]: Obj[key] extends Type ? key : never; +}[keyof Obj]; + +/** + * Takes a map Map required values and makes all keys matching Keys optional + * + * @ignore + */ +type PartialKeys< + Map extends { [name in string]: any }, + Keys extends keyof Map, +> = Partial> & Required>; + +/** + * Creates a map of target routes with matching parameters based on a map of external routes. + * + * @ignore + */ +type TargetRouteMap< + ExternalRoutes extends { [name: string]: ExternalRouteRef }, +> = { + [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< + infer Params, + any + > + ? RouteRef | SubRouteRef + : never; +}; + +/** + * A function that can bind from external routes of a given plugin, to concrete + * routes of other plugins. See {@link createApp}. + * + * @public + */ +export type AppRouteBinder = < + TExternalRoutes extends { [name: string]: ExternalRouteRef }, +>( + externalRoutes: TExternalRoutes, + targetRoutes: PartialKeys< + TargetRouteMap, + KeysWithType> + >, +) => void; + +/** @internal */ +export function resolveRouteBindings( + bindRoutes?: (context: { bind: AppRouteBinder }) => void, +): Map { + const result = new Map(); + + if (bindRoutes) { + const bind: AppRouteBinder = ( + externalRoutes, + targetRoutes: { [name: string]: RouteRef | SubRouteRef }, + ) => { + for (const [key, value] of Object.entries(targetRoutes)) { + const externalRoute = externalRoutes[key]; + if (!externalRoute) { + throw new Error(`Key ${key} is not an existing external route`); + } + if (!value && !externalRoute.optional) { + throw new Error( + `External route ${key} is required but was undefined`, + ); + } + if (value) { + result.set(externalRoute, value); + } + } + }; + bindRoutes({ bind }); + } + + return result; +} diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index beb29eacff..4842383096 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -21,9 +21,7 @@ import { coreExtensionData, ExtensionDataRef, ExtensionOverrides, - ExternalRouteRef, RouteRef, - SubRouteRef, useRouteRef, } from '@backstage/frontend-plugin-api'; import { Core } from '../extensions/Core'; @@ -76,10 +74,6 @@ import { defaultConfigLoaderSync } from '../../../core-app-api/src/app/defaultCo // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { overrideBaseUrlConfigs } from '../../../core-app-api/src/app/overrideBaseUrlConfigs'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { RoutingProvider } from '../../../core-app-api/src/routing/RoutingProvider'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { resolveRouteBindings } from '../../../core-app-api/src/app/resolveRouteBindings'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppLanguageSelector } from '../../../core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { I18nextTranslationApi } from '../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi'; @@ -98,57 +92,9 @@ import { appLanguageApiRef, translationApiRef, } from '@backstage/core-plugin-api/alpha'; - -/** - * Extracts a union of the keys in a map whose value extends the given type - * - * @ignore - */ -type KeysWithType = { - [key in keyof Obj]: Obj[key] extends Type ? key : never; -}[keyof Obj]; - -/** - * Takes a map Map required values and makes all keys matching Keys optional - * - * @ignore - */ -type PartialKeys< - Map extends { [name in string]: any }, - Keys extends keyof Map, -> = Partial> & Required>; - -/** - * Creates a map of target routes with matching parameters based on a map of external routes. - * - * @ignore - */ -type TargetRouteMap< - ExternalRoutes extends { [name: string]: ExternalRouteRef }, -> = { - [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< - infer Params, - any - > - ? RouteRef | SubRouteRef - : never; -}; - -/** - * A function that can bind from external routes of a given plugin, to concrete - * routes of other plugins. See {@link createApp}. - * - * @public - */ -export type AppRouteBinder = < - TExternalRoutes extends { [name: string]: ExternalRouteRef }, ->( - externalRoutes: TExternalRoutes, - targetRoutes: PartialKeys< - TargetRouteMap, - KeysWithType> - >, -) => void; +import { AppRouteBinder } from '../routing'; +import { RoutingProvider } from '../routing/RoutingProvider'; +import { resolveRouteBindings } from '../routing/resolveRouteBindings'; /** @public */ export interface ExtensionTreeNode { @@ -365,8 +311,8 @@ export function createApp(options: { {/* TODO: set base path using the logic from AppRouter */} From 659651bfcca1f9e9ae71b22a19b812c33d89654b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 17:26:04 +0200 Subject: [PATCH 24/34] frontend-app-api: added utility for collecting route IDs Signed-off-by: Patrik Oldsberg --- .../src/routing/collectRouteIds.test.ts | 51 ++++++++++++++ .../src/routing/collectRouteIds.ts | 70 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 packages/frontend-app-api/src/routing/collectRouteIds.test.ts create mode 100644 packages/frontend-app-api/src/routing/collectRouteIds.ts diff --git a/packages/frontend-app-api/src/routing/collectRouteIds.test.ts b/packages/frontend-app-api/src/routing/collectRouteIds.test.ts new file mode 100644 index 0000000000..95a5afd8cd --- /dev/null +++ b/packages/frontend-app-api/src/routing/collectRouteIds.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2023 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 { + createRouteRef, + createExternalRouteRef, + createPlugin, +} from '@backstage/frontend-plugin-api'; +import { collectRouteIds } from './collectRouteIds'; + +describe('collectRouteIds', () => { + it('should assign IDs to routes', () => { + const ref = createRouteRef(); + const extRef = createExternalRouteRef(); + + expect(String(ref)).toMatch( + /^RouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/, + ); + expect(String(extRef)).toMatch( + /^ExternalRouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/, + ); + + const collected = collectRouteIds([ + createPlugin({ id: 'test', routes: { ref }, externalRoutes: { extRef } }), + ]); + expect(Object.fromEntries(collected.routes)).toEqual({ + 'plugin.test.routes.ref': ref, + }); + expect(Object.fromEntries(collected.externalRoutes)).toEqual({ + 'plugin.test.externalRoutes.extRef': extRef, + }); + + expect(String(ref)).toBe('RouteRef{plugin.test.routes.ref}'); + expect(String(extRef)).toBe( + 'ExternalRouteRef{plugin.test.externalRoutes.extRef}', + ); + }); +}); diff --git a/packages/frontend-app-api/src/routing/collectRouteIds.ts b/packages/frontend-app-api/src/routing/collectRouteIds.ts new file mode 100644 index 0000000000..7e2a4cad2e --- /dev/null +++ b/packages/frontend-app-api/src/routing/collectRouteIds.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2023 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 { + BackstagePlugin, + ExtensionOverrides, + RouteRef, + SubRouteRef, + ExternalRouteRef, +} from '@backstage/frontend-plugin-api'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; + +/** @internal */ +export interface RouteRefsById { + routes: Map; + externalRoutes: Map; +} + +/** @internal */ +export function collectRouteIds( + features: (BackstagePlugin | ExtensionOverrides)[], +): RouteRefsById { + const routesById = new Map(); + const externalRoutesById = new Map(); + + for (const feature of features) { + if (feature.$$type !== '@backstage/BackstagePlugin') { + continue; + } + + for (const [name, ref] of Object.entries(feature.routes)) { + const refId = `plugin.${feature.id}.routes.${name}`; + if (routesById.has(refId)) { + throw new Error(`Unexpected duplicate route '${refId}'`); + } + + const internalRef = toInternalRouteRef(ref); + internalRef.setId(refId); + routesById.set(refId, ref); + } + for (const [name, ref] of Object.entries(feature.externalRoutes)) { + const refId = `plugin.${feature.id}.externalRoutes.${name}`; + if (externalRoutesById.has(refId)) { + throw new Error(`Unexpected duplicate external route '${refId}'`); + } + + const internalRef = toInternalExternalRouteRef(ref); + internalRef.setId(refId); + externalRoutesById.set(refId, ref); + } + } + + return { routes: routesById, externalRoutes: externalRoutesById }; +} From 2307fb17470e5a3039fcf8a8b42c317622293288 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 17:26:21 +0200 Subject: [PATCH 25/34] frontend-app-api: make it possible to bind routes through config Signed-off-by: Patrik Oldsberg --- packages/frontend-app-api/config.d.ts | 7 ++ .../src/routing/resolveRouteBindings.test.ts | 89 +++++++++++++++++-- .../src/routing/resolveRouteBindings.ts | 43 ++++++++- .../frontend-app-api/src/wiring/createApp.tsx | 10 ++- 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts index 5f5f877d3e..9ec020fa56 100644 --- a/packages/frontend-app-api/config.d.ts +++ b/packages/frontend-app-api/config.d.ts @@ -24,6 +24,13 @@ export interface Config { packages?: 'all' | { include?: string[]; exclude?: string[] }; }; + routes?: { + /** + * @deepVisibility frontend + */ + bindings?: { [externalRouteRefId: string]: string }; + }; + /** * @deepVisibility frontend */ diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts index 9ea288c196..d80a02e34d 100644 --- a/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts @@ -19,14 +19,21 @@ import { createRouteRef, } from '@backstage/frontend-plugin-api'; import { resolveRouteBindings } from './resolveRouteBindings'; +import { ConfigReader } from '@backstage/config'; + +const emptyIds = { routes: new Map(), externalRoutes: new Map() }; describe('resolveRouteBindings', () => { it('runs happy path', () => { const external = { myRoute: createExternalRouteRef() }; const ref = createRouteRef(); - const result = resolveRouteBindings(({ bind }) => { - bind(external, { myRoute: ref }); - }); + const result = resolveRouteBindings( + ({ bind }) => { + bind(external, { myRoute: ref }); + }, + new ConfigReader({}), + emptyIds, + ); expect(result.get(external.myRoute)).toBe(ref); }); @@ -35,9 +42,79 @@ describe('resolveRouteBindings', () => { const external = { myRoute: createExternalRouteRef() }; const ref = createRouteRef(); expect(() => - resolveRouteBindings(({ bind }) => { - bind(external, { someOtherRoute: ref } as any); - }), + resolveRouteBindings( + ({ bind }) => { + bind(external, { someOtherRoute: ref } as any); + }, + new ConfigReader({}), + emptyIds, + ), ).toThrow('Key someOtherRoute is not an existing external route'); }); + + it('reads bindings from config', () => { + const mySource = createExternalRouteRef(); + const myTarget = createRouteRef(); + const result = resolveRouteBindings( + () => {}, + new ConfigReader({ + app: { routes: { bindings: { mySource: 'myTarget' } } }, + }), + { + routes: new Map([['myTarget', myTarget]]), + externalRoutes: new Map([['mySource', mySource]]), + }, + ); + + expect(result.get(mySource)).toBe(myTarget); + }); + + it('throws on invalid config', () => { + expect(() => + resolveRouteBindings( + () => {}, + new ConfigReader({ app: { routes: { bindings: 'derp' } } }), + emptyIds, + ), + ).toThrow( + "Invalid type in config for key 'app.routes.bindings' in 'mock-config', got string, wanted object", + ); + + expect(() => + resolveRouteBindings( + () => {}, + new ConfigReader({ app: { routes: { bindings: { mySource: true } } } }), + emptyIds, + ), + ).toThrow( + "Invalid config at app.routes.bindings['mySource'], value must be a non-empty string", + ); + + expect(() => + resolveRouteBindings( + () => {}, + new ConfigReader({ + app: { routes: { bindings: { mySource: 'myTarget' } } }, + }), + emptyIds, + ), + ).toThrow( + "Invalid config at app.routes.bindings, 'mySource' is not a valid external route", + ); + + expect(() => + resolveRouteBindings( + () => {}, + new ConfigReader({ + app: { routes: { bindings: { mySource: 'myTarget' } } }, + }), + { + ...emptyIds, + externalRoutes: new Map([['mySource', createExternalRouteRef()]]), + }, + ), + ).toThrow( + "Invalid config at app.routes.bindings['mySource'], 'myTarget' is not a valid route", + ); + }); }); diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts index 968da005ec..c98e36a75b 100644 --- a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts @@ -19,6 +19,8 @@ import { SubRouteRef, ExternalRouteRef, } from '@backstage/frontend-plugin-api'; +import { RouteRefsById } from './collectRouteIds'; +import { Config } from '@backstage/config'; /** * Extracts a union of the keys in a map whose value extends the given type @@ -73,7 +75,9 @@ export type AppRouteBinder = < /** @internal */ export function resolveRouteBindings( - bindRoutes?: (context: { bind: AppRouteBinder }) => void, + bindRoutes: ((context: { bind: AppRouteBinder }) => void) | undefined, + config: Config, + routesById: RouteRefsById, ): Map { const result = new Map(); @@ -100,5 +104,42 @@ export function resolveRouteBindings( bindRoutes({ bind }); } + const bindingsConfig = config.getOptionalConfig('app.routes.bindings'); + if (!bindingsConfig) { + return result; + } + + const bindings = bindingsConfig.get(); + if (bindings === null || typeof bindings !== 'object') { + throw new Error('Invalid config at app.routes.bindings, must be an object'); + } + + for (const [externalRefId, targetRefId] of Object.entries(bindings)) { + if (typeof targetRefId !== 'string' || targetRefId === '') { + throw new Error( + `Invalid config at app.routes.bindings['${externalRefId}'], value must be a non-empty string`, + ); + } + + const externalRef = routesById.externalRoutes.get(externalRefId); + if (!externalRef) { + throw new Error( + `Invalid config at app.routes.bindings, '${externalRefId}' is not a valid external route`, + ); + } + // Route bindings defined in config have lower priority than those defined in code + if (result.has(externalRef)) { + continue; + } + const targetRef = routesById.routes.get(targetRefId); + if (!targetRef) { + throw new Error( + `Invalid config at app.routes.bindings['${externalRefId}'], '${targetRefId}' is not a valid route`, + ); + } + + result.set(externalRef, targetRef); + } + return result; } diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 4842383096..87b2042d34 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -95,6 +95,7 @@ import { import { AppRouteBinder } from '../routing'; import { RoutingProvider } from '../routing/RoutingProvider'; import { resolveRouteBindings } from '../routing/resolveRouteBindings'; +import { collectRouteIds } from '../routing/collectRouteIds'; /** @public */ export interface ExtensionTreeNode { @@ -305,14 +306,19 @@ export function createApp(options: { ), ); + const routeIds = collectRouteIds(allFeatures); + const App = () => ( {/* TODO: set base path using the logic from AppRouter */} From 3f47f0d9b9f1b9b6ad2d635061cee804585b2752 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 17:26:56 +0200 Subject: [PATCH 26/34] app-next: bind page1 route through config Signed-off-by: Patrik Oldsberg --- packages/app-next/app-config.yaml | 3 +++ packages/app-next/src/App.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index a97c52f40f..eaaa2b0560 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -1,6 +1,9 @@ app: experimental: packages: 'all' # ✨ + routes: + bindings: + plugin.pages.externalRoutes.pageX: plugin.pages.routes.pageX extensions: - apis.plugin.graphiql.browse.gitlab: true diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 03fa03cfb3..92d71fee89 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -75,9 +75,10 @@ const app = createApp({ extensions: [entityPageExtension], }), ], - bindRoutes({ bind }) { - bind(pagesPlugin.externalRoutes, { pageX: pagesPlugin.routes.pageX }); - }, + /* Handled through config instead */ + // bindRoutes({ bind }) { + // bind(pagesPlugin.externalRoutes, { pageX: pagesPlugin.routes.pageX }); + // }, }); // const legacyApp = createLegacyApp({ plugins: [legacyGraphiqlPlugin] }); From 68fc9dc60e10fc684b9a4e975335d6f95edc8a05 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 17:35:39 +0200 Subject: [PATCH 27/34] changesets: added changesets for routing system refactor Signed-off-by: Patrik Oldsberg --- .changeset/eighty-chairs-camp.md | 5 +++++ .changeset/hungry-paws-dress.md | 8 ++++++++ .changeset/modern-ducks-battle.md | 5 +++++ .changeset/tricky-cups-hammer.md | 5 +++++ 4 files changed, 23 insertions(+) create mode 100644 .changeset/eighty-chairs-camp.md create mode 100644 .changeset/hungry-paws-dress.md create mode 100644 .changeset/modern-ducks-battle.md create mode 100644 .changeset/tricky-cups-hammer.md diff --git a/.changeset/eighty-chairs-camp.md b/.changeset/eighty-chairs-camp.md new file mode 100644 index 0000000000..6a240609d4 --- /dev/null +++ b/.changeset/eighty-chairs-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +Added `RouteRef`, `SubRouteRef`, `ExternalRouteRef`, and related types. All exports from this package that previously relied on the types with the same name from `@backstage/core-plugin-api` now use the new types instead. To convert and existing legacy route ref to be compatible with the APIs from this package, use the `convertLegacyRouteRef` utility from `@backstage/core-plugin-api/alpha`. diff --git a/.changeset/hungry-paws-dress.md b/.changeset/hungry-paws-dress.md new file mode 100644 index 0000000000..ede5358d58 --- /dev/null +++ b/.changeset/hungry-paws-dress.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-user-settings': patch +'@backstage/plugin-tech-radar': patch +'@backstage/plugin-graphiql': patch +'@backstage/plugin-search': patch +--- + +Updated alpha exports according to routing changes in `@backstage/frontend-plugin-api`. diff --git a/.changeset/modern-ducks-battle.md b/.changeset/modern-ducks-battle.md new file mode 100644 index 0000000000..e8359cf4ac --- /dev/null +++ b/.changeset/modern-ducks-battle.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': minor +--- + +Added the ability to configure bound routes through `app.routes.bindings`. The routing system used by `createApp` has been replaced by one that only supports route refs of the new format from `@backstage/frontend-plugin-api`. The requirement for route refs to have the same ID as their associated extension has been removed. diff --git a/.changeset/tricky-cups-hammer.md b/.changeset/tricky-cups-hammer.md new file mode 100644 index 0000000000..4e1b6fe9ce --- /dev/null +++ b/.changeset/tricky-cups-hammer.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Added a new `/alpha` export `convertLegacyRouteRef`, which is a temporary utility to allow existing route refs to be used with the new experimental packages. From a285d8c00098197d5dbe57a9c2c3312eaebfda53 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 18:52:06 +0200 Subject: [PATCH 28/34] API report updates and frontend-*-api fixes Signed-off-by: Patrik Oldsberg --- .../app-next-example-plugin/api-report.md | 4 +- packages/frontend-app-api/api-report.md | 17 ++- packages/frontend-app-api/src/index.ts | 1 + packages/frontend-plugin-api/api-report.md | 140 +++++++++++++++++- .../src/routing/SubRouteRef.ts | 19 +-- .../src/wiring/createPlugin.ts | 4 +- .../frontend-plugin-api/src/wiring/index.ts | 2 + plugins/adr/alpha-api-report.md | 4 +- plugins/catalog/alpha-api-report.md | 4 +- plugins/explore/alpha-api-report.md | 4 +- plugins/graphiql/alpha-api-report.md | 11 +- plugins/search/alpha-api-report.md | 11 +- plugins/tech-radar/alpha-api-report.md | 11 +- plugins/techdocs/alpha-api-report.md | 4 +- plugins/user-settings/alpha-api-report.md | 15 +- 15 files changed, 203 insertions(+), 48 deletions(-) diff --git a/packages/app-next-example-plugin/api-report.md b/packages/app-next-example-plugin/api-report.md index 83449a102f..979abac30a 100644 --- a/packages/app-next-example-plugin/api-report.md +++ b/packages/app-next-example-plugin/api-report.md @@ -3,8 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; +import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; +import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { default as React_2 } from 'react'; diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md index 74c34be5f4..11c9ac03c5 100644 --- a/packages/frontend-app-api/api-report.md +++ b/packages/frontend-app-api/api-report.md @@ -3,13 +3,28 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AppRouteBinder } from '@backstage/core-app-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/core-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionOverrides } from '@backstage/frontend-plugin-api'; +import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; +import { RouteRef } from '@backstage/frontend-plugin-api'; +import { SubRouteRef } from '@backstage/frontend-plugin-api'; + +// @public +export type AppRouteBinder = < + TExternalRoutes extends { + [name: string]: ExternalRouteRef; + }, +>( + externalRoutes: TExternalRoutes, + targetRoutes: PartialKeys< + TargetRouteMap, + KeysWithType> + >, +) => void; // @public (undocumented) export function createApp(options: { diff --git a/packages/frontend-app-api/src/index.ts b/packages/frontend-app-api/src/index.ts index f2dfa0f029..ad82b4ceef 100644 --- a/packages/frontend-app-api/src/index.ts +++ b/packages/frontend-app-api/src/index.ts @@ -21,3 +21,4 @@ */ export * from './wiring'; +export * from './routing'; diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 071b617851..7753b1744e 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -7,15 +7,12 @@ import { AnyApiFactory } from '@backstage/core-plugin-api'; import { AnyApiRef } from '@backstage/core-plugin-api'; -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; import { AppTheme } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; -import { RouteRef } from '@backstage/core-plugin-api'; import { z } from 'zod'; import { ZodSchema } from 'zod'; import { ZodTypeDef } from 'zod'; @@ -41,6 +38,23 @@ export type AnyExtensionInputMap = { >; }; +// @public (undocumented) +export type AnyExternalRoutes = { + [name in string]: ExternalRouteRef; +}; + +// @public +export type AnyRouteParams = + | { + [param in string]: string; + } + | undefined; + +// @public (undocumented) +export type AnyRoutes = { + [name in string]: RouteRef; +}; + // @public (undocumented) export interface BackstagePlugin< Routes extends AnyRoutes = AnyRoutes, @@ -79,7 +93,7 @@ export const coreExtensionData: { reactElement: ConfigurableExtensionDataRef; routePath: ConfigurableExtensionDataRef; apiFactory: ConfigurableExtensionDataRef; - routeRef: ConfigurableExtensionDataRef; + routeRef: ConfigurableExtensionDataRef, {}>; navTarget: ConfigurableExtensionDataRef; theme: ConfigurableExtensionDataRef; }; @@ -173,10 +187,35 @@ export function createExtensionOverrides( options: ExtensionOverridesOptions, ): ExtensionOverrides; +// @public +export function createExternalRouteRef< + TParams extends + | { + [param in TParamKeys]: string; + } + | undefined = undefined, + TOptional extends boolean = false, + TParamKeys extends string = string, +>(options?: { + readonly params?: string extends TParamKeys + ? (keyof TParams)[] + : TParamKeys[]; + optional?: TOptional; +}): ExternalRouteRef< + keyof TParams extends never + ? undefined + : string extends TParamKeys + ? TParams + : { + [param in TParamKeys]: string; + }, + TOptional +>; + // @public export function createNavItemExtension(options: { id: string; - routeRef: RouteRef; + routeRef: RouteRef; title: string; icon: IconComponent; }): Extension<{ @@ -215,17 +254,46 @@ export function createPageExtension< // @public (undocumented) export function createPlugin< - Routes extends AnyRoutes = AnyRoutes, - ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes, + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes, >( options: PluginOptions, ): BackstagePlugin; +// @public +export function createRouteRef< + TParams extends + | { + [param in TParamKeys]: string; + } + | undefined = undefined, + TParamKeys extends string = string, +>(config?: { + readonly params: string extends TParamKeys ? (keyof TParams)[] : TParamKeys[]; +}): RouteRef< + keyof TParams extends never + ? undefined + : string extends TParamKeys + ? TParams + : { + [param in TParamKeys]: string; + } +>; + // @public (undocumented) export function createSchemaFromZod( schemaCreator: (zImpl: typeof z) => ZodSchema, ): PortableSchema; +// @public +export function createSubRouteRef< + Path extends string, + ParentParams extends AnyRouteParams = never, +>(config: { + path: Path; + parent: RouteRef; +}): MakeSubRouteRef, ParentParams>; + // @public (undocumented) export function createThemeExtension(theme: AppTheme): Extension; @@ -344,11 +412,24 @@ export interface ExtensionOverridesOptions { extensions: Extension[]; } +// @public +export interface ExternalRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, + TOptional extends boolean = boolean, +> { + // (undocumented) + readonly $$type: '@backstage/ExternalRouteRef'; + // (undocumented) + readonly optional: TOptional; + // (undocumented) + readonly T: TParams; +} + // @public (undocumented) export type NavTarget = { title: string; icon: IconComponent; - routeRef: RouteRef<{}>; + routeRef: RouteRef; }; // @public (undocumented) @@ -371,4 +452,47 @@ export type PortableSchema = { parse: (input: unknown) => TOutput; schema: JsonObject; }; + +// @public +export type RouteFunc = ( + ...[params]: TParams extends undefined + ? readonly [] + : readonly [params: TParams] +) => string; + +// @public +export interface RouteRef { + // (undocumented) + readonly $$type: '@backstage/RouteRef'; + // (undocumented) + readonly T: TParams; +} + +// @public +export interface SubRouteRef { + // (undocumented) + readonly $$type: '@backstage/SubRouteRef'; + // (undocumented) + readonly path: string; + // (undocumented) + readonly T: TParams; +} + +// @public +export function useRouteRef< + TOptional extends boolean, + TParams extends AnyRouteParams, +>( + routeRef: ExternalRouteRef, +): TParams extends true ? RouteFunc | undefined : RouteFunc; + +// @public +export function useRouteRef( + routeRef: RouteRef | SubRouteRef, +): RouteFunc; + +// @public +export function useRouteRefParams( + _routeRef: RouteRef | SubRouteRef, +): Params; ``` diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts index b71ceb6a95..5c093afa7a 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts @@ -105,30 +105,27 @@ export class SubRouteRefImpl * Used in {@link PathParams} type declaration. * @ignore */ -export type ParamPart = S extends `:${infer Param}` - ? Param - : never; +type ParamPart = S extends `:${infer Param}` ? Param : never; /** * Used in {@link PathParams} type declaration. * @ignore */ -export type ParamNames = - S extends `${infer Part}/${infer Rest}` - ? ParamPart | ParamNames - : ParamPart; +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 }` * @ignore */ -export type PathParams = { [name in ParamNames]: string }; +type PathParams = { [name in ParamNames]: string }; /** * Merges a param object type with an optional params type into a params object. * @ignore */ -export type MergeParams< +type MergeParams< P1 extends { [param in string]: string }, P2 extends AnyRouteParams, > = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); @@ -137,7 +134,7 @@ export type MergeParams< * Convert empty params to undefined. * @ignore */ -export type TrimEmptyParams = +type TrimEmptyParams = keyof Params extends never ? undefined : Params; /** @@ -146,7 +143,7 @@ export type TrimEmptyParams = * * @ignore */ -export type MakeSubRouteRef< +type MakeSubRouteRef< Params extends { [param in string]: string }, ParentParams extends AnyRouteParams, > = keyof Params & keyof ParentParams extends never diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.ts index 782e5134ec..cf18de2b5d 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.ts @@ -17,10 +17,10 @@ import { Extension } from './createExtension'; import { ExternalRouteRef, RouteRef } from '../routing'; -/** @internal */ +/** @public */ export type AnyRoutes = { [name in string]: RouteRef }; -/** @internal */ +/** @public */ export type AnyExternalRoutes = { [name in string]: ExternalRouteRef }; /** @public */ diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 4e4104de97..ec685d6c81 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -37,6 +37,8 @@ export { createPlugin, type BackstagePlugin, type PluginOptions, + type AnyRoutes, + type AnyExternalRoutes, } from './createPlugin'; export { createExtensionOverrides, diff --git a/plugins/adr/alpha-api-report.md b/plugins/adr/alpha-api-report.md index 6dd1c8043f..379b7e2ada 100644 --- a/plugins/adr/alpha-api-report.md +++ b/plugins/adr/alpha-api-report.md @@ -3,8 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; +import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; +import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; diff --git a/plugins/catalog/alpha-api-report.md b/plugins/catalog/alpha-api-report.md index 4582a13a5d..0ae94b81ae 100644 --- a/plugins/catalog/alpha-api-report.md +++ b/plugins/catalog/alpha-api-report.md @@ -3,8 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; +import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; +import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; diff --git a/plugins/explore/alpha-api-report.md b/plugins/explore/alpha-api-report.md index 308cbac733..b4de3ef3db 100644 --- a/plugins/explore/alpha-api-report.md +++ b/plugins/explore/alpha-api-report.md @@ -3,8 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; +import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; +import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; diff --git a/plugins/graphiql/alpha-api-report.md b/plugins/graphiql/alpha-api-report.md index 6c0ea02988..eca4a81c33 100644 --- a/plugins/graphiql/alpha-api-report.md +++ b/plugins/graphiql/alpha-api-report.md @@ -3,12 +3,12 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; +import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; import { GraphQLEndpoint } from '@backstage/plugin-graphiql'; import { PortableSchema } from '@backstage/frontend-plugin-api'; +import { RouteRef } from '@backstage/frontend-plugin-api'; // @alpha (undocumented) export function createEndpointExtension(options: { @@ -21,7 +21,12 @@ export function createEndpointExtension(options: { }): Extension; // @alpha (undocumented) -const _default: BackstagePlugin; +const _default: BackstagePlugin< + { + root: RouteRef; + }, + AnyExternalRoutes +>; export default _default; // @alpha (undocumented) diff --git a/plugins/search/alpha-api-report.md b/plugins/search/alpha-api-report.md index 58c963b1d6..cc4b68f86e 100644 --- a/plugins/search/alpha-api-report.md +++ b/plugins/search/alpha-api-report.md @@ -3,13 +3,18 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; +import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; +import { RouteRef } from '@backstage/frontend-plugin-api'; // @alpha (undocumented) -const _default: BackstagePlugin; +const _default: BackstagePlugin< + { + root: RouteRef; + }, + AnyExternalRoutes +>; export default _default; // @alpha (undocumented) diff --git a/plugins/tech-radar/alpha-api-report.md b/plugins/tech-radar/alpha-api-report.md index 9245acfaf2..24846a1106 100644 --- a/plugins/tech-radar/alpha-api-report.md +++ b/plugins/tech-radar/alpha-api-report.md @@ -3,13 +3,18 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; +import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; +import { RouteRef } from '@backstage/frontend-plugin-api'; // @alpha (undocumented) -const _default: BackstagePlugin; +const _default: BackstagePlugin< + { + root: RouteRef; + }, + AnyExternalRoutes +>; export default _default; // @alpha (undocumented) diff --git a/plugins/techdocs/alpha-api-report.md b/plugins/techdocs/alpha-api-report.md index 0bf8c25b33..5fd1374114 100644 --- a/plugins/techdocs/alpha-api-report.md +++ b/plugins/techdocs/alpha-api-report.md @@ -3,8 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; +import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; +import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; diff --git a/plugins/user-settings/alpha-api-report.md b/plugins/user-settings/alpha-api-report.md index 8a3e109e5b..4ad22fa984 100644 --- a/plugins/user-settings/alpha-api-report.md +++ b/plugins/user-settings/alpha-api-report.md @@ -3,19 +3,20 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/core-plugin-api'; -import { AnyRoutes } from '@backstage/core-plugin-api'; +import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; -import { RouteRef } from '@backstage/core-plugin-api'; +import { RouteRef } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; // @alpha (undocumented) -const _default: BackstagePlugin; +const _default: BackstagePlugin< + { + root: RouteRef; + }, + AnyExternalRoutes +>; export default _default; -// @alpha (undocumented) -export const userSettingsRouteRef: RouteRef; - // @alpha (undocumented) export const userSettingsTranslationRef: TranslationRef< 'user-settings', From 5b665a3810a45987bc9b44e74b0fb14de4d66fe8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 19:56:51 +0200 Subject: [PATCH 29/34] frontend-plugin-api: rename AnyRouteParams -> AnyRouteRefParams Signed-off-by: Patrik Oldsberg --- .../src/routing/RouteResolver.ts | 4 ++-- .../extractRouteInfoFromInstanceTree.test.ts | 4 ++-- packages/frontend-plugin-api/api-report.md | 24 +++++++++++-------- .../src/routing/ExternalRouteRef.test.ts | 4 ++-- .../src/routing/ExternalRouteRef.ts | 8 +++---- .../src/routing/RouteRef.test.ts | 4 ++-- .../src/routing/RouteRef.ts | 10 ++++---- .../src/routing/SubRouteRef.test.ts | 4 ++-- .../src/routing/SubRouteRef.ts | 18 +++++++------- .../frontend-plugin-api/src/routing/index.ts | 2 +- .../frontend-plugin-api/src/routing/types.ts | 2 +- .../src/routing/useRouteRef.tsx | 12 +++++----- .../src/routing/useRouteRefParams.ts | 4 ++-- 13 files changed, 54 insertions(+), 46 deletions(-) diff --git a/packages/frontend-app-api/src/routing/RouteResolver.ts b/packages/frontend-app-api/src/routing/RouteResolver.ts index 9da5836be4..77336988e2 100644 --- a/packages/frontend-app-api/src/routing/RouteResolver.ts +++ b/packages/frontend-app-api/src/routing/RouteResolver.ts @@ -19,7 +19,7 @@ import { RouteRef, ExternalRouteRef, SubRouteRef, - AnyRouteParams, + AnyRouteRefParams, RouteFunc, } from '@backstage/frontend-plugin-api'; import mapValues from 'lodash/mapValues'; @@ -189,7 +189,7 @@ export class RouteResolver { private readonly appBasePath: string, // base path without a trailing slash ) {} - resolve( + resolve( anyRouteRef: | RouteRef | SubRouteRef diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts index 86a071df79..0de33b50f2 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts @@ -18,7 +18,7 @@ import React from 'react'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { extractRouteInfoFromInstanceTree } from './extractRouteInfoFromInstanceTree'; import { - AnyRouteParams, + AnyRouteRefParams, Extension, RouteRef, coreExtensionData, @@ -35,7 +35,7 @@ const ref2 = createRouteRef(); const ref3 = createRouteRef(); const ref4 = createRouteRef(); const ref5 = createRouteRef(); -const refOrder: RouteRef[] = [ref1, ref2, ref3, ref4, ref5]; +const refOrder: RouteRef[] = [ref1, ref2, ref3, ref4, ref5]; function createTestExtension(options: { id: string; diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 7753b1744e..9169d9c9e8 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -44,7 +44,7 @@ export type AnyExternalRoutes = { }; // @public -export type AnyRouteParams = +export type AnyRouteRefParams = | { [param in string]: string; } @@ -93,7 +93,7 @@ export const coreExtensionData: { reactElement: ConfigurableExtensionDataRef; routePath: ConfigurableExtensionDataRef; apiFactory: ConfigurableExtensionDataRef; - routeRef: ConfigurableExtensionDataRef, {}>; + routeRef: ConfigurableExtensionDataRef, {}>; navTarget: ConfigurableExtensionDataRef; theme: ConfigurableExtensionDataRef; }; @@ -288,7 +288,7 @@ export function createSchemaFromZod( // @public export function createSubRouteRef< Path extends string, - ParentParams extends AnyRouteParams = never, + ParentParams extends AnyRouteRefParams = never, >(config: { path: Path; parent: RouteRef; @@ -414,7 +414,7 @@ export interface ExtensionOverridesOptions { // @public export interface ExternalRouteRef< - TParams extends AnyRouteParams = AnyRouteParams, + TParams extends AnyRouteRefParams = AnyRouteRefParams, TOptional extends boolean = boolean, > { // (undocumented) @@ -454,14 +454,16 @@ export type PortableSchema = { }; // @public -export type RouteFunc = ( +export type RouteFunc = ( ...[params]: TParams extends undefined ? readonly [] : readonly [params: TParams] ) => string; // @public -export interface RouteRef { +export interface RouteRef< + TParams extends AnyRouteRefParams = AnyRouteRefParams, +> { // (undocumented) readonly $$type: '@backstage/RouteRef'; // (undocumented) @@ -469,7 +471,9 @@ export interface RouteRef { } // @public -export interface SubRouteRef { +export interface SubRouteRef< + TParams extends AnyRouteRefParams = AnyRouteRefParams, +> { // (undocumented) readonly $$type: '@backstage/SubRouteRef'; // (undocumented) @@ -481,18 +485,18 @@ export interface SubRouteRef { // @public export function useRouteRef< TOptional extends boolean, - TParams extends AnyRouteParams, + TParams extends AnyRouteRefParams, >( routeRef: ExternalRouteRef, ): TParams extends true ? RouteFunc | undefined : RouteFunc; // @public -export function useRouteRef( +export function useRouteRef( routeRef: RouteRef | SubRouteRef, ): RouteFunc; // @public -export function useRouteRefParams( +export function useRouteRefParams( _routeRef: RouteRef | SubRouteRef, ): Params; ``` diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts index ad47941833..727dc1f53e 100644 --- a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts @@ -19,7 +19,7 @@ import { createExternalRouteRef, toInternalExternalRouteRef, } from './ExternalRouteRef'; -import { AnyRouteParams } from './types'; +import { AnyRouteRefParams } from './types'; describe('ExternalRouteRef', () => { it('should be created', () => { @@ -67,7 +67,7 @@ describe('ExternalRouteRef', () => { it('should properly infer and validate parameter types and assignments', () => { function checkRouteRef< - T extends AnyRouteParams, + T extends AnyRouteRefParams, TOptional extends boolean, TCheck extends TOptional, >( diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts index ad5ad05f05..c9aabdef27 100644 --- a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts @@ -16,7 +16,7 @@ import { RouteRefImpl } from './RouteRef'; import { describeParentCallSite } from './describeParentCallSite'; -import { AnyRouteParams } from './types'; +import { AnyRouteRefParams } from './types'; /** * Route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references. @@ -28,7 +28,7 @@ import { AnyRouteParams } from './types'; * @public */ export interface ExternalRouteRef< - TParams extends AnyRouteParams = AnyRouteParams, + TParams extends AnyRouteRefParams = AnyRouteRefParams, TOptional extends boolean = boolean, > { readonly $$type: '@backstage/ExternalRouteRef'; @@ -38,7 +38,7 @@ export interface ExternalRouteRef< /** @internal */ export interface InternalExternalRouteRef< - TParams extends AnyRouteParams = AnyRouteParams, + TParams extends AnyRouteRefParams = AnyRouteRefParams, TOptional extends boolean = boolean, > extends ExternalRouteRef { readonly version: 'v1'; @@ -50,7 +50,7 @@ export interface InternalExternalRouteRef< /** @internal */ export function toInternalExternalRouteRef< - TParams extends AnyRouteParams = AnyRouteParams, + TParams extends AnyRouteRefParams = AnyRouteRefParams, TOptional extends boolean = boolean, >( resource: ExternalRouteRef, diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts index c8d7c22e35..de7a3de887 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AnyRouteParams } from './types'; +import { AnyRouteRefParams } from './types'; import { RouteRef, createRouteRef, toInternalRouteRef } from './RouteRef'; describe('RouteRef', () => { @@ -54,7 +54,7 @@ describe('RouteRef', () => { }); it('should properly infer and validate parameter types and assignments', () => { - function checkRouteRef( + function checkRouteRef( _ref: RouteRef, _params: T extends undefined ? undefined : T, ) {} diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.ts b/packages/frontend-plugin-api/src/routing/RouteRef.ts index 1085644993..da562e5273 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.ts @@ -15,7 +15,7 @@ */ import { describeParentCallSite } from './describeParentCallSite'; -import { AnyRouteParams } from './types'; +import { AnyRouteRefParams } from './types'; /** * Absolute route reference. @@ -26,14 +26,16 @@ import { AnyRouteParams } from './types'; * * @public */ -export interface RouteRef { +export interface RouteRef< + TParams extends AnyRouteRefParams = AnyRouteRefParams, +> { readonly $$type: '@backstage/RouteRef'; readonly T: TParams; } /** @internal */ export interface InternalRouteRef< - TParams extends AnyRouteParams = AnyRouteParams, + TParams extends AnyRouteRefParams = AnyRouteRefParams, > extends RouteRef { readonly version: 'v1'; getParams(): string[]; @@ -44,7 +46,7 @@ export interface InternalRouteRef< /** @internal */ export function toInternalRouteRef< - TParams extends AnyRouteParams = AnyRouteParams, + TParams extends AnyRouteRefParams = AnyRouteRefParams, >(resource: RouteRef): InternalRouteRef { const r = resource as InternalRouteRef; if (r.$$type !== '@backstage/RouteRef') { diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts index 5b1ef6d836..b04728c49b 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AnyRouteParams } from './types'; +import { AnyRouteRefParams } from './types'; import { SubRouteRef, createSubRouteRef, @@ -96,7 +96,7 @@ describe('SubRouteRef', () => { }); it('should properly infer and parse path parameters', () => { - function checkSubRouteRef( + function checkSubRouteRef( _ref: SubRouteRef, _params: T extends undefined ? undefined : T, ) {} diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts index 5c093afa7a..74996ab4e7 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts @@ -15,7 +15,7 @@ */ import { RouteRef, toInternalRouteRef } from './RouteRef'; -import { AnyRouteParams } from './types'; +import { AnyRouteRefParams } from './types'; // Should match the pattern in react-router const PARAM_PATTERN = /^\w+$/; @@ -29,7 +29,9 @@ const PARAM_PATTERN = /^\w+$/; * * @public */ -export interface SubRouteRef { +export interface SubRouteRef< + TParams extends AnyRouteRefParams = AnyRouteRefParams, +> { readonly $$type: '@backstage/SubRouteRef'; readonly T: TParams; @@ -39,7 +41,7 @@ export interface SubRouteRef { /** @internal */ export interface InternalSubRouteRef< - TParams extends AnyRouteParams = AnyRouteParams, + TParams extends AnyRouteRefParams = AnyRouteRefParams, > extends SubRouteRef { readonly version: 'v1'; @@ -50,7 +52,7 @@ export interface InternalSubRouteRef< /** @internal */ export function toInternalSubRouteRef< - TParams extends AnyRouteParams = AnyRouteParams, + TParams extends AnyRouteRefParams = AnyRouteRefParams, >(resource: SubRouteRef): InternalSubRouteRef { const r = resource as InternalSubRouteRef; if (r.$$type !== '@backstage/SubRouteRef') { @@ -68,7 +70,7 @@ export function isSubRouteRef(opaque: { } /** @internal */ -export class SubRouteRefImpl +export class SubRouteRefImpl implements SubRouteRef { readonly $$type = '@backstage/SubRouteRef'; @@ -127,7 +129,7 @@ type PathParams = { [name in ParamNames]: string }; */ type MergeParams< P1 extends { [param in string]: string }, - P2 extends AnyRouteParams, + P2 extends AnyRouteRefParams, > = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); /** @@ -145,7 +147,7 @@ type TrimEmptyParams = */ type MakeSubRouteRef< Params extends { [param in string]: string }, - ParentParams extends AnyRouteParams, + ParentParams extends AnyRouteRefParams, > = keyof Params & keyof ParentParams extends never ? SubRouteRef>> : never; @@ -158,7 +160,7 @@ type MakeSubRouteRef< */ export function createSubRouteRef< Path extends string, - ParentParams extends AnyRouteParams = never, + ParentParams extends AnyRouteRefParams = never, >(config: { path: Path; parent: RouteRef; diff --git a/packages/frontend-plugin-api/src/routing/index.ts b/packages/frontend-plugin-api/src/routing/index.ts index d262bbed58..6c25f725b5 100644 --- a/packages/frontend-plugin-api/src/routing/index.ts +++ b/packages/frontend-plugin-api/src/routing/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export type { AnyRouteParams } from './types'; +export type { AnyRouteRefParams } from './types'; export { createRouteRef, type RouteRef } from './RouteRef'; export { createSubRouteRef, type SubRouteRef } from './SubRouteRef'; export { diff --git a/packages/frontend-plugin-api/src/routing/types.ts b/packages/frontend-plugin-api/src/routing/types.ts index 8fe0d7e8a4..3bf1323210 100644 --- a/packages/frontend-plugin-api/src/routing/types.ts +++ b/packages/frontend-plugin-api/src/routing/types.ts @@ -19,4 +19,4 @@ * * @public */ -export type AnyRouteParams = { [param in string]: string } | undefined; +export type AnyRouteRefParams = { [param in string]: string } | undefined; diff --git a/packages/frontend-plugin-api/src/routing/useRouteRef.tsx b/packages/frontend-plugin-api/src/routing/useRouteRef.tsx index 3754d2e6d5..dfcb110930 100644 --- a/packages/frontend-plugin-api/src/routing/useRouteRef.tsx +++ b/packages/frontend-plugin-api/src/routing/useRouteRef.tsx @@ -17,7 +17,7 @@ import { useMemo } from 'react'; import { matchRoutes, useLocation } from 'react-router-dom'; import { useVersionedContext } from '@backstage/version-bridge'; -import { AnyRouteParams } from './types'; +import { AnyRouteRefParams } from './types'; import { RouteRef } from './RouteRef'; import { SubRouteRef } from './SubRouteRef'; import { ExternalRouteRef } from './ExternalRouteRef'; @@ -35,7 +35,7 @@ import { ExternalRouteRef } from './ExternalRouteRef'; * * @public */ -export type RouteFunc = ( +export type RouteFunc = ( ...[params]: TParams extends undefined ? readonly [] : readonly [params: TParams] @@ -45,7 +45,7 @@ export type RouteFunc = ( * @internal */ export interface RouteResolver { - resolve( + resolve( anyRouteRef: | RouteRef | SubRouteRef @@ -67,7 +67,7 @@ export interface RouteResolver { */ export function useRouteRef< TOptional extends boolean, - TParams extends AnyRouteParams, + TParams extends AnyRouteRefParams, >( routeRef: ExternalRouteRef, ): TParams extends true ? RouteFunc | undefined : RouteFunc; @@ -83,7 +83,7 @@ export function useRouteRef< * @returns A function that will in turn return the concrete URL of the `routeRef`. * @public */ -export function useRouteRef( +export function useRouteRef( routeRef: RouteRef | SubRouteRef, ): RouteFunc; @@ -98,7 +98,7 @@ export function useRouteRef( * @returns A function that will in turn return the concrete URL of the `routeRef`. * @public */ -export function useRouteRef( +export function useRouteRef( routeRef: | RouteRef | SubRouteRef diff --git a/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts b/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts index dbe34a4d01..1bbd8ec6b8 100644 --- a/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts +++ b/packages/frontend-plugin-api/src/routing/useRouteRefParams.ts @@ -15,7 +15,7 @@ */ import { useParams } from 'react-router-dom'; -import { AnyRouteParams } from './types'; +import { AnyRouteRefParams } from './types'; import { RouteRef } from './RouteRef'; import { SubRouteRef } from './SubRouteRef'; @@ -24,7 +24,7 @@ import { SubRouteRef } from './SubRouteRef'; * @param _routeRef - Ref of the current route. * @public */ -export function useRouteRefParams( +export function useRouteRefParams( _routeRef: RouteRef | SubRouteRef, ): Params { return useParams() as Params; From cb6db75bc2f74253c716f895a7882edda0f17252 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 19:59:29 +0200 Subject: [PATCH 30/34] core-plugin-api: deprecate old routing types and introduce AnyRouteRefParams + report fixes Signed-off-by: Patrik Oldsberg --- .changeset/stale-rice-count.md | 5 +++ .changeset/tender-maps-type.md | 5 +++ packages/core-plugin-api/alpha-api-report.md | 22 +++++++++++ packages/core-plugin-api/api-report.md | 19 +++++---- .../src/routing/SubRouteRef.ts | 5 +++ .../src/routing/convertLegacyRouteRef.ts | 39 +++++++++++++------ packages/core-plugin-api/src/routing/index.ts | 1 + packages/core-plugin-api/src/routing/types.ts | 17 +++++++- 8 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 .changeset/stale-rice-count.md create mode 100644 .changeset/tender-maps-type.md diff --git a/.changeset/stale-rice-count.md b/.changeset/stale-rice-count.md new file mode 100644 index 0000000000..3d2caf168d --- /dev/null +++ b/.changeset/stale-rice-count.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Deprecated several types related to the routing system that are scheduled to be removed, as well as several fields on the route ref types themselves. diff --git a/.changeset/tender-maps-type.md b/.changeset/tender-maps-type.md new file mode 100644 index 0000000000..11aa814a17 --- /dev/null +++ b/.changeset/tender-maps-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': minor +--- + +Introduced `AnyRouteRefParams` as a replacement for `AnyParams`, which is now deprecated. diff --git a/packages/core-plugin-api/alpha-api-report.md b/packages/core-plugin-api/alpha-api-report.md index ba3583142f..0284d691a9 100644 --- a/packages/core-plugin-api/alpha-api-report.md +++ b/packages/core-plugin-api/alpha-api-report.md @@ -3,8 +3,12 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AnyRouteRefParams } from '@backstage/core-plugin-api'; import { ApiRef } from '@backstage/core-plugin-api'; +import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; +import { RouteRef } from '@backstage/core-plugin-api'; +import { SubRouteRef } from '@backstage/core-plugin-api'; import { TranslationMessages as TranslationMessages_2 } from '@backstage/core-plugin-api/alpha'; import { TranslationRef as TranslationRef_2 } from '@backstage/core-plugin-api/alpha'; @@ -25,6 +29,24 @@ export type AppLanguageApi = { // @alpha (undocumented) export const appLanguageApiRef: ApiRef; +// @public +export function convertLegacyRouteRef( + ref: RouteRef, +): NewRouteRef; + +// @public +export function convertLegacyRouteRef( + ref: SubRouteRef, +): NewSubRouteRef; + +// @public +export function convertLegacyRouteRef< + TParams extends AnyRouteRefParams, + TOptional extends boolean, +>( + ref: ExternalRouteRef, +): NewExternalRouteRef; + // @alpha export function createTranslationMessages< TId extends string, diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 7873b23f13..789fc2bfdc 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -95,8 +95,11 @@ export type AnyExternalRoutes = { [name: string]: ExternalRouteRef; }; +// @public @deprecated (undocumented) +export type AnyParams = AnyRouteRefParams; + // @public -export type AnyParams = +export type AnyRouteRefParams = | { [param in string]: string; } @@ -523,7 +526,7 @@ export type IdentityApi = { // @public export const identityApiRef: ApiRef; -// @public +// @public @deprecated export type MakeSubRouteRef< Params extends { [param in string]: string; @@ -533,7 +536,7 @@ export type MakeSubRouteRef< ? SubRouteRef>> : never; -// @public +// @public @deprecated export type MergeParams< P1 extends { [param in string]: string; @@ -606,30 +609,30 @@ export type OpenIdConnectApi = { getIdToken(options?: AuthRequestOptions): Promise; }; -// @public +// @public @deprecated export type OptionalParams< Params extends { [param in string]: string; }, > = Params[keyof Params] extends never ? undefined : Params; -// @public +// @public @deprecated export type ParamKeys = keyof Params extends never ? [] : (keyof Params)[]; -// @public +// @public @deprecated export type ParamNames = S extends `${infer Part}/${infer Rest}` ? ParamPart | ParamNames : ParamPart; -// @public +// @public @deprecated export type ParamPart = S extends `:${infer Param}` ? Param : never; -// @public +// @public @deprecated export type PathParams = { [name in ParamNames]: string; }; diff --git a/packages/core-plugin-api/src/routing/SubRouteRef.ts b/packages/core-plugin-api/src/routing/SubRouteRef.ts index 14a614c2ba..b08368d74e 100644 --- a/packages/core-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/core-plugin-api/src/routing/SubRouteRef.ts @@ -51,6 +51,7 @@ export class SubRouteRefImpl /** * Used in {@link PathParams} type declaration. * @public + * @deprecated this type is deprecated and will be removed in the future */ export type ParamPart = S extends `:${infer Param}` ? Param @@ -59,6 +60,7 @@ export type ParamPart = S extends `:${infer Param}` /** * Used in {@link PathParams} type declaration. * @public + * @deprecated this type is deprecated and will be removed in the future */ export type ParamNames = S extends `${infer Part}/${infer Rest}` @@ -68,12 +70,14 @@ export type ParamNames = * 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 + * @deprecated this type is deprecated and will be removed in the future */ export type PathParams = { [name in ParamNames]: string }; /** * Merges a param object type with an optional params type into a params object. * @public + * @deprecated this type is deprecated and will be removed in the future */ export type MergeParams< P1 extends { [param in string]: string }, @@ -85,6 +89,7 @@ export type MergeParams< * The parameters types are merged together while ensuring that there is no overlap between the two. * * @public + * @deprecated this type is deprecated and will be removed in the future */ export type MakeSubRouteRef< Params extends { [param in string]: string }, diff --git a/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts b/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts index 9778625355..940a4174a9 100644 --- a/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts +++ b/packages/core-plugin-api/src/routing/convertLegacyRouteRef.ts @@ -14,12 +14,13 @@ * limitations under the License. */ +import { routeRefType } from './types'; import { - routeRefType, RouteRef as LegacyRouteRef, SubRouteRef as LegacySubRouteRef, ExternalRouteRef as LegacyExternalRouteRef, -} from './types'; + AnyRouteRefParams, +} from '@backstage/core-plugin-api'; // Relative imports to avoid dependency, at least for now @@ -28,7 +29,6 @@ import { RouteRef, SubRouteRef, ExternalRouteRef, - AnyRouteParams, createRouteRef, createSubRouteRef, createExternalRouteRef, @@ -40,6 +40,22 @@ import { toInternalSubRouteRef } from '../../../frontend-plugin-api/src/routing/ // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; +// TODO(Rugvip): Once this is moved to a compat package these aliases can be removed and imported from frontend- instead + +/** @ignore */ +type NewRouteRef = + RouteRef; + +/** @ignore */ +type NewSubRouteRef = + SubRouteRef; + +/** @ignore */ +type NewExternalRouteRef< + TParams extends AnyRouteRefParams = AnyRouteRefParams, + TOptional extends boolean = boolean, +> = ExternalRouteRef; + /** * A temporary helper to convert a legacy route ref to the new system. * @@ -48,9 +64,9 @@ import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/rou * * In the future the legacy createRouteRef will instead create refs compatible with both systems. */ -export function convertLegacyRouteRef( +export function convertLegacyRouteRef( ref: LegacyRouteRef, -): RouteRef; +): NewRouteRef; /** * A temporary helper to convert a legacy sub route ref to the new system. @@ -60,9 +76,9 @@ export function convertLegacyRouteRef( * * In the future the legacy createSubRouteRef will instead create refs compatible with both systems. */ -export function convertLegacyRouteRef( +export function convertLegacyRouteRef( ref: LegacySubRouteRef, -): SubRouteRef; +): NewSubRouteRef; /** * A temporary helper to convert a legacy external route ref to the new system. @@ -73,17 +89,18 @@ export function convertLegacyRouteRef( * In the future the legacy createExternalRouteRef will instead create refs compatible with both systems. */ export function convertLegacyRouteRef< - TParams extends AnyRouteParams, + TParams extends AnyRouteRefParams, TOptional extends boolean, >( ref: LegacyExternalRouteRef, -): ExternalRouteRef; +): NewExternalRouteRef; + export function convertLegacyRouteRef( ref: LegacyRouteRef | LegacySubRouteRef | LegacyExternalRouteRef, -): RouteRef | SubRouteRef | ExternalRouteRef { +): NewRouteRef | NewSubRouteRef | NewExternalRouteRef { // Ref has already been converted if ('$$type' in ref) { - return ref as unknown as RouteRef | SubRouteRef | ExternalRouteRef; + return ref as unknown as NewRouteRef | NewSubRouteRef | NewExternalRouteRef; } const type = (ref as unknown as { [routeRefType]: unknown })[routeRefType]; diff --git a/packages/core-plugin-api/src/routing/index.ts b/packages/core-plugin-api/src/routing/index.ts index 01d69cd4b0..4f8b12ec73 100644 --- a/packages/core-plugin-api/src/routing/index.ts +++ b/packages/core-plugin-api/src/routing/index.ts @@ -16,6 +16,7 @@ export type { AnyParams, + AnyRouteRefParams, RouteRef, SubRouteRef, ExternalRouteRef, diff --git a/packages/core-plugin-api/src/routing/types.ts b/packages/core-plugin-api/src/routing/types.ts index 80653518bb..32f26f19c1 100644 --- a/packages/core-plugin-api/src/routing/types.ts +++ b/packages/core-plugin-api/src/routing/types.ts @@ -21,12 +21,19 @@ import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; * * @public */ -export type AnyParams = { [param in string]: string } | undefined; +export type AnyRouteRefParams = { [param in string]: string } | undefined; + +/** + * @deprecated use {@link AnyRouteRefParams} instead + * @public + */ +export type AnyParams = AnyRouteRefParams; /** * Type describing the key type of a route parameter mapping. * * @public + * @deprecated this type is deprecated and will be removed in the future */ export type ParamKeys = keyof Params extends never ? [] @@ -36,6 +43,7 @@ export type ParamKeys = keyof Params extends never * Optional route params. * * @public + * @deprecated this type is deprecated and will be removed in the future */ export type OptionalParams = Params[keyof Params] extends never ? undefined : Params; @@ -81,8 +89,10 @@ export const routeRefType: unique symbol = getOrCreateGlobalSingleton( * @public */ export type RouteRef = { + /** @deprecated access to this property will be removed in the future */ $$routeRefType: 'absolute'; // See routeRefType above + /** @deprecated access to this property will be removed in the future */ params: ParamKeys; }; @@ -96,12 +106,15 @@ export type RouteRef = { * @public */ export type SubRouteRef = { + /** @deprecated access to this property will be removed in the future */ $$routeRefType: 'sub'; // See routeRefType above + /** @deprecated access to this property will be removed in the future */ parent: RouteRef; path: string; + /** @deprecated access to this property will be removed in the future */ params: ParamKeys; }; @@ -118,8 +131,10 @@ export type ExternalRouteRef< Params extends AnyParams = any, Optional extends boolean = any, > = { + /** @deprecated access to this property will be removed in the future */ $$routeRefType: 'external'; // See routeRefType above + /** @deprecated access to this property will be removed in the future */ params: ParamKeys; optional?: Optional; From 10d75752ddb48226c4afa20b7d161a225fb49771 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 20:07:54 +0200 Subject: [PATCH 31/34] techdocs: migrate alpha exports to new routing API Signed-off-by: Patrik Oldsberg --- .changeset/hungry-paws-dress.md | 1 + plugins/techdocs/alpha-api-report.md | 14 ++++++++++++-- plugins/techdocs/src/alpha.tsx | 9 +++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.changeset/hungry-paws-dress.md b/.changeset/hungry-paws-dress.md index ede5358d58..b227a77e72 100644 --- a/.changeset/hungry-paws-dress.md +++ b/.changeset/hungry-paws-dress.md @@ -1,5 +1,6 @@ --- '@backstage/plugin-user-settings': patch +'@backstage/plugin-techdocs': patch '@backstage/plugin-tech-radar': patch '@backstage/plugin-graphiql': patch '@backstage/plugin-search': patch diff --git a/plugins/techdocs/alpha-api-report.md b/plugins/techdocs/alpha-api-report.md index 5fd1374114..bc6b8926cf 100644 --- a/plugins/techdocs/alpha-api-report.md +++ b/plugins/techdocs/alpha-api-report.md @@ -4,12 +4,22 @@ ```ts import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; -import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; +import { RouteRef } from '@backstage/frontend-plugin-api'; // @alpha (undocumented) -const _default: BackstagePlugin; +const _default: BackstagePlugin< + { + root: RouteRef; + docRoot: RouteRef<{ + name: string; + kind: string; + namespace: string; + }>; + }, + AnyExternalRoutes +>; export default _default; // @alpha (undocumented) diff --git a/plugins/techdocs/src/alpha.tsx b/plugins/techdocs/src/alpha.tsx index 2f991f2fcc..f4d4da6e93 100644 --- a/plugins/techdocs/src/alpha.tsx +++ b/plugins/techdocs/src/alpha.tsx @@ -35,6 +35,7 @@ import { techdocsStorageApiRef, } from '@backstage/plugin-techdocs-react'; import { TechDocsClient, TechDocsStorageClient } from './client'; +import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; const rootRouteRef = createRouteRef({ id: 'plugin.techdocs.indexPage', @@ -76,7 +77,7 @@ export const TechDocsSearchResultListItemExtension = const TechDocsIndexPage = createPageExtension({ id: 'plugin.techdocs.indexPage', defaultPath: '/docs', - routeRef: rootRouteRef, + routeRef: convertLegacyRouteRef(rootRouteRef), loader: () => import('./home/components/TechDocsIndexPage').then(m => ( @@ -94,7 +95,7 @@ const TechDocsReaderPage = createPageExtension({ import('./reader/components/TechDocsReaderPage').then(m => ( )), - routeRef: rootDocsRouteRef, + routeRef: convertLegacyRouteRef(rootDocsRouteRef), defaultPath: '/docs/:namespace/:kind/:name/*', }); @@ -153,4 +154,8 @@ export default createPlugin({ techDocsStorage, TechDocsSearchResultListItemExtension, ], + routes: { + root: convertLegacyRouteRef(rootRouteRef), + docRoot: convertLegacyRouteRef(rootDocsRouteRef), + }, }); From 6c8834add07174637cc9153ed26c1cad598c1693 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Oct 2023 20:36:21 +0200 Subject: [PATCH 32/34] frontend-plugin-api: add missing tlr dep Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index e996881611..8d90a84d71 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -27,6 +27,7 @@ "@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" }, diff --git a/yarn.lock b/yarn.lock index 4e47f15fd3..492c512e76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4236,6 +4236,7 @@ __metadata: "@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 From c4c13a6cb11b542d82070fc71dfe927725acccde Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Oct 2023 12:50:36 +0200 Subject: [PATCH 33/34] frontend-plugin-api: default plugin routes to empty Signed-off-by: Patrik Oldsberg --- packages/app-next-example-plugin/api-report.md | 4 +--- packages/frontend-plugin-api/api-report.md | 4 ++-- packages/frontend-plugin-api/src/wiring/createPlugin.ts | 4 ++-- plugins/adr/alpha-api-report.md | 4 +--- plugins/catalog/alpha-api-report.md | 4 +--- plugins/explore/alpha-api-report.md | 4 +--- plugins/graphiql/alpha-api-report.md | 3 +-- plugins/search/alpha-api-report.md | 3 +-- plugins/tech-radar/alpha-api-report.md | 3 +-- plugins/techdocs/alpha-api-report.md | 3 +-- plugins/user-settings/alpha-api-report.md | 3 +-- 11 files changed, 13 insertions(+), 26 deletions(-) diff --git a/packages/app-next-example-plugin/api-report.md b/packages/app-next-example-plugin/api-report.md index 979abac30a..c89a7b599b 100644 --- a/packages/app-next-example-plugin/api-report.md +++ b/packages/app-next-example-plugin/api-report.md @@ -3,13 +3,11 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; -import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { default as React_2 } from 'react'; // @public (undocumented) -const examplePlugin: BackstagePlugin; +const examplePlugin: BackstagePlugin<{}, {}>; export default examplePlugin; // @public (undocumented) diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 9169d9c9e8..3ed5965c99 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -254,8 +254,8 @@ export function createPageExtension< // @public (undocumented) export function createPlugin< - Routes extends AnyRoutes, - ExternalRoutes extends AnyExternalRoutes, + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {}, >( options: PluginOptions, ): BackstagePlugin; diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.ts index cf18de2b5d..84edd954ef 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.ts @@ -48,8 +48,8 @@ export interface BackstagePlugin< /** @public */ export function createPlugin< - Routes extends AnyRoutes, - ExternalRoutes extends AnyExternalRoutes, + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {}, >( options: PluginOptions, ): BackstagePlugin { diff --git a/plugins/adr/alpha-api-report.md b/plugins/adr/alpha-api-report.md index 379b7e2ada..5740556904 100644 --- a/plugins/adr/alpha-api-report.md +++ b/plugins/adr/alpha-api-report.md @@ -3,8 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; -import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; @@ -26,7 +24,7 @@ export const adrTranslationRef: TranslationRef< >; // @alpha (undocumented) -const _default: BackstagePlugin; +const _default: BackstagePlugin<{}, {}>; export default _default; // (No @packageDocumentation comment for this package) diff --git a/plugins/catalog/alpha-api-report.md b/plugins/catalog/alpha-api-report.md index 0ae94b81ae..db76a2e0f3 100644 --- a/plugins/catalog/alpha-api-report.md +++ b/plugins/catalog/alpha-api-report.md @@ -3,8 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; -import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; @@ -17,7 +15,7 @@ export const CatalogSearchResultListItemExtension: Extension<{ }>; // @alpha (undocumented) -const _default: BackstagePlugin; +const _default: BackstagePlugin<{}, {}>; export default _default; // @alpha (undocumented) diff --git a/plugins/explore/alpha-api-report.md b/plugins/explore/alpha-api-report.md index b4de3ef3db..8670338f3e 100644 --- a/plugins/explore/alpha-api-report.md +++ b/plugins/explore/alpha-api-report.md @@ -3,13 +3,11 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; -import { AnyRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; // @alpha (undocumented) -const _default: BackstagePlugin; +const _default: BackstagePlugin<{}, {}>; export default _default; // @alpha (undocumented) diff --git a/plugins/graphiql/alpha-api-report.md b/plugins/graphiql/alpha-api-report.md index eca4a81c33..3362cf0a59 100644 --- a/plugins/graphiql/alpha-api-report.md +++ b/plugins/graphiql/alpha-api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; import { GraphQLEndpoint } from '@backstage/plugin-graphiql'; @@ -25,7 +24,7 @@ const _default: BackstagePlugin< { root: RouteRef; }, - AnyExternalRoutes + {} >; export default _default; diff --git a/plugins/search/alpha-api-report.md b/plugins/search/alpha-api-report.md index cc4b68f86e..99f3d07b57 100644 --- a/plugins/search/alpha-api-report.md +++ b/plugins/search/alpha-api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; import { RouteRef } from '@backstage/frontend-plugin-api'; @@ -13,7 +12,7 @@ const _default: BackstagePlugin< { root: RouteRef; }, - AnyExternalRoutes + {} >; export default _default; diff --git a/plugins/tech-radar/alpha-api-report.md b/plugins/tech-radar/alpha-api-report.md index 24846a1106..da8f12cf0e 100644 --- a/plugins/tech-radar/alpha-api-report.md +++ b/plugins/tech-radar/alpha-api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; import { RouteRef } from '@backstage/frontend-plugin-api'; @@ -13,7 +12,7 @@ const _default: BackstagePlugin< { root: RouteRef; }, - AnyExternalRoutes + {} >; export default _default; diff --git a/plugins/techdocs/alpha-api-report.md b/plugins/techdocs/alpha-api-report.md index bc6b8926cf..2b3a934e34 100644 --- a/plugins/techdocs/alpha-api-report.md +++ b/plugins/techdocs/alpha-api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Extension } from '@backstage/frontend-plugin-api'; import { RouteRef } from '@backstage/frontend-plugin-api'; @@ -18,7 +17,7 @@ const _default: BackstagePlugin< namespace: string; }>; }, - AnyExternalRoutes + {} >; export default _default; diff --git a/plugins/user-settings/alpha-api-report.md b/plugins/user-settings/alpha-api-report.md index 4ad22fa984..f375eadbee 100644 --- a/plugins/user-settings/alpha-api-report.md +++ b/plugins/user-settings/alpha-api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyExternalRoutes } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; @@ -13,7 +12,7 @@ const _default: BackstagePlugin< { root: RouteRef; }, - AnyExternalRoutes + {} >; export default _default; From 332a3702b345d6328e1f30ba760b9569d54f72f6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Oct 2023 12:54:45 +0200 Subject: [PATCH 34/34] frontend-app-api: cleanup route binding config reading Signed-off-by: Patrik Oldsberg --- .../frontend-app-api/src/routing/resolveRouteBindings.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts index c98e36a75b..f5a2e5bd94 100644 --- a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts @@ -21,6 +21,7 @@ import { } from '@backstage/frontend-plugin-api'; import { RouteRefsById } from './collectRouteIds'; import { Config } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; /** * Extracts a union of the keys in a map whose value extends the given type @@ -109,11 +110,7 @@ export function resolveRouteBindings( return result; } - const bindings = bindingsConfig.get(); - if (bindings === null || typeof bindings !== 'object') { - throw new Error('Invalid config at app.routes.bindings, must be an object'); - } - + const bindings = bindingsConfig.get(); for (const [externalRefId, targetRefId] of Object.entries(bindings)) { if (typeof targetRefId !== 'string' || targetRefId === '') { throw new Error(