Skip to content

Commit

Permalink
Admin Stack: Show prompt if navigating into a nested Stack, even if s…
Browse files Browse the repository at this point in the history
…ubroutes would normally not show prompt (#1955)

This adds:
- a new `ForcePromptRoute`, to be used like react-router Route, that
also registers it's path in a new context created by `RouterPrompt`
- `PromptHandler` can now check if the navigating-to-url matches with a
path from registered `ForcePromptRoute`
- and show the Prompt

Also adjust `StackSwitch`:
- to use the new `ForcePromptRoute`
- don't use react-router `Switch` as that would only render the first
matching route (but we need `ForcePromptRoute` to always render so it
can register)
- instead implement the logic from `Switch` by only rendering index if
no other route matched

---------

Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com>
  • Loading branch information
nsams and johnnyomair authored Jul 4, 2024
1 parent a589188 commit a0bd09a
Show file tree
Hide file tree
Showing 10 changed files with 226 additions and 32 deletions.
7 changes: 7 additions & 0 deletions .changeset/fast-dodos-compete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@comet/admin": minor
---

Add `ForcePromptRoute`, a `Route` that triggers a prompt even if it is a subroute

Used in `StackSwitch` so that navigating to a nested stack subpage will show a prompt (if dirty)
2 changes: 1 addition & 1 deletion packages/admin/admin/src/FinalForm.InTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,6 @@ test("Form DirtyPrompt for inner Stack", async () => {

await waitFor(() => {
const dirtyDialog = rendered.queryAllByText("Do you want to save your changes?");
expect(dirtyDialog.length).toBe(0);
expect(dirtyDialog.length).toBe(2); // 2 because text is shown twice in dialog (title+content)
});
});
1 change: 1 addition & 0 deletions packages/admin/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export { MuiThemeProvider } from "./mui/ThemeProvider";
export { RouterBrowserRouter } from "./router/BrowserRouter";
export { RouterConfirmationDialog, RouterConfirmationDialogClassKey, RouterConfirmationDialogProps } from "./router/ConfirmationDialog";
export { RouterContext } from "./router/Context";
export { ForcePromptRoute } from "./router/ForcePromptRoute";
export { RouterMemoryRouter } from "./router/MemoryRouter";
export { RouterPrompt } from "./router/Prompt";
export { RouterPromptHandler, SaveAction } from "./router/PromptHandler";
Expand Down
2 changes: 2 additions & 0 deletions packages/admin/admin/src/router/Context.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as History from "history";
import * as React from "react";

import { PromptRoutes } from "./Prompt";
import { ResetAction, SaveAction } from "./PromptHandler";

interface IContext {
Expand All @@ -11,6 +12,7 @@ interface IContext {
resetAction?: ResetAction;
path: string;
subRoutePath?: string;
promptRoutes?: React.MutableRefObject<PromptRoutes>;
}) => void;
unregister: (id: string) => void;
}
Expand Down
22 changes: 22 additions & 0 deletions packages/admin/admin/src/router/ForcePromptRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as React from "react";
import { Route, RouteProps } from "react-router";
import useConstant from "use-constant";
import { v4 as uuid } from "uuid";

import { PromptContext } from "./Prompt";

export function ForcePromptRoute(props: RouteProps) {
const id = useConstant<string>(() => uuid());
const context = React.useContext(PromptContext);
React.useEffect(() => {
if (context) {
context.register(id, props.path as string);
}
return function cleanup() {
if (context) {
context.unregister(id);
}
};
});
return <Route {...props} />;
}
33 changes: 27 additions & 6 deletions packages/admin/admin/src/router/Prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import { RouterContext } from "./Context";
import { ResetAction, SaveAction } from "./PromptHandler";
import { SubRoute, useSubRoutePrefix } from "./SubRoute";

type PromptRoute = {
path: string;
};
export type PromptRoutes = Record<string, PromptRoute>;
interface PromptContext {
register: (id: string, path: string) => void;
unregister: (id: string) => void;
}
export const PromptContext = React.createContext<PromptContext | undefined>(undefined);

// react-router Prompt doesn't support multiple Prompts, this one does
interface IProps {
/**
Expand All @@ -25,12 +35,13 @@ export const RouterPrompt: React.FunctionComponent<IProps> = ({ message, saveAct
const path: string | undefined = reactRouterContext?.match?.path;
const context = React.useContext(RouterContext);
const subRoutePrefix = useSubRoutePrefix();
const promptRoutes = React.useRef<PromptRoutes>({});
if (subRoutePath && subRoutePath.startsWith("./")) {
subRoutePath = subRoutePrefix + subRoutePath.substring(1);
}
React.useEffect(() => {
if (context) {
context.register({ id, message, saveAction, resetAction, path, subRoutePath });
context.register({ id, message, saveAction, resetAction, path, subRoutePath, promptRoutes });
} else {
console.error("Can't register RouterPrompt, missing <RouterPromptHandler>");
}
Expand All @@ -40,9 +51,19 @@ export const RouterPrompt: React.FunctionComponent<IProps> = ({ message, saveAct
}
};
});
if (subRoutePath) {
return <SubRoute path={subRoutePath}>{children}</SubRoute>;
} else {
return <>{children}</>;
}
const childrenWithSubRoute = subRoutePath ? <SubRoute path={subRoutePath}>{children}</SubRoute> : children;
return (
<PromptContext.Provider
value={{
register: (id: string, path: string) => {
promptRoutes.current[id] = { path };
},
unregister: (id: string) => {
delete promptRoutes.current[id];
},
}}
>
{childrenWithSubRoute}
</PromptContext.Provider>
);
};
60 changes: 60 additions & 0 deletions packages/admin/admin/src/router/PromptHandler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Redirect, Route, Switch } from "react-router";
import { Link } from "react-router-dom";
import { fireEvent, render, waitFor } from "test-utils";

import { ForcePromptRoute } from "./ForcePromptRoute";
import { RouterPrompt } from "./Prompt";
import { useSubRoutePrefix } from "./SubRoute";

Expand Down Expand Up @@ -168,3 +169,62 @@ test("route outside Prompt", async () => {
expect(sub.length).toBe(1);
});
});

test("ForcePromptRoute", async () => {
function Story() {
return (
<Switch>
<Route path="/foo">
<RouterPrompt
message={() => {
return "sure?";
}}
subRoutePath="/foo"
>
<ul>
<li>
<Link to="/foo/sub1">/foo/sub1</Link> (no prompt)
</li>
<li>
<Link to="/foo/sub2">/foo/sub2</Link> (force prompt)
</li>
<li>
<Link to="/foo">/foo</Link> (back)
</li>
</ul>
<Route path="/foo/sub1">
<div>sub1</div>
</Route>
<ForcePromptRoute path="/foo/sub2">
<div>sub2</div>
</ForcePromptRoute>
</RouterPrompt>
</Route>
<Redirect to="/foo" />
</Switch>
);
}
const rendered = render(<Story />);

fireEvent.click(rendered.getByText("/foo/sub2"));

// verify navigation to bar did get blocked
await waitFor(() => {
const sub = rendered.queryAllByText("sub2");
expect(sub.length).toBe(0);
});

// and dirty dialog is shown
await waitFor(() => {
const sub = rendered.queryAllByText("sure?");
expect(sub.length).toBe(1);
});

fireEvent.click(rendered.getByText("/foo/sub1"));

// verify navigation didn't get blocked
await waitFor(() => {
const sub = rendered.queryAllByText("sub");
expect(sub.length).toBe(0);
});
});
21 changes: 14 additions & 7 deletions packages/admin/admin/src/router/PromptHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { matchPath, Prompt } from "react-router";

import { PromptAction, RouterConfirmationDialog } from "./ConfirmationDialog";
import { RouterContext } from "./Context";
import { PromptRoutes } from "./Prompt";

interface PromptHandlerState {
showConfirmationDialog: boolean;
Expand Down Expand Up @@ -40,12 +41,15 @@ function InnerPromptHandler({
for (const id of Object.keys(registeredMessages.current)) {
const path = registeredMessages.current[id].path;
const subRoutePath = registeredMessages.current[id].subRoutePath;
// allow transition if location is below path where prompt was rendered
if (subRoutePath && location.pathname.startsWith(subRoutePath)) {
//subRoutePath matches with location, allow transition
} else if (matchPath(location.pathname, { path, exact: true })) {
// path matches with location, allow transition
} else {
const promptRoutes = registeredMessages.current[id].promptRoutes?.current ?? {};

const promptRouteMatches = Object.values(promptRoutes).some((route) => {
return matchPath(location.pathname, { path: route.path, exact: true });
});
const subRouteMatches = subRoutePath && location.pathname.startsWith(subRoutePath);
const pathMatches = matchPath(location.pathname, { path, exact: true });

if (promptRouteMatches || (!subRouteMatches && !pathMatches)) {
const message = registeredMessages.current[id].message(location, action);
if (message !== true) {
return message;
Expand Down Expand Up @@ -103,6 +107,7 @@ interface PromptMessages {
subRoutePath?: string;
saveAction?: SaveAction;
resetAction?: ResetAction;
promptRoutes?: React.MutableRefObject<PromptRoutes>;
};
}
interface Props {
Expand All @@ -123,15 +128,17 @@ export const RouterPromptHandler: React.FunctionComponent<Props> = ({ children,
resetAction,
path,
subRoutePath,
promptRoutes,
}: {
id: string;
message: (location: History.Location, action: History.Action) => string | boolean;
saveAction?: SaveAction;
resetAction?: ResetAction;
path: string;
subRoutePath?: string;
promptRoutes?: React.MutableRefObject<PromptRoutes>;
}) => {
registeredMessages.current[id] = { message, path, subRoutePath, saveAction, resetAction };
registeredMessages.current[id] = { message, path, subRoutePath, saveAction, resetAction, promptRoutes };
// If saveAction is passed it has to be passed for all registered components
if (saveAction && Object.values(registeredMessages.current).some((registeredMessage) => !registeredMessage.saveAction)) {
// eslint-disable-next-line no-console
Expand Down
44 changes: 26 additions & 18 deletions packages/admin/admin/src/stack/Switch.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as React from "react";
import { Route, RouteComponentProps, Switch, useHistory, useRouteMatch } from "react-router";
import { matchPath, RouteComponentProps, useHistory, useLocation, useRouteMatch } from "react-router";
import { v4 as uuid } from "uuid";

import { ForcePromptRoute } from "../router/ForcePromptRoute";
import { SubRouteIndexRoute, useSubRoutePrefix } from "../router/SubRoute";
import { StackBreadcrumb } from "./Breadcrumb";
import { IStackPageProps } from "./Page";
Expand Down Expand Up @@ -89,6 +90,7 @@ const StackSwitchInner: React.RefForwardingComponent<IStackSwitchApi, IProps & I
const history = useHistory();
const match = useRouteMatch<IRouteParams>();
const subRoutePrefix = useSubRoutePrefix();
const location = useLocation();

let activePage: string | undefined;

Expand Down Expand Up @@ -171,34 +173,40 @@ const StackSwitchInner: React.RefForwardingComponent<IStackSwitchApi, IProps & I

if (!match) return null;

let routeMatched = false; //to prevent rendering the initial page when a route is matched (as the initial would also match)
return (
<Switch>
<>
{React.Children.map(props.children, (page: React.ReactElement<IStackPageProps>) => {
if (isInitialPage(page.props.name)) return null; // don't render initial Page
const path = `${removeTrailingSlash(subRoutePrefix)}/:id/${page.props.name}`;
if (matchPath(location.pathname, { path })) {
routeMatched = true;
}
return (
<Route path={path}>
<ForcePromptRoute path={path}>
{(routeProps: RouteComponentProps<IRouteParams>) => {
if (!routeProps.match) return null;
return renderRoute(page, routeProps);
}}
</Route>
</ForcePromptRoute>
);
})}
<SubRouteIndexRoute>
{(routeProps: RouteComponentProps<IRouteParams>) => {
if (!routeProps.match) return null;
// now render initial page (as last route so it's a fallback)
let initialPage: React.ReactElement<IStackPageProps> | null = null;
React.Children.forEach(props.children, (page: React.ReactElement<IStackPageProps>) => {
if (isInitialPage(page.props.name)) {
initialPage = page;
}
});
return renderRoute(initialPage!, routeProps);
}}
</SubRouteIndexRoute>
</Switch>
{!routeMatched && (
<SubRouteIndexRoute>
{(routeProps: RouteComponentProps<IRouteParams>) => {
if (!routeProps.match) return null;
// now render initial page (as last route so it's a fallback)
let initialPage: React.ReactElement<IStackPageProps> | null = null;
React.Children.forEach(props.children, (page: React.ReactElement<IStackPageProps>) => {
if (isInitialPage(page.props.name)) {
initialPage = page;
}
});
return renderRoute(initialPage!, routeProps);
}}
</SubRouteIndexRoute>
)}
</>
);
};
const StackSwitchWithRef = React.forwardRef(StackSwitchInner);
Expand Down
66 changes: 66 additions & 0 deletions storybook/src/admin/router/ForcePromptRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ForcePromptRoute, RouterPrompt } from "@comet/admin";
import { storiesOf } from "@storybook/react";
import * as React from "react";
import { Redirect, Route, Switch, useLocation } from "react-router";
import { Link } from "react-router-dom";

import { storyRouterDecorator } from "../../story-router.decorator";

function Story() {
return (
<Switch>
<Route path="/foo">
<RouterPrompt
message={() => {
return "rly?";
}}
subRoutePath="/foo"
>
<ul>
<li>
<Link to="/foo/sub1">/foo/sub1</Link> (no prompt)
</li>
<li>
<Link to="/foo/sub2">/foo/sub2</Link> (force prompt)
</li>
<li>
<Link to="/foo">/foo</Link> (back)
</li>
</ul>
<Route path="/foo/sub1">
<div>sub1</div>
</Route>
<ForcePromptRoute path="/foo/sub2">
<div>sub2</div>
</ForcePromptRoute>
</RouterPrompt>
</Route>
<Redirect to="/foo" />
</Switch>
);
}

function Path() {
const location = useLocation();
const [, rerender] = React.useState(0);
React.useEffect(() => {
const timer = setTimeout(() => {
rerender(new Date().getTime());
}, 1000);
return () => clearTimeout(timer);
}, []);
return <div>{location.pathname}</div>;
}

function App() {
return (
<>
<Path />
<Story />
</>
);
}

storiesOf("@comet/admin/router", module)
.addDecorator(storyRouterDecorator())
.add("ForcePromptRoute", () => <App />);

0 comments on commit a0bd09a

Please sign in to comment.