Skip to content

Commit

Permalink
feat: support a top-level path configuration in linking config
Browse files Browse the repository at this point in the history
  • Loading branch information
satya164 committed Mar 6, 2023
1 parent 230c09d commit 1d0297e
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 31 deletions.
35 changes: 35 additions & 0 deletions packages/core/src/__tests__/getPathFromState.test.tsx
Expand Up @@ -1714,3 +1714,38 @@ it('uses nearest parent wildcard match for unmatched paths', () => {
)
).toBe('/404');
});

it('handles path at top level', () => {
const path = 'foo/fruits/apple';
const config = {
path: 'foo',
screens: {
Foo: {
screens: {
Fruits: 'fruits/:fruit',
},
},
},
};

const state = {
routes: [
{
name: 'Foo',
state: {
routes: [
{
name: 'Fruits',
params: { fruit: 'apple' },
},
],
},
},
],
};

expect(getPathFromState<object>(state, config)).toEqual(path);
expect(
getPathFromState<object>(getStateFromPath<object>(path, config)!, config)
).toEqual(path);
});
120 changes: 104 additions & 16 deletions packages/core/src/__tests__/getStateFromPath.test.tsx
Expand Up @@ -508,6 +508,42 @@ it('handles parse in nested object for second route depth and and path and parse
).toEqual(state);
});

it('handles path at top level', () => {
const path = 'foo/fruits/apple';
const config = {
path: 'foo',
screens: {
Foo: {
screens: {
Fruits: 'fruits/:fruit',
},
},
},
};

const state = {
routes: [
{
name: 'Foo',
state: {
routes: [
{
name: 'Fruits',
params: { fruit: 'apple' },
path,
},
],
},
},
],
};

expect(getStateFromPath<object>(path, config)).toEqual(state);
expect(
getStateFromPath<object>(getPathFromState<object>(state, config), config)
).toEqual(state);
});

it('handles initialRouteName at top level', () => {
const path = '/baz';
const config = {
Expand Down Expand Up @@ -830,10 +866,33 @@ it('accepts initialRouteName without config for it', () => {
).toEqual(state);
});

it('returns undefined if path is empty and no matching screen is present', () => {
it('returns undefined if no matching screen is present (top level path)', () => {
const path = '/foo/bar';
const config = {
path: 'qux',
screens: {
Foo: {
screens: {
Foe: 'foo',
Bar: {
screens: {
Baz: 'bar',
},
},
},
},
},
};

expect(getStateFromPath<object>(path, config)).toBeUndefined();
});

it('returns undefined if no matching screen is present', () => {
const path = '/baz';
const config = {
screens: {
Foo: {
path: 'foo',
screens: {
Foe: 'foe',
Bar: {
Expand All @@ -846,7 +905,25 @@ it('returns undefined if path is empty and no matching screen is present', () =>
},
};

expect(getStateFromPath<object>(path, config)).toBeUndefined();
});

it('returns undefined if path is empty and no matching screen is present', () => {
const path = '';
const config = {
screens: {
Foo: {
screens: {
Foe: 'foe',
Bar: {
screens: {
Baz: 'baz',
},
},
},
},
},
};

expect(getStateFromPath<object>(path, config)).toBeUndefined();
});
Expand Down Expand Up @@ -2467,21 +2544,24 @@ it('correctly applies initialRouteName for config with similar route names v2',
it('throws when invalid properties are specified in the config', () => {
expect(() =>
getStateFromPath<object>('', {
path: 42,
Foo: 'foo',
Bar: {
path: 'bar',
},
} as any)
).toThrowErrorMatchingInlineSnapshot(`
"Found invalid properties in the configuration:
- Foo
- Bar
Did you forget to specify them under a 'screens' property?
- path (expected 'string', got 'number')
- Foo (extraneous)
- Bar (extraneous)
You can only specify the following properties:
- initialRouteName
- screens
- path (string)
- initialRouteName (string)
- screens (object)
If you want to specify configuration for screens, you need to specify them under a 'screens' property.
See https://reactnavigation.org/docs/configuring-links for more details on how to specify a linking configuration."
`);
Expand All @@ -2502,18 +2582,26 @@ it('throws when invalid properties are specified in the config', () => {
} as any)
).toThrowErrorMatchingInlineSnapshot(`
"Found invalid properties in the configuration:
- Qux
Did you forget to specify them under a 'screens' property?
- Qux (extraneous)
You can only specify the following properties:
- initialRouteName
- screens
- path
- exact
- stringify
- parse
- path (string)
- initialRouteName (string)
- screens (object)
- exact (boolean)
- stringify (object)
- parse (object)
If you want to specify configuration for screens, you need to specify them under a 'screens' property.
See https://reactnavigation.org/docs/configuring-links for more details on how to specify a linking configuration."
`);

expect(() =>
getStateFromPath<object>('', {
path: 'foo/:id',
} as any)
).toThrowErrorMatchingInlineSnapshot(
`"Found invalid path 'foo/:id'. The 'path' in the top-level configuration cannot contain patterns for params."`
);
});
6 changes: 6 additions & 0 deletions packages/core/src/getPathFromState.tsx
Expand Up @@ -10,6 +10,7 @@ import type { PathConfig, PathConfigMap } from './types';
import { validatePathConfig } from './validatePathConfig';

type Options<ParamList extends {}> = {
path?: string;
initialRouteName?: string;
screens: PathConfigMap<ParamList>;
};
Expand Down Expand Up @@ -234,6 +235,11 @@ export function getPathFromState<ParamList extends {}>(
path = path.replace(/\/+/g, '/');
path = path.length > 1 ? path.replace(/\/$/, '') : path;

// Include the root path if specified
if (options?.path) {
path = joinPaths(options.path, path);
}

return path;
}

Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/getStateFromPath.tsx
Expand Up @@ -11,6 +11,7 @@ import type { PathConfigMap } from './types';
import { validatePathConfig } from './validatePathConfig';

type Options<ParamList extends {}> = {
path?: string;
initialRouteName?: string;
screens: PathConfigMap<ParamList>;
};
Expand Down Expand Up @@ -89,6 +90,21 @@ export function getStateFromPath<ParamList extends {}>(
// Make sure there is a trailing slash
remaining = remaining.endsWith('/') ? remaining : `${remaining}/`;

const prefix = options?.path?.replace(/^\//, ''); // Remove extra leading slash

if (prefix) {
// Make sure there is a trailing slash
const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;

// If the path doesn't start with the prefix, it's not a match
if (!remaining.startsWith(normalizedPrefix)) {
return undefined;
}

// Remove the prefix from the path
remaining = remaining.replace(normalizedPrefix, '');
}

if (screens === undefined) {
// When no config is specified, use the path segments as route names
const routes = remaining
Expand Down
74 changes: 60 additions & 14 deletions packages/core/src/validatePathConfig.tsx
@@ -1,28 +1,74 @@
const formatToList = (items: string[]) =>
items.map((key) => `- ${key}`).join('\n');
import { fromEntries } from './fromEntries';

export function validatePathConfig(config: any, root = true) {
const validKeys = ['initialRouteName', 'screens'];
const formatToList = (items: Record<string, string>) =>
Object.entries(items)
.map(([key, value]) => `- ${key} (${value})`)
.join('\n');

if (!root) {
validKeys.push('path', 'exact', 'stringify', 'parse');
export function validatePathConfig(config: unknown, root = true) {
const validation = {
path: 'string',
initialRouteName: 'string',
screens: 'object',
...(root
? null
: {
exact: 'boolean',
stringify: 'object',
parse: 'object',
}),
};

if (typeof config !== 'object' || config === null) {
throw new Error(
`Expected the configuration to be an object, but got ${JSON.stringify(
config
)}.`
);
}

const invalidKeys = Object.keys(config).filter(
(key) => !validKeys.includes(key)
const validationErrors = fromEntries(
Object.keys(config)
.map((key) => {
if (key in validation) {
const type = validation[key as keyof typeof validation];
// @ts-expect-error: we know the key exists
const value = config[key];

if (typeof value !== type) {
return [key, `expected '${type}', got '${typeof value}'`];
}
} else {
return [key, 'extraneous'];
}

return null;
})
.filter(Boolean) as [string, string][]
);

if (invalidKeys.length) {
if (Object.keys(validationErrors).length) {
throw new Error(
`Found invalid properties in the configuration:\n${formatToList(
invalidKeys
)}\n\nDid you forget to specify them under a 'screens' property?\n\nYou can only specify the following properties:\n${formatToList(
validKeys
)}\n\nSee https://reactnavigation.org/docs/configuring-links for more details on how to specify a linking configuration.`
validationErrors
)}\n\nYou can only specify the following properties:\n${formatToList(
validation
)}\n\nIf you want to specify configuration for screens, you need to specify them under a 'screens' property.\n\nSee https://reactnavigation.org/docs/configuring-links for more details on how to specify a linking configuration.`
);
}

if (
root &&
'path' in config &&
typeof config.path === 'string' &&
config.path.includes(':')
) {
throw new Error(
`Found invalid path '${config.path}'. The 'path' in the top-level configuration cannot contain patterns for params.`
);
}

if (config.screens) {
if ('screens' in config && config.screens) {
Object.entries(config.screens).forEach(([_, value]) => {
if (typeof value !== 'string') {
validatePathConfig(value, false);
Expand Down
15 changes: 14 additions & 1 deletion packages/native/src/types.tsx
Expand Up @@ -96,8 +96,21 @@ export type LinkingOptions<ParamList extends {}> = {
* ```
*/
config?: {
initialRouteName?: keyof ParamList;
/**
* Path string to match against for the whole navigation tree.
* It's not possible to specify params here since this doesn't belong to a screen.
* This is useful when the whole app is under a specific path.
* e.g. all of the screens are under `/admin` in `https://example.com/admin`
*/
path?: string;
/**
* Path configuration for child screens.
*/
screens: PathConfigMap<ParamList>;
/**
* Name of the initial route to use for the root navigator.
*/
initialRouteName?: keyof ParamList;
};
/**
* Custom function to get the initial URL used for linking.
Expand Down

0 comments on commit 1d0297e

Please sign in to comment.