diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx index a263a452b9..e474a8b851 100644 --- a/packages/core-api/src/app/App.test.tsx +++ b/packages/core-api/src/app/App.test.tsx @@ -27,7 +27,7 @@ import { createExternalRouteRef, createRouteRef, createSubRouteRef, -} from '../routing/RouteRef'; +} from '../routing'; import { generateBoundRoutes, PrivateAppImpl } from './App'; describe('generateBoundRoutes', () => { diff --git a/packages/core-api/src/routing/ExternalRouteRef.ts b/packages/core-api/src/routing/ExternalRouteRef.ts new file mode 100644 index 0000000000..e6af9cf8a7 --- /dev/null +++ b/packages/core-api/src/routing/ExternalRouteRef.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + RouteRef, + SubRouteRef, + ExternalRouteRef, + routeRefType, + AnyParams, + ParamKeys, + OptionalParams, +} from './types'; + +export class ExternalRouteRefImpl< + Params extends AnyParams, + Optional extends boolean +> implements ExternalRouteRef { + readonly [routeRefType] = 'external'; + + constructor( + private readonly id: string, + readonly params: ParamKeys, + readonly optional: Optional, + ) {} + + toString() { + return `routeRef{type=external,id=${this.id}}`; + } +} + +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, + ); +} + +export function isExternalRouteRef< + Params extends AnyParams, + Optional extends boolean +>( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is ExternalRouteRef { + return routeRef[routeRefType] === 'external'; +} diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 6eae4f48c0..3f14d2f49b 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -21,6 +21,7 @@ import { routeRefType, AnyParams, ParamKeys, + OptionalParams, } from './types'; import { IconComponent } from '../icons'; @@ -32,20 +33,11 @@ export type RouteRefConfig = { title: string; }; -class RouteRefBase { - constructor(type: string, id: string) { - this.toString = () => `routeRef{type=${type},id=${id}}`; - } -} - export class RouteRefImpl - extends RouteRefBase implements RouteRef { readonly [routeRefType] = 'absolute'; - constructor(private readonly config: RouteRefConfig) { - super('absolute', config.title); - } + constructor(private readonly config: RouteRefConfig) {} get params(): ParamKeys { return this.config.params as any; @@ -63,11 +55,11 @@ export class RouteRefImpl get title() { return this.config.title; } -} -type OptionalParams< - Params extends { [param in string]: string } -> = Params[keyof Params] extends never ? undefined : Params; + toString() { + return `routeRef{type=absolute,id=${this.config.title}}`; + } +} export function createRouteRef< // Params is the type that we care about and the one to be embedded in the route ref. @@ -92,132 +84,6 @@ export function createRouteRef< }); } -export class SubRouteRefImpl - extends RouteRefBase - implements SubRouteRef { - readonly [routeRefType] = 'sub'; - - constructor( - id: string, - readonly path: string, - readonly parent: RouteRef, - readonly params: ParamKeys, - ) { - super('sub', id); - } -} - -// These utility types help us infer a Param object type from a string path -// For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }` -type ParamPart = S extends `:${infer Param}` ? Param : never; -type ParamNames = S extends `${infer Part}/${infer Rest}` - ? ParamPart | ParamNames - : ParamPart; -type PathParams = { [name in ParamNames]: string }; - -/** - * Merges a param object type with with an optional params type into a params object - */ -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. - */ -type MakeSubRouteRef< - Params extends { [param in string]: string }, - ParentParams extends AnyParams -> = keyof Params & keyof ParentParams extends never - ? SubRouteRef>> - : never; - -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((_, i) => i % 2 === 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 params', - ); - } - if (!path.startsWith('/')) { - throw new Error(`SubRouteRef path sub starts with '/', got '${path}'`); - } - - // 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; -} - -export class ExternalRouteRefImpl< - Params extends AnyParams, - Optional extends boolean - > - extends RouteRefBase - implements ExternalRouteRef { - readonly [routeRefType] = 'external'; - - constructor( - id: string, - readonly params: ParamKeys, - readonly optional: Optional, - ) { - super('external', id); - } -} - -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, - ); -} - export function isRouteRef( routeRef: | RouteRef @@ -226,24 +92,3 @@ export function isRouteRef( ): routeRef is RouteRef { return routeRef[routeRefType] === 'absolute'; } - -export function isSubRouteRef( - routeRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, -): routeRef is SubRouteRef { - return routeRef[routeRefType] === 'sub'; -} - -export function isExternalRouteRef< - Params extends AnyParams, - Optional extends boolean ->( - routeRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, -): routeRef is ExternalRouteRef { - return routeRef[routeRefType] === 'external'; -} diff --git a/packages/core-api/src/routing/SubRouteRef.ts b/packages/core-api/src/routing/SubRouteRef.ts new file mode 100644 index 0000000000..8ff23c0c5e --- /dev/null +++ b/packages/core-api/src/routing/SubRouteRef.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AnyParams, + ExternalRouteRef, + OptionalParams, + ParamKeys, + RouteRef, + routeRefType, + SubRouteRef, +} from './types'; + +export class SubRouteRefImpl + implements SubRouteRef { + 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}}`; + } +} + +// These utility types help us infer a Param object type from a string path +// For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }` +type ParamPart = S extends `:${infer Param}` ? Param : never; +type ParamNames = S extends `${infer Part}/${infer Rest}` + ? ParamPart | ParamNames + : ParamPart; +type PathParams = { [name in ParamNames]: string }; + +/** + * Merges a param object type with with an optional params type into a params object + */ +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. + */ +type MakeSubRouteRef< + Params extends { [param in string]: string }, + ParentParams extends AnyParams +> = keyof Params & keyof ParentParams extends never + ? SubRouteRef>> + : never; + +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((_, i) => i % 2 === 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 params', + ); + } + if (!path.startsWith('/')) { + throw new Error(`SubRouteRef path sub starts with '/', got '${path}'`); + } + + // 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; +} + +export function isSubRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is SubRouteRef { + return routeRef[routeRefType] === 'sub'; +} diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 0ada37b18d..e66b76b130 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -35,11 +35,8 @@ import { validateRoutes, RouteFunc, } from './hooks'; -import { - createRouteRef, - createExternalRouteRef, - RouteRefConfig, -} from './RouteRef'; +import { createRouteRef, RouteRefConfig } from './RouteRef'; +import { createExternalRouteRef } from './ExternalRouteRef'; import { AnyRouteRef, RouteRef, ExternalRouteRef } from './types'; const mockConfig = (extra?: Partial>) => ({ diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index e37bb32f8b..2e305d8c38 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -25,7 +25,9 @@ import { SubRouteRef, routeRefType, } from './types'; -import { isRouteRef, isSubRouteRef, isExternalRouteRef } from './RouteRef'; +import { isRouteRef } from './RouteRef'; +import { isSubRouteRef } from './SubRouteRef'; +import { isExternalRouteRef } from './ExternalRouteRef'; // 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. diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 2a0685f583..c6134cfe4f 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -22,6 +22,8 @@ export type { ExternalRouteRef, } from './types'; export { FlatRoutes } from './FlatRoutes'; -export { createRouteRef, createExternalRouteRef } from './RouteRef'; +export { createRouteRef } from './RouteRef'; +export { createSubRouteRef } from './SubRouteRef'; +export { createExternalRouteRef } from './ExternalRouteRef'; export type { RouteRefConfig } from './RouteRef'; export { useRouteRef } from './hooks'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index d91cd77bc5..80b1f0c4e7 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -21,6 +21,9 @@ export type AnyParams = { [param in string]: string } | undefined; export type ParamKeys = keyof Params extends never ? [] : (keyof Params)[]; +export type OptionalParams< + Params extends { [param in string]: string } +> = Params[keyof Params] extends never ? undefined : Params; export const routeRefType: unique symbol = getGlobalSingleton( 'route-ref-type',