Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / pass / components / Navigation / NavigationProvider.tsx
blob708743e16a289d527320d9e33b17a697b5ee6a97
1 import type { PropsWithChildren } from 'react';
2 import { type FC, createContext, useContext, useMemo } from 'react';
3 import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
5 import { type Location } from 'history';
7 import type { ItemFilters, Maybe, MaybeNull, SelectedItem } from '@proton/pass/types';
8 import { objectFilter } from '@proton/pass/utils/object/filter';
10 import {
11     decodeFilters,
12     decodeFiltersFromSearch,
13     encodeFilters,
14     getItemRoute,
15     getLocalPath,
16     getOnboardingRoute,
17     getTrashRoute,
18 } from './routing';
20 export type NavigateOptions<LocationState = any> = {
21     filters?: Partial<ItemFilters>;
22     hash?: string;
23     mode?: 'push' | 'replace';
24     searchParams?: { [key: string]: any };
25     state?: LocationState;
28 export type ItemSelectOptions<LocationState = any> = NavigateOptions<LocationState> & {
29     inTrash?: boolean;
30     prefix?: string;
31     view?: 'edit' | 'view' | 'history';
34 export type NavigationContextValue = {
35     /** Parsed search parameter filters. */
36     filters: ItemFilters;
37     /** Flag indicating whether we are currently on an item list page  */
38     matchItemList: boolean;
39     /** Flag indicating whether we are currently on the onboarding route */
40     matchOnboarding: boolean;
41     /** Flag indicating whether we are currently on a trash route */
42     matchTrash: boolean;
43     /** Selected item's `itemId` and `shareId` parsed from URL */
44     selectedItem: Maybe<SelectedItem>;
45     /** Wraps react-router-dom's `useHistory` and provides extra options
46      * to chose the navigation mode (push, replace) and wether you want to
47      * push new search parameters to the target path. */
48     navigate: <S>(pathname: string, options?: NavigateOptions<S>) => void;
49     /** Navigates to an item view. By default it will go to the `view` screen,
50      * but this can be customized via options. */
51     selectItem: <S>(shareId: string, itemId: string, options?: ItemSelectOptions<S>) => void;
52     /** Sets the filters and updates the location's search parameters. */
53     setFilters: (filters: Partial<ItemFilters>) => void;
54     /** Joins the current location search parameters to the provided path */
55     preserveSearch: (path: string) => string;
56     /** Resolves the current location */
57     getCurrentLocation: () => Location;
60 const NavigationContext = createContext<MaybeNull<NavigationContextValue>>(null);
62 export const NavigationProvider: FC<PropsWithChildren> = ({ children }) => {
63     const history = useHistory();
64     const location = useLocation();
66     const filters = decodeFiltersFromSearch(location.search);
68     const matchTrash = useRouteMatch(getTrashRoute()) !== null;
69     const matchOnboarding = useRouteMatch(getOnboardingRoute()) !== null;
71     const itemRoute = getItemRoute(':shareId', ':itemId', { trashed: matchTrash });
72     const selectedItem = useRouteMatch<SelectedItem>(itemRoute)?.params;
74     const matchRootExact = useRouteMatch({ exact: true, path: getLocalPath() });
75     const matchItemExact = useRouteMatch({ exact: true, path: itemRoute });
76     const matchItemList = matchRootExact !== null || matchItemExact !== null || matchTrash;
78     const navigate = (pathname: string, options: NavigateOptions = { mode: 'push' }) => {
79         const search = new URLSearchParams(history.location.search);
81         if (options.searchParams) {
82             /* If additional search params were provided */
83             Object.entries(options.searchParams).forEach(([key, value]) => search.set(key, value));
84         }
86         if (options.filters) {
87             /* Merge the incoming filters with the current ones */
88             const currFilters = decodeFilters(search.get('filters'));
89             const newFilters = objectFilter(options.filters, (_, value) => value !== undefined);
90             const nextFilters = { ...currFilters, ...newFilters };
91             search.set('filters', encodeFilters(nextFilters));
92         }
94         history[options.mode ?? 'push']({
95             pathname,
96             search: `?${search.toString()}`,
97             hash: options.hash,
98             state: options.state,
99         });
100     };
102     const selectItem = (shareId: string, itemId: string, options?: ItemSelectOptions) => {
103         const base = getItemRoute(shareId, itemId, { trashed: options?.inTrash, prefix: options?.prefix });
104         const view = options?.view && options.view !== 'view' ? `/${options.view}` : '';
105         navigate(base + view, options);
106     };
108     /** Determines whether to replace the history entry only when the search
109      * filter have changed. For all other changes, it is preferred to push to
110      * the history. This approach prevents creating a new route every time the
111      * debounced search value changes, which would pollute the history stack. */
112     const setFilters = (update: Partial<ItemFilters>) => {
113         const shouldPush =
114             (update.selectedShareId && update.selectedShareId !== filters.selectedShareId) ||
115             (update.sort && update.sort !== filters.sort) ||
116             (update.type && update.type !== filters.type);
118         navigate(history.location.pathname, {
119             mode: shouldPush ? 'push' : 'replace',
120             filters: update,
121         });
122     };
124     const navigation = useMemo<NavigationContextValue>(
125         () => ({
126             filters,
127             matchItemList,
128             matchOnboarding,
129             matchTrash,
130             selectedItem,
131             navigate,
132             selectItem,
133             setFilters,
134             preserveSearch: (path) => path + history.location.search,
135             getCurrentLocation: () => ({ ...history.location }),
136         }),
137         [
138             location.search /* indirectly matches filter changes */,
139             matchOnboarding,
140             matchTrash,
141             matchItemList,
142             selectedItem,
143         ]
144     );
146     return <NavigationContext.Provider value={navigation}>{children}</NavigationContext.Provider>;
149 export const useNavigation = (): NavigationContextValue => useContext(NavigationContext)!;