From a0bd09afa1c31093e34a1123b869f5f25139aed0 Mon Sep 17 00:00:00 2001 From: Niko Sams Date: Thu, 4 Jul 2024 08:26:56 +0200 Subject: [PATCH] Admin Stack: Show prompt if navigating into a nested Stack, even if subroutes 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> --- .changeset/fast-dodos-compete.md | 7 ++ .../admin/admin/src/FinalForm.InTabs.test.tsx | 2 +- packages/admin/admin/src/index.ts | 1 + packages/admin/admin/src/router/Context.tsx | 2 + .../admin/src/router/ForcePromptRoute.tsx | 22 +++++++ packages/admin/admin/src/router/Prompt.tsx | 33 ++++++++-- .../admin/src/router/PromptHandler.test.tsx | 60 +++++++++++++++++ .../admin/admin/src/router/PromptHandler.tsx | 21 ++++-- packages/admin/admin/src/stack/Switch.tsx | 44 ++++++++----- .../src/admin/router/ForcePromptRoute.tsx | 66 +++++++++++++++++++ 10 files changed, 226 insertions(+), 32 deletions(-) create mode 100644 .changeset/fast-dodos-compete.md create mode 100644 packages/admin/admin/src/router/ForcePromptRoute.tsx create mode 100644 storybook/src/admin/router/ForcePromptRoute.tsx diff --git a/.changeset/fast-dodos-compete.md b/.changeset/fast-dodos-compete.md new file mode 100644 index 0000000000..6f24fbf510 --- /dev/null +++ b/.changeset/fast-dodos-compete.md @@ -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) \ No newline at end of file diff --git a/packages/admin/admin/src/FinalForm.InTabs.test.tsx b/packages/admin/admin/src/FinalForm.InTabs.test.tsx index 55813a379a..8acade65c3 100644 --- a/packages/admin/admin/src/FinalForm.InTabs.test.tsx +++ b/packages/admin/admin/src/FinalForm.InTabs.test.tsx @@ -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) }); }); diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index 2f3ed53551..461e1f22d5 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -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"; diff --git a/packages/admin/admin/src/router/Context.tsx b/packages/admin/admin/src/router/Context.tsx index 65ed63d7d6..fbb382cedf 100644 --- a/packages/admin/admin/src/router/Context.tsx +++ b/packages/admin/admin/src/router/Context.tsx @@ -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 { @@ -11,6 +12,7 @@ interface IContext { resetAction?: ResetAction; path: string; subRoutePath?: string; + promptRoutes?: React.MutableRefObject; }) => void; unregister: (id: string) => void; } diff --git a/packages/admin/admin/src/router/ForcePromptRoute.tsx b/packages/admin/admin/src/router/ForcePromptRoute.tsx new file mode 100644 index 0000000000..5c77a42b92 --- /dev/null +++ b/packages/admin/admin/src/router/ForcePromptRoute.tsx @@ -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(() => 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 ; +} diff --git a/packages/admin/admin/src/router/Prompt.tsx b/packages/admin/admin/src/router/Prompt.tsx index 6b2336babf..d1c24d9fb6 100644 --- a/packages/admin/admin/src/router/Prompt.tsx +++ b/packages/admin/admin/src/router/Prompt.tsx @@ -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; +interface PromptContext { + register: (id: string, path: string) => void; + unregister: (id: string) => void; +} +export const PromptContext = React.createContext(undefined); + // react-router Prompt doesn't support multiple Prompts, this one does interface IProps { /** @@ -25,12 +35,13 @@ export const RouterPrompt: React.FunctionComponent = ({ message, saveAct const path: string | undefined = reactRouterContext?.match?.path; const context = React.useContext(RouterContext); const subRoutePrefix = useSubRoutePrefix(); + const promptRoutes = React.useRef({}); 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 "); } @@ -40,9 +51,19 @@ export const RouterPrompt: React.FunctionComponent = ({ message, saveAct } }; }); - if (subRoutePath) { - return {children}; - } else { - return <>{children}; - } + const childrenWithSubRoute = subRoutePath ? {children} : children; + return ( + { + promptRoutes.current[id] = { path }; + }, + unregister: (id: string) => { + delete promptRoutes.current[id]; + }, + }} + > + {childrenWithSubRoute} + + ); }; diff --git a/packages/admin/admin/src/router/PromptHandler.test.tsx b/packages/admin/admin/src/router/PromptHandler.test.tsx index d68159c854..b36a6b4407 100644 --- a/packages/admin/admin/src/router/PromptHandler.test.tsx +++ b/packages/admin/admin/src/router/PromptHandler.test.tsx @@ -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"; @@ -168,3 +169,62 @@ test("route outside Prompt", async () => { expect(sub.length).toBe(1); }); }); + +test("ForcePromptRoute", async () => { + function Story() { + return ( + + + { + return "sure?"; + }} + subRoutePath="/foo" + > +
    +
  • + /foo/sub1 (no prompt) +
  • +
  • + /foo/sub2 (force prompt) +
  • +
  • + /foo (back) +
  • +
+ +
sub1
+
+ +
sub2
+
+
+
+ +
+ ); + } + const rendered = render(); + + 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); + }); +}); diff --git a/packages/admin/admin/src/router/PromptHandler.tsx b/packages/admin/admin/src/router/PromptHandler.tsx index c768ddd02a..b0ef524203 100644 --- a/packages/admin/admin/src/router/PromptHandler.tsx +++ b/packages/admin/admin/src/router/PromptHandler.tsx @@ -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; @@ -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; @@ -103,6 +107,7 @@ interface PromptMessages { subRoutePath?: string; saveAction?: SaveAction; resetAction?: ResetAction; + promptRoutes?: React.MutableRefObject; }; } interface Props { @@ -123,6 +128,7 @@ export const RouterPromptHandler: React.FunctionComponent = ({ children, resetAction, path, subRoutePath, + promptRoutes, }: { id: string; message: (location: History.Location, action: History.Action) => string | boolean; @@ -130,8 +136,9 @@ export const RouterPromptHandler: React.FunctionComponent = ({ children, resetAction?: ResetAction; path: string; subRoutePath?: string; + promptRoutes?: React.MutableRefObject; }) => { - 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 diff --git a/packages/admin/admin/src/stack/Switch.tsx b/packages/admin/admin/src/stack/Switch.tsx index 23a55808f7..f00b58b2d3 100644 --- a/packages/admin/admin/src/stack/Switch.tsx +++ b/packages/admin/admin/src/stack/Switch.tsx @@ -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"; @@ -89,6 +90,7 @@ const StackSwitchInner: React.RefForwardingComponent(); const subRoutePrefix = useSubRoutePrefix(); + const location = useLocation(); let activePage: string | undefined; @@ -171,34 +173,40 @@ const StackSwitchInner: React.RefForwardingComponent + <> {React.Children.map(props.children, (page: React.ReactElement) => { 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 ( - + {(routeProps: RouteComponentProps) => { if (!routeProps.match) return null; return renderRoute(page, routeProps); }} - + ); })} - - {(routeProps: RouteComponentProps) => { - if (!routeProps.match) return null; - // now render initial page (as last route so it's a fallback) - let initialPage: React.ReactElement | null = null; - React.Children.forEach(props.children, (page: React.ReactElement) => { - if (isInitialPage(page.props.name)) { - initialPage = page; - } - }); - return renderRoute(initialPage!, routeProps); - }} - - + {!routeMatched && ( + + {(routeProps: RouteComponentProps) => { + if (!routeProps.match) return null; + // now render initial page (as last route so it's a fallback) + let initialPage: React.ReactElement | null = null; + React.Children.forEach(props.children, (page: React.ReactElement) => { + if (isInitialPage(page.props.name)) { + initialPage = page; + } + }); + return renderRoute(initialPage!, routeProps); + }} + + )} + ); }; const StackSwitchWithRef = React.forwardRef(StackSwitchInner); diff --git a/storybook/src/admin/router/ForcePromptRoute.tsx b/storybook/src/admin/router/ForcePromptRoute.tsx new file mode 100644 index 0000000000..c52bc0ebce --- /dev/null +++ b/storybook/src/admin/router/ForcePromptRoute.tsx @@ -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 ( + + + { + return "rly?"; + }} + subRoutePath="/foo" + > +
    +
  • + /foo/sub1 (no prompt) +
  • +
  • + /foo/sub2 (force prompt) +
  • +
  • + /foo (back) +
  • +
+ +
sub1
+
+ +
sub2
+
+
+
+ +
+ ); +} + +function Path() { + const location = useLocation(); + const [, rerender] = React.useState(0); + React.useEffect(() => { + const timer = setTimeout(() => { + rerender(new Date().getTime()); + }, 1000); + return () => clearTimeout(timer); + }, []); + return
{location.pathname}
; +} + +function App() { + return ( + <> + + + + ); +} + +storiesOf("@comet/admin/router", module) + .addDecorator(storyRouterDecorator()) + .add("ForcePromptRoute", () => );