1 | import type {
|
2 | NavigationState,
|
3 | PartialState,
|
4 | Route,
|
5 | } from '@react-navigation/routers';
|
6 | import * as queryString from 'query-string';
|
7 |
|
8 | import fromEntries from './fromEntries';
|
9 | import type { PathConfig, PathConfigMap } from './types';
|
10 | import validatePathConfig from './validatePathConfig';
|
11 |
|
12 | type Options<ParamList> = {
|
13 | initialRouteName?: string;
|
14 | screens: PathConfigMap<ParamList>;
|
15 | };
|
16 |
|
17 | type State = NavigationState | Omit<PartialState<NavigationState>, 'stale'>;
|
18 |
|
19 | type StringifyConfig = Record<string, (value: any) => string>;
|
20 |
|
21 | type ConfigItem = {
|
22 | pattern?: string;
|
23 | stringify?: StringifyConfig;
|
24 | screens?: Record<string, ConfigItem>;
|
25 | };
|
26 |
|
27 | const getActiveRoute = (state: State): { name: string; params?: object } => {
|
28 | const route =
|
29 | typeof state.index === 'number'
|
30 | ? state.routes[state.index]
|
31 | : state.routes[state.routes.length - 1];
|
32 |
|
33 | if (route.state) {
|
34 | return getActiveRoute(route.state);
|
35 | }
|
36 |
|
37 | return route;
|
38 | };
|
39 |
|
40 |
|
41 |
|
42 |
|
43 |
|
44 |
|
45 |
|
46 |
|
47 |
|
48 |
|
49 |
|
50 |
|
51 |
|
52 |
|
53 |
|
54 |
|
55 |
|
56 |
|
57 |
|
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 |
|
64 |
|
65 |
|
66 |
|
67 |
|
68 |
|
69 | export default function getPathFromState<ParamList extends {}>(
|
70 | state: State,
|
71 | options?: Options<ParamList>
|
72 | ): string {
|
73 | if (state == null) {
|
74 | throw Error(
|
75 | "Got 'undefined' for the navigation state. You must pass a valid state object."
|
76 | );
|
77 | }
|
78 |
|
79 | if (options) {
|
80 | validatePathConfig(options);
|
81 | }
|
82 |
|
83 |
|
84 | const configs: Record<string, ConfigItem> = options?.screens
|
85 | ? createNormalizedConfigs(options?.screens)
|
86 | : {};
|
87 |
|
88 | let path = '/';
|
89 | let current: State | undefined = state;
|
90 |
|
91 | const allParams: Record<string, any> = {};
|
92 |
|
93 | while (current) {
|
94 | let index = typeof current.index === 'number' ? current.index : 0;
|
95 | let route = current.routes[index] as Route<string> & {
|
96 | state?: State;
|
97 | };
|
98 |
|
99 | let pattern: string | undefined;
|
100 |
|
101 | let focusedParams: Record<string, any> | undefined;
|
102 | let focusedRoute = getActiveRoute(state);
|
103 | let currentOptions = configs;
|
104 |
|
105 |
|
106 | let nestedRouteNames = [];
|
107 |
|
108 | let hasNext = true;
|
109 |
|
110 | while (route.name in currentOptions && hasNext) {
|
111 | pattern = currentOptions[route.name].pattern;
|
112 |
|
113 | nestedRouteNames.push(route.name);
|
114 |
|
115 | if (route.params) {
|
116 | const stringify = currentOptions[route.name]?.stringify;
|
117 |
|
118 | const currentParams = fromEntries(
|
119 | Object.entries(route.params).map(([key, value]) => [
|
120 | key,
|
121 | stringify?.[key] ? stringify[key](value) : String(value),
|
122 | ])
|
123 | );
|
124 |
|
125 | if (pattern) {
|
126 | Object.assign(allParams, currentParams);
|
127 | }
|
128 |
|
129 | if (focusedRoute === route) {
|
130 |
|
131 |
|
132 | focusedParams = { ...currentParams };
|
133 |
|
134 | pattern
|
135 | ?.split('/')
|
136 | .filter((p) => p.startsWith(':'))
|
137 |
|
138 | .forEach((p) => {
|
139 | const name = getParamName(p);
|
140 |
|
141 |
|
142 | if (focusedParams) {
|
143 |
|
144 | delete focusedParams[name];
|
145 | }
|
146 | });
|
147 | }
|
148 | }
|
149 |
|
150 |
|
151 | if (!currentOptions[route.name].screens || route.state === undefined) {
|
152 | hasNext = false;
|
153 | } else {
|
154 | index =
|
155 | typeof route.state.index === 'number'
|
156 | ? route.state.index
|
157 | : route.state.routes.length - 1;
|
158 |
|
159 | const nextRoute = route.state.routes[index];
|
160 | const nestedConfig = currentOptions[route.name].screens;
|
161 |
|
162 |
|
163 | if (nestedConfig && nextRoute.name in nestedConfig) {
|
164 | route = nextRoute as Route<string> & { state?: State };
|
165 | currentOptions = nestedConfig;
|
166 | } else {
|
167 |
|
168 | hasNext = false;
|
169 | }
|
170 | }
|
171 | }
|
172 |
|
173 | if (pattern === undefined) {
|
174 | pattern = nestedRouteNames.join('/');
|
175 | }
|
176 |
|
177 | if (currentOptions[route.name] !== undefined) {
|
178 | path += pattern
|
179 | .split('/')
|
180 | .map((p) => {
|
181 | const name = getParamName(p);
|
182 |
|
183 |
|
184 |
|
185 |
|
186 | if (p === '*') {
|
187 | return route.name;
|
188 | }
|
189 |
|
190 |
|
191 | if (p.startsWith(':')) {
|
192 | const value = allParams[name];
|
193 |
|
194 | if (value === undefined && p.endsWith('?')) {
|
195 |
|
196 | return '';
|
197 | }
|
198 |
|
199 | return encodeURIComponent(value);
|
200 | }
|
201 |
|
202 | return encodeURIComponent(p);
|
203 | })
|
204 | .join('/');
|
205 | } else {
|
206 | path += encodeURIComponent(route.name);
|
207 | }
|
208 |
|
209 | if (!focusedParams) {
|
210 | focusedParams = focusedRoute.params;
|
211 | }
|
212 |
|
213 | if (route.state) {
|
214 | path += '/';
|
215 | } else if (focusedParams) {
|
216 | for (let param in focusedParams) {
|
217 | if (focusedParams[param] === 'undefined') {
|
218 |
|
219 | delete focusedParams[param];
|
220 | }
|
221 | }
|
222 |
|
223 | const query = queryString.stringify(focusedParams, { sort: false });
|
224 |
|
225 | if (query) {
|
226 | path += `?${query}`;
|
227 | }
|
228 | }
|
229 |
|
230 | current = route.state;
|
231 | }
|
232 |
|
233 |
|
234 | path = path.replace(/\/+/g, '/');
|
235 | path = path.length > 1 ? path.replace(/\/$/, '') : path;
|
236 |
|
237 | return path;
|
238 | }
|
239 |
|
240 | const getParamName = (pattern: string) =>
|
241 | pattern.replace(/^:/, '').replace(/\?$/, '');
|
242 |
|
243 | const joinPaths = (...paths: string[]): string =>
|
244 | ([] as string[])
|
245 | .concat(...paths.map((p) => p.split('/')))
|
246 | .filter(Boolean)
|
247 | .join('/');
|
248 |
|
249 | const createConfigItem = (
|
250 | config: PathConfig<object> | string,
|
251 | parentPattern?: string
|
252 | ): ConfigItem => {
|
253 | if (typeof config === 'string') {
|
254 |
|
255 | const pattern = parentPattern ? joinPaths(parentPattern, config) : config;
|
256 |
|
257 | return { pattern };
|
258 | }
|
259 |
|
260 |
|
261 |
|
262 | let pattern: string | undefined;
|
263 |
|
264 | if (config.exact && config.path === undefined) {
|
265 | throw new Error(
|
266 | "A 'path' needs to be specified when specifying 'exact: true'. If you don't want this screen in the URL, specify it as empty string, e.g. `path: ''`."
|
267 | );
|
268 | }
|
269 |
|
270 | pattern =
|
271 | config.exact !== true
|
272 | ? joinPaths(parentPattern || '', config.path || '')
|
273 | : config.path || '';
|
274 |
|
275 | const screens = config.screens
|
276 | ? createNormalizedConfigs(config.screens, pattern)
|
277 | : undefined;
|
278 |
|
279 | return {
|
280 |
|
281 | pattern: pattern?.split('/').filter(Boolean).join('/'),
|
282 | stringify: config.stringify,
|
283 | screens,
|
284 | };
|
285 | };
|
286 |
|
287 | const createNormalizedConfigs = (
|
288 | options: PathConfigMap<object>,
|
289 | pattern?: string
|
290 | ): Record<string, ConfigItem> =>
|
291 | fromEntries(
|
292 | Object.entries(options).map(([name, c]) => {
|
293 | const result = createConfigItem(c, pattern);
|
294 |
|
295 | return [name, result];
|
296 | })
|
297 | );
|