Skip to content

Commit

Permalink
Merge pull request #44 from software-mansion-labs/ideal-nav-merge-sta…
Browse files Browse the repository at this point in the history
…te-diff

Add getStateDiff for navigating to RHP
  • Loading branch information
adamgrzybowski authored Jan 26, 2024
2 parents 447f033 + 79d9ddf commit e6202e2
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function compareAndAdaptState(state: StackNavigationState<RootStackParamList>) {
// We will generate a template state and compare the current state with it.
// If there is a differences in the screens that should be visible under the overlay, we will add the screen from templateState to the current state.
const pathFromCurrentState = getPathFromState(state, linkingConfig.config);
const templateState = getAdaptedStateFromPath(pathFromCurrentState, linkingConfig.config);
const {adaptedState: templateState} = getAdaptedStateFromPath(pathFromCurrentState, linkingConfig.config);

if (!templateState) {
return;
Expand Down
43 changes: 43 additions & 0 deletions src/libs/Navigation/AppNavigator/getActionsFromPartialDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {getActionFromState, StackActions} from '@react-navigation/native';
import type {NavigationAction} from '@react-navigation/native';
import linkingConfig from '@libs/Navigation/linkingConfig';
import NAVIGATORS from '@src/NAVIGATORS';
import type {GetPartialStateDiffReturnType} from './getPartialStateDiff';

/**
* @param diff - Diff generated by getPartialDiff.
* @returns Array of actions to dispatch to apply diff.
*/
function getActionsFromPartialDiff(diff: GetPartialStateDiffReturnType): NavigationAction[] {
const actions: NavigationAction[] = [];

const bottomTabDiff = diff[NAVIGATORS.BOTTOM_TAB_NAVIGATOR];
const centralPaneDiff = diff[NAVIGATORS.CENTRAL_PANE_NAVIGATOR];
const fullScreenDiff = diff[NAVIGATORS.FULL_SCREEN_NAVIGATOR];

// There is only one bottom tab navigator so we can just push this route.
if (bottomTabDiff) {
actions.push(StackActions.push(bottomTabDiff.name, bottomTabDiff.params));
}

if (centralPaneDiff) {
// In this case we have to wrap the inner central pane route with central pane navigator.
actions.push(
StackActions.push(NAVIGATORS.CENTRAL_PANE_NAVIGATOR, {
screen: centralPaneDiff.name,
params: centralPaneDiff.params,
}),
);
}

if (fullScreenDiff) {
const action = getActionFromState({routes: [fullScreenDiff]}, linkingConfig.config);
if (action) {
actions.push(action);
}
}

return actions;
}

export default getActionsFromPartialDiff;
77 changes: 77 additions & 0 deletions src/libs/Navigation/AppNavigator/getPartialStateDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
import type {Metainfo} from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath';
import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
import NAVIGATORS from '@src/NAVIGATORS';

// eslint-disable-next-line @typescript-eslint/ban-types
const shallowCompare = (obj1?: object, obj2?: object) => {
if (!obj1 && !obj2) {
return true;
}
if (obj1 && obj2) {
// @ts-expect-error we know that obj1 and obj2 are params of a route.
return Object.keys(obj1).length === Object.keys(obj2).length && Object.keys(obj1).every((key) => obj1[key] === obj2[key]);
}
return false;
};

type GetPartialStateDiffReturnType = {
[NAVIGATORS.BOTTOM_TAB_NAVIGATOR]?: NavigationPartialRoute;
[NAVIGATORS.CENTRAL_PANE_NAVIGATOR]?: NavigationPartialRoute;
[NAVIGATORS.FULL_SCREEN_NAVIGATOR]?: NavigationPartialRoute;
};

/**
* This function returns partial additive diff beteween the two states.
* The partial diff have information which bottom tab, central pane and full screen screens we need to push to go from state to templateState
* @param state - Current state.
* @param templateState - Desired state generated with getAdaptedStateFromPath.
* @param metainfo - Additional info from getAdaptedStateFromPath funciton.
* @returns The screen options object
*/
function getPartialStateDiff(state: State<RootStackParamList>, templateState: State<RootStackParamList>, metainfo: Metainfo): GetPartialStateDiffReturnType {
const diff: GetPartialStateDiffReturnType = {};

// If it is mandatory we need to compare both central pane and bottom tab of states.
if (metainfo.isCentralPaneAndBottomTabMandatory) {
const stateTopmostBottomTab = getTopmostBottomTabRoute(state);
const templateStateTopmostBottomTab = getTopmostBottomTabRoute(templateState);

// Bottom tab navigator
if (stateTopmostBottomTab && templateStateTopmostBottomTab && stateTopmostBottomTab.name !== templateStateTopmostBottomTab.name) {
diff[NAVIGATORS.BOTTOM_TAB_NAVIGATOR] = templateStateTopmostBottomTab;
}

const stateTopmostCentralPane = getTopmostCentralPaneRoute(state);
const templateStateTopmostCentralPane = getTopmostCentralPaneRoute(templateState);

if (
// If the central pane is only in the template state, it's diff.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
(!stateTopmostCentralPane && templateStateTopmostCentralPane) ||
(stateTopmostCentralPane &&
templateStateTopmostCentralPane &&
stateTopmostCentralPane.name !== templateStateTopmostCentralPane.name &&
!shallowCompare(stateTopmostCentralPane.params, templateStateTopmostCentralPane.params))
) {
// We need to wrap central pane routes in the central pane navigator.
diff[NAVIGATORS.CENTRAL_PANE_NAVIGATOR] = templateStateTopmostCentralPane;
}
}

// This one is heurestic and may need to improved if we will be able to navigate from modal screen with full screen in background to another modal screen with full screen in background.
// For now this simple check is enought.
if (metainfo.isFullScreenNavigatorMandatory) {
const stateTopmostFullScreen = state.routes.filter((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR).at(-1);
const templateStateTopmostFullScreen = templateState.routes.filter((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR).at(-1) as NavigationPartialRoute;
if (!stateTopmostFullScreen && templateStateTopmostFullScreen) {
diff[NAVIGATORS.FULL_SCREEN_NAVIGATOR] = templateStateTopmostFullScreen;
}
}

return diff;
}

export default getPartialStateDiff;
export type {GetPartialStateDiffReturnType};
3 changes: 2 additions & 1 deletion src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
return;
}

return getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config);
const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config);
return adaptedState;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
Expand Down
13 changes: 13 additions & 0 deletions src/libs/Navigation/linkTo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import type {Route} from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import getActionsFromPartialDiff from './AppNavigator/getActionsFromPartialDiff';
import getPartialStateDiff from './AppNavigator/getPartialStateDiff';
import dismissModal from './dismissModal';
import getPolicyIdFromState from './getPolicyIdFromState';
import getStateFromPath from './getStateFromPath';
import getTopmostBottomTabRoute from './getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute';
import getTopmostReportId from './getTopmostReportId';
import linkingConfig from './linkingConfig';
import getAdaptedStateFromPath from './linkingConfig/getAdaptedStateFromPath';
import getMatchingBottomTabRouteForState from './linkingConfig/getMatchingBottomTabRouteForState';
import getMatchingCentralPaneRouteForState from './linkingConfig/getMatchingCentralPaneRouteForState';
import replacePathInNestedState from './linkingConfig/replacePathInNestedState';
Expand Down Expand Up @@ -185,6 +188,16 @@ export default function linkTo(navigation: NavigationContainerRef<RootStackParam
dismissModal('', navigation);
}
action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;

// If this RHP has mandatory central pane and bottom tab screens defined we need to push them.
const {adaptedState, metainfo} = getAdaptedStateFromPath(path, linkingConfig.config);
if (adaptedState && (metainfo.isCentralPaneAndBottomTabMandatory || metainfo.isFullScreenNavigatorMandatory)) {
const diff = getPartialStateDiff(rootState, adaptedState as State<RootStackParamList>, metainfo);
const diffActions = getActionsFromPartialDiff(diff);
for (const diffAction of diffActions) {
root.dispatch(diffAction);
}
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
} else if (action.payload.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR) {
// If path contains a policyID, we should invoke the navigate function
Expand Down
97 changes: 75 additions & 22 deletions src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type {NavigationState, PartialState} from '@react-navigation/native';
import {getStateFromPath} from '@react-navigation/native';
import {isAnonymousUser} from '@libs/actions/Session';
Expand All @@ -15,8 +14,24 @@ import getMatchingBottomTabRouteForState from './getMatchingBottomTabRouteForSta
import getMatchingCentralPaneRouteForState from './getMatchingCentralPaneRouteForState';
import replacePathInNestedState from './replacePathInNestedState';

type Metainfo = {
// Sometimes modal screens doesn't have information about what should be visible under the overlay.
// That means such screen can have different screens under the overlay depending on what was already in the state.
// If the screens in the bottom tab and central pane are not mandatory for this state, we want to have this information.
// It will help us later with creating proper diff betwen current and desired state.
isCentralPaneAndBottomTabMandatory: boolean;
isFullScreenNavigatorMandatory: boolean;
};

type GetAdaptedStateReturnType = {
adaptedState: ReturnType<typeof getStateFromPath>;
metainfo: Metainfo;
};

type GetAdaptedStateFromPath = (...args: Parameters<typeof getStateFromPath>) => GetAdaptedStateReturnType;

// The function getPathFromState that we are using in some places isn't working correctly without defined index.
const getRoutesWithIndex = (routes: NavigationPartialRoute[]) => ({routes, index: routes.length - 1});
const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState<NavigationState> => ({routes, index: routes.length - 1});

const addPolicyIdToRoute = (route: NavigationPartialRoute, policyID?: string) => {
const routeWithPolicyID = {...route};
Expand Down Expand Up @@ -71,7 +86,9 @@ function createFullScreenNavigator(route: NavigationPartialRoute<FullScreenName>
}

// This function will return CentralPaneNavigator route or FullScreenNavigator route.
function getMatchingRootRouteForRHPRoute(route: NavigationPartialRoute): NavigationPartialRoute<typeof NAVIGATORS.CENTRAL_PANE_NAVIGATOR | typeof NAVIGATORS.FULL_SCREEN_NAVIGATOR> {
function getMatchingRootRouteForRHPRoute(
route: NavigationPartialRoute,
): NavigationPartialRoute<typeof NAVIGATORS.CENTRAL_PANE_NAVIGATOR | typeof NAVIGATORS.FULL_SCREEN_NAVIGATOR> | undefined {
// Check for backTo param. One screen with different backTo value may need diferent screens visible under the overlay.
if (route.params && 'backTo' in route.params && typeof route.params.backTo === 'string') {
const stateForBackTo = getStateFromPath(route.params.backTo, config);
Expand Down Expand Up @@ -112,13 +129,14 @@ function getMatchingRootRouteForRHPRoute(route: NavigationPartialRoute): Navigat
return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: route.params});
}
}

// Default route
return createCentralPaneNavigator({name: SCREENS.REPORT, params: route.params});
}

function getAdaptedState(state: PartialState<NavigationState<RootStackParamList>>, policyID?: string) {
function getAdaptedState(state: PartialState<NavigationState<RootStackParamList>>, policyID?: string): GetAdaptedStateReturnType {
const isSmallScreenWidth = getIsSmallScreenWidth();
const metainfo = {
isCentralPaneAndBottomTabMandatory: true,
isFullScreenNavigatorMandatory: true,
};

// We need to check what is defined to know what we need to add.
const bottomTabNavigator = state.routes.find((route) => route.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR);
Expand All @@ -137,21 +155,35 @@ function getAdaptedState(state: PartialState<NavigationState<RootStackParamList>
const routes = [];

if (topmostNestedRHPRoute) {
const matchingRootRoute = getMatchingRootRouteForRHPRoute(topmostNestedRHPRoute);
let matchingRootRoute = getMatchingRootRouteForRHPRoute(topmostNestedRHPRoute);

// This may happen if this RHP doens't have a route that should be under the overlay defined.
if (!matchingRootRoute) {
metainfo.isCentralPaneAndBottomTabMandatory = false;
metainfo.isFullScreenNavigatorMandatory = false;
matchingRootRoute = createCentralPaneNavigator({name: SCREENS.REPORT});
}
// If the root route is type of FullScreenNavigator, the default bottom tab will be added.
const matchingBottomTabRoute = getMatchingBottomTabRouteForState({routes: [matchingRootRoute]});
routes.push(createBottomTabNavigator(matchingBottomTabRoute, policyID));
routes.push(matchingRootRoute);
}

routes.push(rhpNavigator);
return getRoutesWithIndex(routes);
return {
adaptedState: getRoutesWithIndex(routes),
metainfo,
};
}
if (lhpNavigator) {
// Routes
// - default bottom tab
// - default central pane on desktop layout
// - found lhp

// Currently there is only the search and workspace switcher in LHP both can have any central pane under the overlay.
metainfo.isCentralPaneAndBottomTabMandatory = false;
metainfo.isFullScreenNavigatorMandatory = false;
const routes = [];
routes.push(
createBottomTabNavigator(
Expand All @@ -170,13 +202,20 @@ function getAdaptedState(state: PartialState<NavigationState<RootStackParamList>
}
routes.push(lhpNavigator);

return getRoutesWithIndex(routes);
return {
adaptedState: getRoutesWithIndex(routes),
metainfo,
};
}
if (fullScreenNavigator) {
// Routes
// - default bottom tab
// - default central pane on desktop layout
// - found fullscreen

// Full screen navigator can have any central pane and bottom tab under. They will be covered anyway.
metainfo.isCentralPaneAndBottomTabMandatory = false;

const routes = [];
routes.push(
createBottomTabNavigator(
Expand All @@ -191,7 +230,10 @@ function getAdaptedState(state: PartialState<NavigationState<RootStackParamList>
}
routes.push(fullScreenNavigator);

return getRoutesWithIndex(routes);
return {
adaptedState: getRoutesWithIndex(routes),
metainfo,
};
}
if (centralPaneNavigator) {
// Routes
Expand All @@ -202,15 +244,20 @@ function getAdaptedState(state: PartialState<NavigationState<RootStackParamList>
routes.push(createBottomTabNavigator(matchingBottomTabRoute, policyID));
routes.push(centralPaneNavigator);

// TODO: TEMPORARY FIX - REPLACE WITH getRoutesWithIndex(routes)
return getRoutesWithIndex(routes);
return {
adaptedState: getRoutesWithIndex(routes),
metainfo,
};
}
if (bottomTabNavigator) {
// Routes
// - found bottom tab
// - matching central pane on desktop layout
if (isSmallScreenWidth) {
return state;
return {
adaptedState: state,
metainfo,
};
}

const routes = [...state.routes];
Expand All @@ -219,27 +266,33 @@ function getAdaptedState(state: PartialState<NavigationState<RootStackParamList>
routes.push(createCentralPaneNavigator(matchingCentralPaneRoute));
}

// TODO: TEMPORARY FIX - REPLACE WITH getRoutesWithIndex(routes)
return getRoutesWithIndex(routes);
return {
adaptedState: getRoutesWithIndex(routes),
metainfo,
};
}

return state;
return {
adaptedState: state,
metainfo,
};
}

const getAdaptedStateFromPath: typeof getStateFromPath = (path, options) => {
const url = getPathWithoutPolicyID(path);
const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options) => {
const normalizedPath = !path.startsWith('/') ? `/${path}` : path;
const pathWithoutPolicyID = getPathWithoutPolicyID(normalizedPath);
const isAnonymous = isAnonymousUser();
// Anonymous users don't have access to workspaces
const policyID = isAnonymous ? undefined : extractPolicyIDFromPath(path);

const state = getStateFromPath(url, options) as PartialState<NavigationState<RootStackParamList>>;
const state = getStateFromPath(pathWithoutPolicyID, options) as PartialState<NavigationState<RootStackParamList>>;
replacePathInNestedState(state, path);

if (state === undefined) {
throw new Error('Unable to parse path');
}
const adaptedState = getAdaptedState(state, policyID);
return adaptedState;
return getAdaptedState(state, policyID);
};

export default getAdaptedStateFromPath;
export type {Metainfo};
8 changes: 7 additions & 1 deletion src/libs/Navigation/linkingConfig/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import customGetPathFromState from './customGetPathFromState';
import getAdaptedStateFromPath from './getAdaptedStateFromPath';

const linkingConfig: LinkingOptions<RootStackParamList> = {
getStateFromPath: getAdaptedStateFromPath,
getStateFromPath: (...args) => {
const {adaptedState} = getAdaptedStateFromPath(...args);

// ResultState | undefined is the type this function expect.
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return adaptedState;
},
getPathFromState: customGetPathFromState,
prefixes: [
'app://-/',
Expand Down

0 comments on commit e6202e2

Please sign in to comment.