diff --git a/packages/core/src/__tests__/getPathFromState.test.tsx b/packages/core/src/__tests__/getPathFromState.test.tsx index 75b5ad43..f453b286 100644 --- a/packages/core/src/__tests__/getPathFromState.test.tsx +++ b/packages/core/src/__tests__/getPathFromState.test.tsx @@ -117,7 +117,8 @@ it("doesn't add query param for empty params", () => { }); it('handles state with config with nested screens', () => { - const path = '/foe/bar/sweet/apple/baz/jane?answer=42&count=10&valid=true'; + const path = + '/foo/foe/bar/sweet/apple/baz/jane?answer=42&count=10&valid=true'; const config = { Foo: { path: 'foo', @@ -182,8 +183,77 @@ it('handles state with config with nested screens', () => { expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); }); +it('handles state with config with nested screens and exact', () => { + const path = '/foe/bar/sweet/apple/baz/jane?answer=42&count=10&valid=true'; + const config = { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + exact: true, + }, + }, + }, + Bar: 'bar/:type/:fruit', + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + stringify: { + author: (author: string) => author.toLowerCase(), + id: (id: number) => `x${id}`, + unknown: (_: unknown) => 'x', + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Baz', + params: { + author: 'Jane', + count: '10', + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); +}); + it('handles state with config with nested screens and unused configs', () => { - const path = '/foe/baz/jane?answer=42&count=10&valid=true'; + const path = '/foo/foe/baz/jane?answer=42&count=10&valid=true'; const config = { Foo: { path: 'foo', @@ -239,6 +309,66 @@ it('handles state with config with nested screens and unused configs', () => { expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); }); +it('handles state with config with nested screens and unused configs with exact', () => { + const path = '/foe/baz/jane?answer=42&count=10&valid=true'; + const config = { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + exact: true, + }, + }, + }, + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + unknown: (_: unknown) => 'x', + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Baz', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); +}); + it('handles nested object with stringify in it', () => { const path = '/bar/sweet/apple/foo/bis/jane?answer=42&count=10&valid=true'; const config = { @@ -252,7 +382,6 @@ it('handles nested object with stringify in it', () => { }, Bar: 'bar/:type/:fruit', Baz: { - path: 'baz', screens: { Bos: 'bos', Bis: { @@ -312,8 +441,82 @@ it('handles nested object with stringify in it', () => { expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); }); +it('handles nested object with stringify in it with exact', () => { + const path = '/bar/sweet/apple/foo/bis/jane?answer=42&count=10&valid=true'; + const config = { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + }, + }, + }, + Bar: 'bar/:type/:fruit', + Baz: { + path: 'baz', + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + exact: true, + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); +}); + it('handles nested object for second route depth', () => { - const path = '/baz'; + const path = '/foo/bar/baz'; const config = { Foo: { path: 'foo', @@ -351,7 +554,95 @@ it('handles nested object for second route depth', () => { expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); }); -it('handles nested object for second route depth and and path and stringify in roots', () => { +it('handles nested object for second route depth with exact', () => { + const path = '/baz'; + const config = { + Foo: { + path: 'foo', + screens: { + Foe: 'foe', + Bar: { + path: 'bar', + screens: { + Baz: { + path: 'baz', + exact: true, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Bar', + state: { + routes: [{ name: 'Baz' }], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); +}); + +it('handles nested object for second route depth and path and stringify in roots', () => { + const path = '/foo/dathomir/bar/42/baz'; + const config = { + Foo: { + path: 'foo/:planet', + stringify: { + id: (id: number) => `planet=${id}`, + }, + screens: { + Foe: 'foe', + Bar: { + path: 'bar/:id', + parse: { + id: Number, + }, + screens: { + Baz: 'baz', + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + params: { planet: 'dathomir' }, + state: { + routes: [ + { + name: 'Bar', + state: { + routes: [{ name: 'Baz', params: { id: 42 } }], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); +}); + +it('handles nested object for second route depth and path and stringify in roots with exact', () => { const path = '/baz'; const config = { Foo: { @@ -370,7 +661,10 @@ it('handles nested object for second route depth and and path and stringify in r id: Number, }, screens: { - Baz: 'baz', + Baz: { + path: 'baz', + exact: true, + }, }, }, }, @@ -470,7 +764,7 @@ it('keeps query params if path is empty', () => { }); it('cuts nested configs too', () => { - const path = '/baz'; + const path = '/foo/baz'; const config = { Foo: { path: 'foo', @@ -478,7 +772,48 @@ it('cuts nested configs too', () => { Bar: '', }, }, - Baz: { path: 'baz' }, + Baz: { + path: 'baz', + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Bar', + state: { + routes: [{ name: 'Baz' }], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); +}); + +it('cuts nested configs too with exact', () => { + const path = '/baz'; + const config = { + Foo: { + path: 'foo', + screens: { + Bar: { + path: '', + exact: true, + }, + }, + }, + Baz: { + path: 'baz', + }, }; const state = { @@ -504,7 +839,7 @@ it('cuts nested configs too', () => { }); it('handles empty path at the end', () => { - const path = '/bar'; + const path = '/foo/bar'; const config = { Foo: { path: 'foo', @@ -641,7 +976,6 @@ it('strips undefined query params', () => { }, Bar: 'bar/:type/:fruit', Baz: { - path: 'baz', screens: { Bos: 'bos', Bis: { @@ -681,7 +1015,79 @@ it('strips undefined query params', () => { params: { author: 'Jane', count: 10, - answer: undefined, + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); +}); + +it('strips undefined query params with exact', () => { + const path = '/bar/sweet/apple/foo/bis/jane?count=10&valid=true'; + const config = { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + }, + }, + }, + Bar: 'bar/:type/:fruit', + Baz: { + path: 'baz', + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + exact: true, + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + count: 10, valid: true, }, }, @@ -714,7 +1120,6 @@ it('handles stripping all query params', () => { }, Bar: 'bar/:type/:fruit', Baz: { - path: 'baz', screens: { Bos: 'bos', Bis: { @@ -753,9 +1158,6 @@ it('handles stripping all query params', () => { name: 'Bis', params: { author: 'Jane', - count: undefined, - answer: undefined, - valid: undefined, }, }, ], @@ -773,3 +1175,93 @@ it('handles stripping all query params', () => { expect(getPathFromState(state, config)).toBe(path); expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); }); + +it('handles stripping all query params with exact', () => { + const path = '/bar/sweet/apple/foo/bis/jane'; + const config = { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + }, + }, + }, + Bar: 'bar/:type/:fruit', + Baz: { + path: 'baz', + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + exact: true, + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); +}); + +it('replaces undefined query params', () => { + const path = '/bar/undefined/apple'; + const config = { + Bar: 'bar/:type/:fruit', + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple' }, + }, + ], + }; + + expect(getPathFromState(state, config)).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 d3e67203..a4cd723f 100644 --- a/packages/core/src/__tests__/getStateFromPath.test.tsx +++ b/packages/core/src/__tests__/getStateFromPath.test.tsx @@ -147,7 +147,10 @@ it('converts path string to initial state with config with nested screens', () = Foo: { path: 'foo', screens: { - Foe: 'foe', + Foe: { + path: 'foe', + exact: true, + }, }, }, Bar: 'bar/:type/:fruit', @@ -213,7 +216,10 @@ it('converts path string to initial state with config with nested screens and un Foo: { path: 'foo', screens: { - Foe: 'foe', + Foe: { + path: 'foe', + exact: true, + }, }, }, Baz: { @@ -268,16 +274,23 @@ it('handles nested object with unused configs and with parse in it', () => { Foo: { path: 'foo', screens: { - Foe: 'foe', + Foe: { + path: 'foe', + exact: true, + }, }, }, Bar: 'bar/:type/:fruit', Baz: { path: 'baz', screens: { - Bos: 'bos', + Bos: { + path: 'bos', + exact: true, + }, Bis: { path: 'bis/:author', + exact: true, stringify: { author: (author: string) => author.replace(/^\w/, (c) => c.toLowerCase()), @@ -348,11 +361,18 @@ it('handles parse in nested object for second route depth', () => { Foo: { path: 'foo', screens: { - Foe: 'foe', + Foe: { + path: 'foe', + exact: true, + }, Bar: { path: 'bar', + exact: true, screens: { - Baz: 'baz', + Baz: { + path: 'baz', + exact: true, + }, }, }, }, @@ -519,16 +539,23 @@ it('handles two initialRouteNames', () => { Foo: { path: 'foo', screens: { - Foe: 'foe', + Foe: { + path: 'foe', + exact: true, + }, }, }, Bar: 'bar/:type/:fruit', Baz: { initialRouteName: 'Bos', screens: { - Bos: 'bos', + Bos: { + path: 'bos', + exact: true, + }, Bis: { path: 'bis/:author', + exact: true, stringify: { author: (author: string) => author.replace(/^\w/, (c) => c.toLowerCase()), @@ -601,16 +628,23 @@ it('accepts initialRouteName without config for it', () => { Foo: { path: 'foo', screens: { - Foe: 'foe', + Foe: { + path: 'foe', + exact: true, + }, }, }, Bar: 'bar/:type/:fruit', Baz: { initialRouteName: 'Bas', screens: { - Bos: 'bos', + Bos: { + path: 'bos', + exact: true, + }, Bis: { path: 'bis/:author', + exact: true, stringify: { author: (author: string) => author.replace(/^\w/, (c) => c.toLowerCase()), @@ -1777,3 +1811,41 @@ it('handle optional params in the beginning v2', () => { state ); }); + +it('merges parent patterns if needed', () => { + const path = 'foo/42/baz/babel'; + + const config = { + Foo: { + path: 'foo/:bar', + parse: { + bar: Number, + }, + screens: { + Baz: 'baz/:qux', + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + params: { bar: 42 }, + state: { + routes: [ + { + name: 'Baz', + params: { qux: 'babel' }, + }, + ], + }, + }, + ], + }; + + expect(getStateFromPath(path, config)).toEqual(state); + expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( + state + ); +}); diff --git a/packages/core/src/getPathFromState.tsx b/packages/core/src/getPathFromState.tsx index c23d47db..28cb9c51 100644 --- a/packages/core/src/getPathFromState.tsx +++ b/packages/core/src/getPathFromState.tsx @@ -9,16 +9,15 @@ type State = NavigationState | Omit, 'stale'>; type StringifyConfig = Record string>; -type Options = { - [routeName: string]: - | string - | { - path?: string; - stringify?: StringifyConfig; - screens?: Options; - }; +type OptionsItem = { + path?: string; + exact?: boolean; + stringify?: StringifyConfig; + screens?: Options; }; +type Options = Record; + /** * Utility to serialize a navigation state object to a path string. * @@ -53,84 +52,78 @@ export default function getPathFromState( if (state === undefined) { throw Error('NavigationState not passed'); } - let path = '/'; + // Create a normalized configs array which will be easier to use + const configs = createNormalizedConfigs(options); + + let path = '/'; let current: State | undefined = state; + const allParams: Record = {}; + while (current) { let index = typeof current.index === 'number' ? current.index : 0; let route = current.routes[index] as Route & { state?: State; }; - let currentOptions = options; - let pattern = route.name; - // we keep all the route names that appeared during going deeper in config in case the pattern is resolved to undefined - let nestedRouteNames = ''; - while (route.name in currentOptions) { - if (typeof currentOptions[route.name] === 'string') { - pattern = currentOptions[route.name] as string; - break; - } else if (typeof currentOptions[route.name] === 'object') { - // if there is no `screens` property, we return pattern - if ( - !(currentOptions[route.name] as { - screens: Options; - }).screens - ) { - pattern = (currentOptions[route.name] as { path: string }).path; - nestedRouteNames = `${nestedRouteNames}/${route.name}`; - break; + let pattern: string | undefined; + + let currentParams: Record = { ...route.params }; + let currentOptions = configs; + + // Keep all the route names that appeared during going deeper in config in case the pattern is resolved to undefined + let nestedRouteNames = []; + + let hasNext = true; + + while (route.name in currentOptions && hasNext) { + pattern = currentOptions[route.name].pattern; + + nestedRouteNames.push(route.name); + + if (route.params) { + const stringify = currentOptions[route.name]?.stringify; + + currentParams = Object.fromEntries( + Object.entries(route.params).map(([key, value]) => [ + key, + stringify?.[key] ? stringify[key](value) : String(value), + ]) + ); + + if (pattern) { + Object.assign(allParams, currentParams); + } + } + + // If there is no `screens` property or no nested state, we return pattern + if (!currentOptions[route.name].screens || route.state === undefined) { + hasNext = false; + } else { + index = + typeof route.state.index === 'number' + ? route.state.index + : route.state.routes.length - 1; + + const nextRoute = route.state.routes[index]; + const nestedConfig = currentOptions[route.name].screens; + + // if there is config for next route name, we go deeper + if (nestedConfig && nextRoute.name in nestedConfig) { + route = nextRoute as Route & { state?: State }; + currentOptions = nestedConfig; } else { - // if it is the end of state, we return pattern - if (route.state === undefined) { - pattern = (currentOptions[route.name] as { path: string }).path; - nestedRouteNames = `${nestedRouteNames}/${route.name}`; - break; - } else { - index = - typeof route.state.index === 'number' ? route.state.index : 0; - const nextRoute = route.state.routes[index]; - const deeperConfig = (currentOptions[route.name] as { - screens: Options; - }).screens; - // if there is config for next route name, we go deeper - if (nextRoute.name in deeperConfig) { - nestedRouteNames = `${nestedRouteNames}/${route.name}`; - route = nextRoute as Route & { state?: State }; - currentOptions = deeperConfig; - } else { - // if not, there is no sense in going deeper in config - pattern = (currentOptions[route.name] as { path: string }).path; - nestedRouteNames = `${nestedRouteNames}/${route.name}`; - break; - } - } + // If not, there is no sense in going deeper in config + hasNext = false; } } } if (pattern === undefined) { - // cut the first `/` - pattern = nestedRouteNames.substring(1); + pattern = nestedRouteNames.join('/'); } - const config = - currentOptions[route.name] !== undefined - ? (currentOptions[route.name] as { stringify?: StringifyConfig }) - .stringify - : undefined; - - const params = route.params - ? // Stringify all of the param values before we use them - Object.entries(route.params).reduce<{ - [key: string]: string; - }>((acc, [key, value]) => { - acc[key] = config?.[key] ? config[key](value) : String(value); - return acc; - }, {}) - : undefined; - if (currentOptions[route.name] !== undefined) { path += pattern .split('/') @@ -138,16 +131,23 @@ export default function getPathFromState( const name = p.replace(/^:/, '').replace(/\?$/, ''); // If the path has a pattern for a param, put the param in the path - if (params && name in params && p.startsWith(':')) { - const value = params[name]; + if (p.startsWith(':')) { + const value = allParams[name]; + // Remove the used value from the params object since we'll use the rest for query string - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete params[name]; + if (currentParams) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete currentParams[name]; + } + + if (value === undefined && p.endsWith('?')) { + // Optional params without value assigned in route.params should be ignored + return ''; + } + return encodeURIComponent(value); - } else if (p.endsWith('?')) { - // optional params without value assigned in route.params should be ignored - return ''; } + return encodeURIComponent(p); }) .join('/'); @@ -157,14 +157,15 @@ export default function getPathFromState( if (route.state) { path += '/'; - } else if (params) { - for (let param in params) { - if (params[param] === 'undefined') { + } else if (currentParams) { + for (let param in currentParams) { + if (currentParams[param] === 'undefined') { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete params[param]; + delete currentParams[param]; } } - const query = queryString.stringify(params); + + const query = queryString.stringify(currentParams); if (query) { path += `?${query}`; @@ -180,3 +181,59 @@ export default function getPathFromState( return path; } + +type ConfigItem = { + pattern?: string; + stringify?: StringifyConfig; + screens?: Record; +}; + +function joinPaths(...paths: string[]): string { + return paths + .map((p) => p.split('/')) + .flat() + .filter(Boolean) + .join('/'); +} + +function createConfigItem( + config: OptionsItem | string, + parentPattern?: string +): ConfigItem { + if (typeof config === 'string') { + // If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern + const pattern = parentPattern ? joinPaths(parentPattern, config) : config; + + return { pattern }; + } + + // If an object is specified as the value (e.g. Foo: { ... }), + // It can have `path` property and `screens` prop which has nested configs + const pattern = + config.exact !== true && parentPattern && config.path + ? joinPaths(parentPattern, config.path) + : config.path; + + const screens = config.screens + ? createNormalizedConfigs(config.screens, pattern) + : undefined; + + return { + pattern, + stringify: config.stringify, + screens, + }; +} + +function createNormalizedConfigs( + options: Options, + pattern?: string +): Record { + return Object.fromEntries( + Object.entries(options).map(([name, c]) => { + const result = createConfigItem(c, pattern); + + return [name, result]; + }) + ); +} diff --git a/packages/core/src/getStateFromPath.tsx b/packages/core/src/getStateFromPath.tsx index c645c71a..5dec83c0 100644 --- a/packages/core/src/getStateFromPath.tsx +++ b/packages/core/src/getStateFromPath.tsx @@ -13,6 +13,7 @@ type Options = { | string | { path?: string; + exact?: boolean; parse?: ParseConfig; screens?: Options; initialRouteName?: string; @@ -21,10 +22,11 @@ type Options = { type RouteConfig = { screen: string; - match: RegExp | null; + regex?: RegExp; + path: string; pattern: string; routeNames: string[]; - parse: ParseConfig | undefined; + parse?: ParseConfig; }; type InitialRouteConfig = { @@ -62,17 +64,14 @@ export default function getStateFromPath( let initialRoutes: InitialRouteConfig[] = []; // Create a normalized configs array which will be easier to use - const configs = ([] as RouteConfig[]).concat( - ...Object.keys(options).map((key) => - createNormalizedConfigs(key, options, [], initialRoutes) - ) - ); - - // sort configs so the most exhaustive is always first to be chosen - configs.sort( - (config1, config2) => - config2.pattern.split('/').length - config1.pattern.split('/').length - ); + const configs = Object.keys(options) + .map((key) => createNormalizedConfigs(key, options, [], initialRoutes)) + .flat() + .sort( + (a, b) => + // Sort configs so the most exhaustive is always first to be chosen + b.pattern.split('/').length - a.pattern.split('/').length + ); let remaining = path .replace(/\/+/g, '/') // Replace multiple slash (//) with single ones @@ -87,18 +86,23 @@ export default function getStateFromPath( // When handling empty path, we should only look at the root level config const match = configs.find( (config) => - config.pattern === '' && + config.path === '' && config.routeNames.every( - // make sure that none of the parent configs have a non-empty path defined - (name) => !configs.find((c) => c.screen === name)?.pattern + // Make sure that none of the parent configs have a non-empty path defined + (name) => !configs.find((c) => c.screen === name)?.path ) ); if (match) { return createNestedStateObject( - match.routeNames, - initialRoutes, - parseQueryParams(path, match.parse) + match.routeNames.map((name, i, self) => { + if (i === self.length - 1) { + return { name, params: parseQueryParams(path, match.parse) }; + } + + return { name }; + }), + initialRoutes ); } @@ -110,15 +114,15 @@ export default function getStateFromPath( while (remaining) { let routeNames: string[] | undefined; - let params: Record | undefined; + let allParams: Record | undefined; // Go through all configs, and see if the next path segment matches our regex for (const config of configs) { - if (!config.match) { + if (!config.regex) { continue; } - const match = remaining.match(config.match); + const match = remaining.match(config.regex); // If our regex matches, we need to extract params from the path if (match) { @@ -129,16 +133,10 @@ export default function getStateFromPath( .filter((p) => p.startsWith(':')); if (paramPatterns.length) { - params = paramPatterns.reduce>((acc, p, i) => { - const key = p.replace(/^:/, '').replace(/\?$/, ''); - const value = match[(i + 1) * 2].replace(/\//, ''); // The param segments appear every second item starting from 2 in the regex match result + allParams = paramPatterns.reduce>((acc, p, i) => { + const value = match![(i + 1) * 2].replace(/\//, ''); // The param segments appear every second item starting from 2 in the regex match result - if (value) { - acc[key] = - config.parse && config.parse[key] - ? config.parse[key](value) - : value; - } + acc[p] = value; return acc; }, {}); @@ -159,7 +157,46 @@ export default function getStateFromPath( remaining = segments.join('/'); } - const state = createNestedStateObject(routeNames, initialRoutes, params); + const state = createNestedStateObject( + routeNames.map((name) => { + const config = configs.find((c) => c.screen === name); + + let params: object | undefined; + + if (allParams && config?.path) { + const pattern = config.path; + + if (pattern) { + const paramPatterns = pattern + .split('/') + .filter((p) => p.startsWith(':')); + + if (paramPatterns.length) { + params = paramPatterns.reduce>((acc, p) => { + const key = p.replace(/^:/, '').replace(/\?$/, ''); + const value = allParams![p]; + + if (value) { + acc[key] = + config.parse && config.parse[key] + ? config.parse[key](value) + : value; + } + + return acc; + }, {}); + } + } + } + + if (params && Object.keys(params).length) { + return { name, params }; + } + + return { name }; + }), + initialRoutes + ); if (current) { // The state should be nested inside the deepest route we parsed before @@ -194,44 +231,67 @@ export default function getStateFromPath( return result; } +function joinPaths(...paths: string[]): string { + return paths + .map((p) => p.split('/')) + .flat() + .filter(Boolean) + .join('/'); +} + function createNormalizedConfigs( - key: string, + screen: string, routeConfig: Options, routeNames: string[] = [], - initials: InitialRouteConfig[] + initials: InitialRouteConfig[], + parentPattern?: string ): RouteConfig[] { const configs: RouteConfig[] = []; - routeNames.push(key); + routeNames.push(screen); - const value = routeConfig[key]; + const config = routeConfig[screen]; - if (typeof value === 'string') { + if (typeof config === 'string') { // If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern - configs.push(createConfigItem(key, routeNames, value)); - } else if (typeof value === 'object') { + const pattern = parentPattern ? joinPaths(parentPattern, config) : config; + + configs.push(createConfigItem(screen, routeNames, pattern, config)); + } else if (typeof config === 'object') { + let pattern: string | undefined; + // if an object is specified as the value (e.g. Foo: { ... }), // it can have `path` property and // it could have `screens` prop which has nested configs - if (typeof value.path === 'string') { - configs.push(createConfigItem(key, routeNames, value.path, value.parse)); + if (typeof config.path === 'string') { + pattern = + config.exact !== true && parentPattern + ? joinPaths(parentPattern, config.path) + : config.path; + + configs.push( + createConfigItem(screen, routeNames, pattern, config.path, config.parse) + ); } - if (value.screens) { + if (config.screens) { // property `initialRouteName` without `screens` has no purpose - if (value.initialRouteName) { + if (config.initialRouteName) { initials.push({ - initialRouteName: value.initialRouteName, - connectedRoutes: Object.keys(value.screens), + initialRouteName: config.initialRouteName, + connectedRoutes: Object.keys(config.screens), }); } - Object.keys(value.screens).forEach((nestedConfig) => { + + Object.keys(config.screens).forEach((nestedConfig) => { const result = createNormalizedConfigs( nestedConfig, - value.screens as Options, + config.screens as Options, routeNames, - initials + initials, + pattern ); + configs.push(...result); }); } @@ -246,9 +306,10 @@ function createConfigItem( screen: string, routeNames: string[], pattern: string, + path: string, parse?: ParseConfig ): RouteConfig { - const match = pattern + const regex = pattern ? new RegExp( `^(${pattern .split('/') @@ -261,12 +322,13 @@ function createConfigItem( }) .join('')})` ) - : null; + : undefined; return { screen, - match, + regex, pattern, + path, // The routeNames array is mutated, so copy it to keep the current state routeNames: [...routeNames], parse, @@ -305,21 +367,18 @@ function findInitialRoute( function createStateObject( initialRoute: string | undefined, routeName: string, - isEmpty: boolean, - params?: Record | undefined + params: Record | undefined, + isEmpty: boolean ): InitialState { if (isEmpty) { if (initialRoute) { return { index: 1, - routes: [ - { name: initialRoute }, - { name: routeName as string, ...(params && { params }) }, - ], + routes: [{ name: initialRoute }, { name: routeName as string, params }], }; } else { return { - routes: [{ name: routeName as string, ...(params && { params }) }], + routes: [{ name: routeName as string, params }], }; } } else { @@ -328,44 +387,50 @@ function createStateObject( index: 1, routes: [ { name: initialRoute }, - { name: routeName as string, state: { routes: [] } }, + { name: routeName as string, params, state: { routes: [] } }, ], }; } else { - return { routes: [{ name: routeName as string, state: { routes: [] } }] }; + return { + routes: [{ name: routeName as string, params, state: { routes: [] } }], + }; } } } function createNestedStateObject( - routeNames: string[], - initialRoutes: InitialRouteConfig[], - params: object | undefined + routes: { name: string; params?: object }[], + initialRoutes: InitialRouteConfig[] ) { let state: InitialState; - let routeName = routeNames.shift() as string; - let initialRoute = findInitialRoute(routeName, initialRoutes); + let route = routes.shift() as { name: string; params?: object }; + let initialRoute = findInitialRoute(route.name, initialRoutes); state = createStateObject( initialRoute, - routeName, - routeNames.length === 0, - params + route.name, + route.params, + routes.length === 0 ); - if (routeNames.length > 0) { + if (routes.length > 0) { let nestedState = state; - while ((routeName = routeNames.shift() as string)) { - initialRoute = findInitialRoute(routeName, initialRoutes); - nestedState.routes[nestedState.index || 0].state = createStateObject( + while ((route = routes.shift() as { name: string; params?: object })) { + initialRoute = findInitialRoute(route.name, initialRoutes); + + const nestedStateIndex = + nestedState.index || nestedState.routes.length - 1; + + nestedState.routes[nestedStateIndex].state = createStateObject( initialRoute, - routeName, - routeNames.length === 0, - params + route.name, + route.params, + routes.length === 0 ); - if (routeNames.length > 0) { - nestedState = nestedState.routes[nestedState.index || 0] + + if (routes.length > 0) { + nestedState = nestedState.routes[nestedStateIndex] .state as InitialState; } }