From b931ae62dfb2c5253c94ea5ace73e9070ec17c4a Mon Sep 17 00:00:00 2001 From: Wojciech Lewicki Date: Wed, 29 Jan 2020 16:21:35 +0100 Subject: [PATCH] feat: add `screens` prop for nested configs (#308) Nested configs' names with their configs are now in `screens` property of the route object. --- example/src/index.tsx | 25 ++-- .../src/__tests__/getPathFromState.test.tsx | 81 ++++++++----- .../src/__tests__/getStateFromPath.test.tsx | 112 +++++++++++++----- packages/core/src/getPathFromState.tsx | 20 +++- packages/core/src/getStateFromPath.tsx | 63 ++++------ 5 files changed, 193 insertions(+), 108 deletions(-) diff --git a/example/src/index.tsx b/example/src/index.tsx index e94be64c..fe2715ec 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -123,18 +123,21 @@ export default function App() { const { getInitialState } = useLinking(containerRef, { prefixes: LinkingPrefixes, config: { - Root: Object.keys(SCREENS).reduce<{ [key: string]: string }>( - (acc, name) => { - // Convert screen names such as SimpleStack to kebab case (simple-stack) - acc[name] = name - .replace(/([A-Z]+)/g, '-$1') - .replace(/^-/, '') - .toLowerCase(); + Root: { + path: 'root', + screens: Object.keys(SCREENS).reduce<{ [key: string]: string }>( + (acc, name) => { + // Convert screen names such as SimpleStack to kebab case (simple-stack) + acc[name] = name + .replace(/([A-Z]+)/g, '-$1') + .replace(/^-/, '') + .toLowerCase(); - return acc; - }, - {} - ), + return acc; + }, + {} + ), + }, }, }); diff --git a/packages/core/src/__tests__/getPathFromState.test.tsx b/packages/core/src/__tests__/getPathFromState.test.tsx index 41eb7e0d..613d47b8 100644 --- a/packages/core/src/__tests__/getPathFromState.test.tsx +++ b/packages/core/src/__tests__/getPathFromState.test.tsx @@ -116,10 +116,13 @@ it("doesn't add query param for empty params", () => { }); it('handles state with config with nested screens', () => { - const path = '/few/bar/sweet/apple/baz/jane?answer=42&count=10&valid=true'; + const path = '/foe/bar/sweet/apple/baz/jane?answer=42&count=10&valid=true'; const config = { Foo: { - Foe: 'few', + path: 'foo', + screens: { + Foe: 'foe', + }, }, Bar: 'bar/:type/:fruit', Baz: { @@ -178,10 +181,13 @@ it('handles state with config with nested screens', () => { }); it('handles state with config with nested screens and unused configs', () => { - const path = '/few/baz/jane?answer=42&count=10&valid=true'; + const path = '/foe/baz/jane?answer=42&count=10&valid=true'; const config = { Foo: { - Foe: 'few', + path: 'foo', + screens: { + Foe: 'foe', + }, }, Baz: { path: 'baz/:author', @@ -230,19 +236,31 @@ it('handles state with config with nested screens and unused configs', () => { }); it('handles nested object with stringify in it', () => { - const path = '/bar/sweet/apple/few/bis/jane?answer=42&count=10&valid=true'; + const path = '/bar/sweet/apple/foe/bis/jane?answer=42&count=10&valid=true'; const config = { Foo: { - Foe: 'few', + path: 'foo', + screens: { + Foe: 'foe', + }, }, Bar: 'bar/:type/:fruit', Baz: { - Bos: 'bos', - Bis: { - path: 'bis/:author', - stringify: { - author: (author: string) => - author.replace(/^\w/, c => c.toLowerCase()), + path: 'baz', + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + stringify: { + author: (author: string) => + author.replace(/^\w/, c => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, c => c.toUpperCase()), + count: Number, + valid: Boolean, + }, }, }, }, @@ -292,7 +310,7 @@ it('handles nested object with stringify in it', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path))).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); }); it('handles nested object for second route depth', () => { @@ -300,9 +318,14 @@ it('handles nested object for second route depth', () => { const config = { Foo: { path: 'foo', - Foe: 'foe', - Bar: { - Baz: 'baz', + screens: { + Foe: 'foe', + Bar: { + path: 'bar', + screens: { + Baz: 'baz', + }, + }, }, }, }; @@ -326,7 +349,7 @@ it('handles nested object for second route depth', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path))).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); }); it('handles nested object for second route depth and and path and stringify in roots', () => { @@ -337,16 +360,20 @@ it('handles nested object for second route depth and and path and stringify in r stringify: { id: (id: number) => `id=${id}`, }, - Foe: 'foe', - Bar: { - path: 'bar/:id', - stringify: { - id: (id: number) => `id=${id}`, + screens: { + Foe: 'foe', + Bar: { + path: 'bar/:id', + stringify: { + id: (id: number) => `id=${id}`, + }, + parse: { + id: Number, + }, + screens: { + Baz: 'baz', + }, }, - parse: { - id: Number, - }, - Baz: 'baz', }, }, }; @@ -370,5 +397,5 @@ it('handles nested object for second route depth and and path and stringify in r }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path))).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); }); diff --git a/packages/core/src/__tests__/getStateFromPath.test.tsx b/packages/core/src/__tests__/getStateFromPath.test.tsx index f2454ae3..d17a049a 100644 --- a/packages/core/src/__tests__/getStateFromPath.test.tsx +++ b/packages/core/src/__tests__/getStateFromPath.test.tsx @@ -35,9 +35,9 @@ it('converts path string to initial state', () => { }); it('converts path string to initial state with config', () => { - const path = '/few/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true'; + const path = '/foo/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true'; const config = { - Foo: 'few', + Foo: 'foo', Bar: 'bar/:type/:fruit', Baz: { path: 'baz/:author', @@ -141,10 +141,13 @@ it('handles route without param', () => { }); it('converts path string to initial state with config with nested screens', () => { - const path = '/few/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true'; + const path = '/foe/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true'; const config = { Foo: { - Foe: 'few', + path: 'foo', + screens: { + Foe: 'foe', + }, }, Bar: 'bar/:type/:fruit', Baz: { @@ -203,10 +206,13 @@ it('converts path string to initial state with config with nested screens', () = }); it('converts path string to initial state with config with nested screens and unused parse functions', () => { - const path = '/few/baz/jane?count=10&answer=42&valid=true'; + const path = '/foe/baz/jane?count=10&answer=42&valid=true'; const config = { Foo: { - Foe: 'few', + path: 'foo', + screens: { + Foe: 'foe', + }, }, Baz: { path: 'baz/:author', @@ -254,21 +260,31 @@ it('converts path string to initial state with config with nested screens and un }); it('handles nested object with unused configs and with parse in it', () => { - const path = '/bar/sweet/apple/few/bis/jane?count=10&answer=42&valid=true'; + const path = '/bar/sweet/apple/foe/bis/jane?count=10&answer=42&valid=true'; const config = { Foo: { - Foe: 'few', + path: 'foo', + screens: { + Foe: 'foe', + }, }, Bar: 'bar/:type/:fruit', Baz: { - Bos: 'bos', - Bis: { - path: 'bis/:author', - parse: { - author: (author: string) => - author.replace(/^\w/, c => c.toUpperCase()), - count: Number, - valid: Boolean, + path: 'baz', + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + stringify: { + author: (author: string) => + author.replace(/^\w/, c => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, c => c.toUpperCase()), + count: Number, + valid: Boolean, + }, }, }, }, @@ -328,9 +344,14 @@ it('handles parse in nested object for second route depth', () => { const config = { Foo: { path: 'foo', - Foe: 'foe', - Bar: { - Baz: 'baz', + screens: { + Foe: 'foe', + Bar: { + path: 'bar', + screens: { + Baz: 'baz', + }, + }, }, }, }; @@ -370,16 +391,20 @@ it('handles parse in nested object for second route depth and and path and parse stringify: { id: (id: number) => `id=${id}`, }, - Foe: 'foe', - Bar: { - path: 'bar/:id', - parse: { - id: Number, + screens: { + Foe: 'foe', + Bar: { + path: 'bar/:id', + parse: { + id: Number, + }, + stringify: { + id: (id: number) => `id=${id}`, + }, + screens: { + Baz: 'baz', + }, }, - stringify: { - id: (id: number) => `id=${id}`, - }, - Baz: 'baz', }, }, }; @@ -407,3 +432,34 @@ it('handles parse in nested object for second route depth and and path and parse state ); }); + +it('returns undefined if path is empty', () => { + const config = { + Foo: { + path: 'foo/:id', + starting: true, + stringify: { + id: (id: number) => `id=${id}`, + }, + screens: { + Foe: 'foe', + Bar: { + path: 'bar/:id', + parse: { + id: Number, + }, + stringify: { + id: (id: number) => `id=${id}`, + }, + screens: { + Baz: 'baz', + }, + }, + }, + }, + }; + + const path = ''; + + expect(getStateFromPath(path, config)).toEqual(undefined); +}); diff --git a/packages/core/src/getPathFromState.tsx b/packages/core/src/getPathFromState.tsx index a3d89094..4280a5e4 100644 --- a/packages/core/src/getPathFromState.tsx +++ b/packages/core/src/getPathFromState.tsx @@ -8,8 +8,11 @@ type StringifyConfig = Record string>; type Options = { [routeName: string]: | string - | { path: string; stringify?: StringifyConfig } - | Options; + | { + path: string; + stringify?: StringifyConfig; + screens?: Options; + }; }; /** @@ -43,6 +46,9 @@ export default function getPathFromState( state?: State, options: Options = {} ): string { + if (state === undefined) { + throw Error('NavigationState not passed'); + } let path = '/'; let current: State | undefined = state; @@ -50,7 +56,7 @@ export default function getPathFromState( while (current) { let index = typeof current.index === 'number' ? current.index : 0; let route = current.routes[index] as Route & { - state?: State | undefined; + state?: State; }; let currentOptions = options; let pattern = route.name; @@ -64,10 +70,14 @@ export default function getPathFromState( pattern = (currentOptions[route.name] as { path: string }).path; break; } else { - currentOptions = currentOptions[route.name] as Options; + if (!(currentOptions[route.name] as { screens?: Options }).screens) { + throw Error('Wrong Options object passed'); + } + currentOptions = (currentOptions[route.name] as { screens: Options }) + .screens; index = typeof route.state.index === 'number' ? route.state.index : 0; route = route.state.routes[index] as Route & { - state?: State | undefined; + state?: State; }; } } diff --git a/packages/core/src/getStateFromPath.tsx b/packages/core/src/getStateFromPath.tsx index 5ebfe43e..206dc598 100644 --- a/packages/core/src/getStateFromPath.tsx +++ b/packages/core/src/getStateFromPath.tsx @@ -5,7 +5,13 @@ import { NavigationState, PartialState, InitialState } from './types'; type ParseConfig = Record any>; type Options = { - [routeName: string]: string | { path: string; parse?: ParseConfig } | Options; + [routeName: string]: + | string + | { + path: string; + parse?: ParseConfig; + screens?: Options; + }; }; type RouteConfig = { @@ -42,6 +48,9 @@ export default function getStateFromPath( path: string, options: Options = {} ): ResultState | undefined { + if (path === '') { + return undefined; + } // Create a normalized configs array which will be easier to use const configs = ([] as RouteConfig[]).concat( ...Object.keys(options).map(key => createNormalizedConfigs(key, options)) @@ -65,7 +74,7 @@ export default function getStateFromPath( // If our regex matches, we need to extract params from the path if (match) { - routeNames = config.routeNames; + routeNames = [...config.routeNames]; const paramPatterns = config.pattern .split('/') @@ -164,7 +173,7 @@ export default function getStateFromPath( const route = current.routes[0]; const params = queryString.parse(query); - const parseFunction = findParseConfigForRoute(route.name, options); + const parseFunction = findParseConfigForRoute(route.name, configs); if (parseFunction) { Object.keys(params).forEach(name => { @@ -185,7 +194,7 @@ function createNormalizedConfigs( routeConfig: Options, routeNames: string[] = [] ): RouteConfig[] { - const configs = []; + const configs: RouteConfig[] = []; routeNames.push(key); @@ -196,30 +205,19 @@ function createNormalizedConfigs( configs.push(createConfigItem(routeNames, value)); } else if (typeof value === 'object') { // if an object is specified as the value (e.g. Foo: { ... }), - // it could have config object and optionally nested config - Object.keys(value).forEach(nestedKey => { - if (nestedKey === 'path') { - configs.push( - createConfigItem( - routeNames, - value[nestedKey] as string, - value.parse ? (value.parse as ParseConfig) : undefined - ) - ); - } else if (nestedKey === 'parse') { - // We handle custom parse function when a `path` is specified (in nestedKey === path) - } else { - // If the name of the key is not `path` or `parse`, it's a nested config for route - // So we need to traverse into it and collect the configs + // it has `path` property and + // it could have `screens` prop which has nested configs + configs.push(createConfigItem(routeNames, value.path, value.parse)); + if (value.screens) { + Object.keys(value.screens).forEach(nestedConfig => { const result = createNormalizedConfigs( - nestedKey, - routeConfig[key] as Options, + nestedConfig, + value.screens as Options, routeNames ); - configs.push(...result); - } - }); + }); + } } routeNames.pop(); @@ -247,21 +245,12 @@ function createConfigItem( function findParseConfigForRoute( routeName: string, - config: Options + flatConfig: RouteConfig[] ): ParseConfig | undefined { - if (config[routeName]) { - return (config[routeName] as { parse?: ParseConfig }).parse; - } - - for (const name in config) { - if (typeof config[name] === 'object') { - const parse = findParseConfigForRoute(routeName, config[name] as Options); - - if (parse) { - return parse; - } + for (const config of flatConfig) { + if (routeName === config.routeNames[config.routeNames.length - 1]) { + return config.parse; } } - return undefined; }