From c49afd6838b9705eba6851d8477ce47b1b99c608 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Fri, 12 Jul 2024 14:49:28 -0300 Subject: [PATCH 1/7] NavigationBlocker --- .../[slug]/EditSquiggleSnippetModel.tsx | 18 +- packages/hub/src/components/EntityCard.tsx | 2 +- packages/hub/src/components/EntityInfo.tsx | 3 +- .../hub/src/components/NavigationBlocker.tsx | 174 ++++++++++++++++++ packages/hub/src/components/ReactRoot.tsx | 10 +- packages/hub/src/components/ui/Link.tsx | 33 ++++ packages/hub/src/components/ui/StyledLink.tsx | 3 +- 7 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 packages/hub/src/components/NavigationBlocker.tsx create mode 100644 packages/hub/src/components/ui/Link.tsx diff --git a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx index cab2fbbdaf..a8bed6783f 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx @@ -1,6 +1,13 @@ import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; -import { BaseSyntheticEvent, FC, use, useMemo, useState } from "react"; +import { + BaseSyntheticEvent, + FC, + use, + useCallback, + useMemo, + useState, +} from "react"; import { FormProvider, useFieldArray, useForm } from "react-hook-form"; import { graphql, useFragment } from "react-relay"; @@ -29,6 +36,7 @@ import { } from "@quri/versioned-squiggle-components"; import { EditRelativeValueExports } from "@/components/exports/EditRelativeValueExports"; +import { useBlockNavigation } from "@/components/NavigationBlocker"; import { ReactRoot } from "@/components/ReactRoot"; import { FormModal } from "@/components/ui/FormModal"; import { SAMPLE_COUNT_DEFAULT, XY_POINT_LENGTH_DEFAULT } from "@/constants"; @@ -270,6 +278,14 @@ export const EditSquiggleSnippetModel: FC = ({ } }; + // block navigation if code is edited + useBlockNavigation( + useCallback( + () => form.getValues("code") !== content.code, + [form, content.code] + ) + ); + // We don't want to control SquigglePlayground, it's uncontrolled by design. // Instead, we reset the `defaultCode` that we pass to it when version is changed or draft is restored. const [defaultCode, setDefaultCode] = useState(content.code); diff --git a/packages/hub/src/components/EntityCard.tsx b/packages/hub/src/components/EntityCard.tsx index 9116c1b2ea..7ccea5f26d 100644 --- a/packages/hub/src/components/EntityCard.tsx +++ b/packages/hub/src/components/EntityCard.tsx @@ -1,10 +1,10 @@ import clsx from "clsx"; -import Link from "next/link"; import React, { FC, PropsWithChildren } from "react"; import { LockIcon } from "@quri/ui"; import { EntityNode } from "./EntityInfo"; +import { Link } from "./ui/Link"; export type { EntityNode }; diff --git a/packages/hub/src/components/EntityInfo.tsx b/packages/hub/src/components/EntityInfo.tsx index 7076bcd784..c8e0f22d31 100644 --- a/packages/hub/src/components/EntityInfo.tsx +++ b/packages/hub/src/components/EntityInfo.tsx @@ -1,9 +1,10 @@ import { clsx } from "clsx"; -import Link from "next/link"; import { FC, ReactNode } from "react"; import { IconProps } from "@/relative-values/components/ui/icons/Icon"; +import { Link } from "./ui/Link"; + // works both for models and for definitions export type EntityNode = { slug: string; diff --git a/packages/hub/src/components/NavigationBlocker.tsx b/packages/hub/src/components/NavigationBlocker.tsx new file mode 100644 index 0000000000..83d42a7532 --- /dev/null +++ b/packages/hub/src/components/NavigationBlocker.tsx @@ -0,0 +1,174 @@ +import { useRouter } from "next/navigation"; +import { + createContext, + FC, + PropsWithChildren, + Reducer, + use, + useCallback, + useEffect, + useId, + useReducer, +} from "react"; + +import { Button, Modal } from "@quri/ui"; + +type State = { + // Blockers are functions; this way we can check dynamic parameters such as + // form values to decide whether navigation should be blocked. + blockers: Record boolean>; + // Link was intercepted, need to show a modal. + interceptedLink: string | undefined; +}; + +type Action = + | { + type: "addBlocker"; + payload: { + key: string; + blocker: () => boolean; + }; + } + | { + type: "removeBlocker"; + payload: { + key: string; + }; + } + | { + type: "intercept"; + payload: { + link: string; + }; + } + | { + type: "clearInterceptedLink"; + }; + +const reducer: Reducer = (state, action) => { + switch (action.type) { + case "addBlocker": + return { + ...state, + blockers: { + ...state.blockers, + [action.payload.key]: action.payload.blocker, + }, + }; + case "removeBlocker": + const { [action.payload.key]: _, ...blockers } = state.blockers; + return { ...state, blockers }; + case "intercept": + return { ...state, interceptedLink: action.payload.link }; + case "clearInterceptedLink": + return { ...state, interceptedLink: undefined }; + default: + return state; + } +}; + +const NavigationBlockerContext = createContext<{ + state: State; + dispatch: (action: Action) => void; +}>({ + state: { + blockers: {}, + interceptedLink: undefined, + }, + dispatch: () => {}, +}); + +// Be very careful with this hook! `blocker` parameter, if set, must have stable identity. +// If it's an inline function, it will cause an infinite render loop. +export function useBlockNavigation(blocker: () => boolean = () => true) { + const { dispatch } = use(NavigationBlockerContext); + + const key = useId(); + + useEffect(() => { + dispatch({ + type: "addBlocker", + payload: { + key, + blocker, + }, + }); + return () => { + dispatch({ + type: "removeBlocker", + payload: { key }, + }); + }; + }, [dispatch, key, blocker]); +} + +// set `interceptedLink`, show a modal +export function useInterceptLink() { + const { dispatch } = use(NavigationBlockerContext); + + return useCallback( + (link: string) => dispatch({ type: "intercept", payload: { link } }), + [dispatch] + ); +} + +// used by `Link` component to detect if intercepting is active +export function useIsIntercepting() { + const { blockers } = use(NavigationBlockerContext).state; + return () => Object.values(blockers).some((blocker) => blocker()); +} + +const InterceptedLinkModal: FC = () => { + const { state, dispatch } = use(NavigationBlockerContext); + const close = useCallback(() => { + dispatch({ type: "clearInterceptedLink" }); + }, [dispatch]); + + const router = useRouter(); + + if (!state.interceptedLink) { + return null; + } + + return ( + + You have unsaved changes + + Are you sure you want to leave this page? You changes are not saved. + + +
+ + +
+
+
+ ); +}; + +export const NavigationBlocker: FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, { + blockers: {}, + interceptedLink: undefined, + }); + + return ( + + + {children} + + ); +}; diff --git a/packages/hub/src/components/ReactRoot.tsx b/packages/hub/src/components/ReactRoot.tsx index 2da58e883b..4592448922 100644 --- a/packages/hub/src/components/ReactRoot.tsx +++ b/packages/hub/src/components/ReactRoot.tsx @@ -9,6 +9,8 @@ import { WithToasts } from "@quri/ui"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { getCurrentEnvironment } from "@/relay/environment"; +import { NavigationBlocker } from "./NavigationBlocker"; + // This component is used in the app's root layout to configure all common providers and wrappers. // It's also useful when you want to mount a separate React root. One example is CodeMirror tooltips, which are mounted as separate DOM elements. export const ReactRoot: FC> = ({ @@ -20,9 +22,11 @@ export const ReactRoot: FC> = ({ return ( - - {children} - + + + {children} + + ); diff --git a/packages/hub/src/components/ui/Link.tsx b/packages/hub/src/components/ui/Link.tsx new file mode 100644 index 0000000000..fe8e1f52fe --- /dev/null +++ b/packages/hub/src/components/ui/Link.tsx @@ -0,0 +1,33 @@ +import NextLink, { LinkProps } from "next/link"; +import { forwardRef, useMemo } from "react"; + +import { useInterceptLink, useIsIntercepting } from "../NavigationBlocker"; + +// Link type, copy-pasted from next/link for reference: + +// declare const Link: React.ForwardRefExoticComponent, keyof InternalLinkProps> & InternalLinkProps & { +// children?: React.ReactNode; +// } & React.RefAttributes>; + +export const Link = forwardRef< + HTMLAnchorElement, + Omit, keyof LinkProps> & + LinkProps & { + children?: React.ReactNode; + } +>(function Link({ onClick, ...props }, ref) { + const isIntercepting = useIsIntercepting(); + const interceptLink = useInterceptLink(); + + const patchedOnClick = useMemo(() => { + return (e: React.MouseEvent) => { + if (!isIntercepting()) { + return onClick?.(e); + } + interceptLink(e.currentTarget.href); + e.preventDefault(); + }; + }, [onClick, isIntercepting, interceptLink]); + + return ; +}); diff --git a/packages/hub/src/components/ui/StyledLink.tsx b/packages/hub/src/components/ui/StyledLink.tsx index 86724d4af6..7ef9ec3c99 100644 --- a/packages/hub/src/components/ui/StyledLink.tsx +++ b/packages/hub/src/components/ui/StyledLink.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; -import Link from "next/link"; + +import { Link } from "./Link"; export const StyledLink: React.FC< React.AnchorHTMLAttributes & { href: string } From 9d8255dc31c5320aeebc347d97800edf252bafaf Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Fri, 12 Jul 2024 15:29:23 -0300 Subject: [PATCH 2/7] reorganize NavigationBlocker; redesign draft dialog; prevent hard navigation --- .../[slug]/EditSquiggleSnippetModel.tsx | 2 +- .../[slug]/SquiggleSnippetDraftDialog.tsx | 50 ++--- .../hub/src/components/NavigationBlocker.tsx | 174 ------------------ .../InterceptedLinkModal.tsx | 42 +++++ .../NavigationBlockerProvider.tsx | 25 +++ .../components/NavigationBlocker/context.ts | 69 +++++++ .../src/components/NavigationBlocker/hooks.ts | 54 ++++++ packages/hub/src/components/ReactRoot.tsx | 6 +- packages/hub/src/components/ui/Link.tsx | 5 +- 9 files changed, 223 insertions(+), 204 deletions(-) delete mode 100644 packages/hub/src/components/NavigationBlocker.tsx create mode 100644 packages/hub/src/components/NavigationBlocker/InterceptedLinkModal.tsx create mode 100644 packages/hub/src/components/NavigationBlocker/NavigationBlockerProvider.tsx create mode 100644 packages/hub/src/components/NavigationBlocker/context.ts create mode 100644 packages/hub/src/components/NavigationBlocker/hooks.ts diff --git a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx index a8bed6783f..e818b8625b 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx @@ -36,7 +36,7 @@ import { } from "@quri/versioned-squiggle-components"; import { EditRelativeValueExports } from "@/components/exports/EditRelativeValueExports"; -import { useBlockNavigation } from "@/components/NavigationBlocker"; +import { useBlockNavigation } from "@/components/NavigationBlocker/hooks"; import { ReactRoot } from "@/components/ReactRoot"; import { FormModal } from "@/components/ui/FormModal"; import { SAMPLE_COUNT_DEFAULT, XY_POINT_LENGTH_DEFAULT } from "@/constants"; diff --git a/packages/hub/src/app/models/[owner]/[slug]/SquiggleSnippetDraftDialog.tsx b/packages/hub/src/app/models/[owner]/[slug]/SquiggleSnippetDraftDialog.tsx index 884418fb14..a82de66400 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/SquiggleSnippetDraftDialog.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/SquiggleSnippetDraftDialog.tsx @@ -1,7 +1,7 @@ -import { FC, useState } from "react"; +import { FC, PropsWithChildren, useState } from "react"; import { graphql, useFragment } from "react-relay"; -import { Button, Modal, TextTooltip } from "@quri/ui"; +import { Button, Modal } from "@quri/ui"; import { useClientOnlyRender } from "@/hooks/useClientOnlyRender"; @@ -27,6 +27,10 @@ function localStorageExists() { return Boolean(typeof window !== "undefined" && window.localStorage); } +const Hint: FC = ({ children }) => ( +
{children}
+); + const CopyToClipboardButton: FC<{ draftLocator: DraftLocator }> = ({ draftLocator, }) => { @@ -168,29 +172,25 @@ export const SquiggleSnippetDraftDialog: FC = ({ Unsaved Draft You have an unsaved draft for this model. -
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
+
+ + + { + "Draft will be ignored but you'll see this prompt again on next load." + } + + + Draft will be discarded and forgotten. + + Draft will be copied to clipboard. + + + { + "Code and version will be replaced by draft version. You'll still need to save it manually." + } +
diff --git a/packages/hub/src/components/NavigationBlocker.tsx b/packages/hub/src/components/NavigationBlocker.tsx deleted file mode 100644 index 83d42a7532..0000000000 --- a/packages/hub/src/components/NavigationBlocker.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { useRouter } from "next/navigation"; -import { - createContext, - FC, - PropsWithChildren, - Reducer, - use, - useCallback, - useEffect, - useId, - useReducer, -} from "react"; - -import { Button, Modal } from "@quri/ui"; - -type State = { - // Blockers are functions; this way we can check dynamic parameters such as - // form values to decide whether navigation should be blocked. - blockers: Record boolean>; - // Link was intercepted, need to show a modal. - interceptedLink: string | undefined; -}; - -type Action = - | { - type: "addBlocker"; - payload: { - key: string; - blocker: () => boolean; - }; - } - | { - type: "removeBlocker"; - payload: { - key: string; - }; - } - | { - type: "intercept"; - payload: { - link: string; - }; - } - | { - type: "clearInterceptedLink"; - }; - -const reducer: Reducer = (state, action) => { - switch (action.type) { - case "addBlocker": - return { - ...state, - blockers: { - ...state.blockers, - [action.payload.key]: action.payload.blocker, - }, - }; - case "removeBlocker": - const { [action.payload.key]: _, ...blockers } = state.blockers; - return { ...state, blockers }; - case "intercept": - return { ...state, interceptedLink: action.payload.link }; - case "clearInterceptedLink": - return { ...state, interceptedLink: undefined }; - default: - return state; - } -}; - -const NavigationBlockerContext = createContext<{ - state: State; - dispatch: (action: Action) => void; -}>({ - state: { - blockers: {}, - interceptedLink: undefined, - }, - dispatch: () => {}, -}); - -// Be very careful with this hook! `blocker` parameter, if set, must have stable identity. -// If it's an inline function, it will cause an infinite render loop. -export function useBlockNavigation(blocker: () => boolean = () => true) { - const { dispatch } = use(NavigationBlockerContext); - - const key = useId(); - - useEffect(() => { - dispatch({ - type: "addBlocker", - payload: { - key, - blocker, - }, - }); - return () => { - dispatch({ - type: "removeBlocker", - payload: { key }, - }); - }; - }, [dispatch, key, blocker]); -} - -// set `interceptedLink`, show a modal -export function useInterceptLink() { - const { dispatch } = use(NavigationBlockerContext); - - return useCallback( - (link: string) => dispatch({ type: "intercept", payload: { link } }), - [dispatch] - ); -} - -// used by `Link` component to detect if intercepting is active -export function useIsIntercepting() { - const { blockers } = use(NavigationBlockerContext).state; - return () => Object.values(blockers).some((blocker) => blocker()); -} - -const InterceptedLinkModal: FC = () => { - const { state, dispatch } = use(NavigationBlockerContext); - const close = useCallback(() => { - dispatch({ type: "clearInterceptedLink" }); - }, [dispatch]); - - const router = useRouter(); - - if (!state.interceptedLink) { - return null; - } - - return ( - - You have unsaved changes - - Are you sure you want to leave this page? You changes are not saved. - - -
- - -
-
-
- ); -}; - -export const NavigationBlocker: FC = ({ children }) => { - const [state, dispatch] = useReducer(reducer, { - blockers: {}, - interceptedLink: undefined, - }); - - return ( - - - {children} - - ); -}; diff --git a/packages/hub/src/components/NavigationBlocker/InterceptedLinkModal.tsx b/packages/hub/src/components/NavigationBlocker/InterceptedLinkModal.tsx new file mode 100644 index 0000000000..caf7be7b0b --- /dev/null +++ b/packages/hub/src/components/NavigationBlocker/InterceptedLinkModal.tsx @@ -0,0 +1,42 @@ +import { useRouter } from "next/navigation"; +import { FC, use, useCallback } from "react"; + +import { Button, Modal } from "@quri/ui"; + +import { NavigationBlockerContext } from "./context"; + +export const InterceptedLinkModal: FC = () => { + const { state, dispatch } = use(NavigationBlockerContext); + const close = useCallback(() => { + dispatch({ type: "clearInterceptedLink" }); + }, [dispatch]); + + const router = useRouter(); + + if (!state.interceptedLink) { + return null; + } + + return ( + + You have unsaved changes + + Are you sure you want to leave this page? You changes are not saved. + + +
+ + +
+
+
+ ); +}; diff --git a/packages/hub/src/components/NavigationBlocker/NavigationBlockerProvider.tsx b/packages/hub/src/components/NavigationBlocker/NavigationBlockerProvider.tsx new file mode 100644 index 0000000000..0a71911f45 --- /dev/null +++ b/packages/hub/src/components/NavigationBlocker/NavigationBlockerProvider.tsx @@ -0,0 +1,25 @@ +import { FC, PropsWithChildren, useReducer } from "react"; + +import { NavigationBlockerContext, navigationBlockerReducer } from "./context"; +import { InterceptedLinkModal } from "./InterceptedLinkModal"; + +export const NavigationBlockerProvider: FC = ({ + children, +}) => { + const [state, dispatch] = useReducer(navigationBlockerReducer, { + blockers: {}, + interceptedLink: undefined, + }); + + return ( + + + {children} + + ); +}; diff --git a/packages/hub/src/components/NavigationBlocker/context.ts b/packages/hub/src/components/NavigationBlocker/context.ts new file mode 100644 index 0000000000..7bd0851dbd --- /dev/null +++ b/packages/hub/src/components/NavigationBlocker/context.ts @@ -0,0 +1,69 @@ +import { createContext, Reducer } from "react"; + +type State = { + // Blockers are functions; this way we can check dynamic parameters such as + // form values to decide whether navigation should be blocked. + blockers: Record boolean>; + // Link was intercepted, need to show a modal. + interceptedLink: string | undefined; +}; + +type Action = + | { + type: "addBlocker"; + payload: { + key: string; + blocker: () => boolean; + }; + } + | { + type: "removeBlocker"; + payload: { + key: string; + }; + } + | { + type: "intercept"; + payload: { + link: string; + }; + } + | { + type: "clearInterceptedLink"; + }; + +export const navigationBlockerReducer: Reducer = ( + state, + action +) => { + switch (action.type) { + case "addBlocker": + return { + ...state, + blockers: { + ...state.blockers, + [action.payload.key]: action.payload.blocker, + }, + }; + case "removeBlocker": + const { [action.payload.key]: _, ...blockers } = state.blockers; + return { ...state, blockers }; + case "intercept": + return { ...state, interceptedLink: action.payload.link }; + case "clearInterceptedLink": + return { ...state, interceptedLink: undefined }; + default: + return state; + } +}; + +export const NavigationBlockerContext = createContext<{ + state: State; + dispatch: (action: Action) => void; +}>({ + state: { + blockers: {}, + interceptedLink: undefined, + }, + dispatch: () => {}, +}); diff --git a/packages/hub/src/components/NavigationBlocker/hooks.ts b/packages/hub/src/components/NavigationBlocker/hooks.ts new file mode 100644 index 0000000000..edc06e1dbe --- /dev/null +++ b/packages/hub/src/components/NavigationBlocker/hooks.ts @@ -0,0 +1,54 @@ +import { use, useCallback, useEffect, useId } from "react"; + +import { NavigationBlockerContext } from "./context"; + +// Be very careful with this hook! `blocker` parameter, if set, must have stable identity. +// If it's an inline function, it will cause an infinite render loop: +// - any change in blocker will cause all consumers of this hook to re-render +// - this will cause the blocker to re-create, causing another re-render +export function useBlockNavigation(blocker: () => boolean = () => true) { + const { dispatch } = use(NavigationBlockerContext); + + const key = useId(); + + useEffect(() => { + dispatch({ + type: "addBlocker", + payload: { + key, + blocker, + }, + }); + + const listener = (e: BeforeUnloadEvent) => { + if (blocker()) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", listener); + + return () => { + window.removeEventListener("beforeunload", listener); + dispatch({ + type: "removeBlocker", + payload: { key }, + }); + }; + }, [dispatch, key, blocker]); +} + +// set `interceptedLink`, show a modal +export function useInterceptLink() { + const { dispatch } = use(NavigationBlockerContext); + + return useCallback( + (link: string) => dispatch({ type: "intercept", payload: { link } }), + [dispatch] + ); +} + +// used by `Link` component to detect if intercepting is active +export function useIsIntercepting() { + const { blockers } = use(NavigationBlockerContext).state; + return () => Object.values(blockers).some((blocker) => blocker()); +} diff --git a/packages/hub/src/components/ReactRoot.tsx b/packages/hub/src/components/ReactRoot.tsx index 4592448922..cb8c6379a0 100644 --- a/packages/hub/src/components/ReactRoot.tsx +++ b/packages/hub/src/components/ReactRoot.tsx @@ -9,7 +9,7 @@ import { WithToasts } from "@quri/ui"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { getCurrentEnvironment } from "@/relay/environment"; -import { NavigationBlocker } from "./NavigationBlocker"; +import { NavigationBlockerProvider } from "./NavigationBlocker/NavigationBlockerProvider"; // This component is used in the app's root layout to configure all common providers and wrappers. // It's also useful when you want to mount a separate React root. One example is CodeMirror tooltips, which are mounted as separate DOM elements. @@ -22,11 +22,11 @@ export const ReactRoot: FC> = ({ return ( - + {children} - + ); diff --git a/packages/hub/src/components/ui/Link.tsx b/packages/hub/src/components/ui/Link.tsx index fe8e1f52fe..16f21caba2 100644 --- a/packages/hub/src/components/ui/Link.tsx +++ b/packages/hub/src/components/ui/Link.tsx @@ -1,7 +1,10 @@ import NextLink, { LinkProps } from "next/link"; import { forwardRef, useMemo } from "react"; -import { useInterceptLink, useIsIntercepting } from "../NavigationBlocker"; +import { + useInterceptLink, + useIsIntercepting, +} from "../NavigationBlocker/hooks"; // Link type, copy-pasted from next/link for reference: From 422f951a4971cf80737e49f432f3cb7e371cf893 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Fri, 12 Jul 2024 15:35:38 -0300 Subject: [PATCH 3/7] replace all Link components with improved version --- .../[slug]/variables/[variableName]/VariablePage.tsx | 2 +- packages/hub/src/components/GlobalSearch/SearchResult.tsx | 7 ++++--- .../hub/src/components/layout/RootLayout/PageFooter.tsx | 2 +- packages/hub/src/components/layout/RootLayout/PageMenu.tsx | 2 +- .../hub/src/components/layout/RootLayout/PageMenuLink.tsx | 2 +- .../hub/src/components/ui/DropdownMenuNextLinkItem.tsx | 3 ++- packages/hub/src/components/ui/EntityTab.tsx | 3 ++- packages/hub/src/components/ui/StyledDefinitionLink.tsx | 3 ++- packages/hub/src/components/ui/StyledTabLink.tsx | 3 ++- packages/hub/src/models/components/ModelCard.tsx | 2 +- packages/hub/src/variables/components/VariableCard.tsx | 2 +- 11 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/hub/src/app/models/[owner]/[slug]/variables/[variableName]/VariablePage.tsx b/packages/hub/src/app/models/[owner]/[slug]/variables/[variableName]/VariablePage.tsx index 53dcb989b2..474d63b4bf 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/variables/[variableName]/VariablePage.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/variables/[variableName]/VariablePage.tsx @@ -1,7 +1,6 @@ "use client"; import clsx from "clsx"; import { format } from "date-fns"; -import Link from "next/link"; import { FC, useState } from "react"; import { FaClock, FaMinusCircle } from "react-icons/fa"; import { graphql, usePaginationFragment } from "react-relay"; @@ -10,6 +9,7 @@ import { FragmentRefs } from "relay-runtime"; import { CheckIcon, XIcon } from "@quri/ui"; import { LoadMore } from "@/components/LoadMore"; +import { Link } from "@/components/ui/Link"; import { extractFromGraphqlErrorUnion } from "@/lib/graphqlHelpers"; import { exportTypeIcon } from "@/lib/typeIcon"; import { SerializablePreloadedQuery } from "@/relay/loadPageQuery"; diff --git a/packages/hub/src/components/GlobalSearch/SearchResult.tsx b/packages/hub/src/components/GlobalSearch/SearchResult.tsx index e847ea5feb..cb868623bd 100644 --- a/packages/hub/src/components/GlobalSearch/SearchResult.tsx +++ b/packages/hub/src/components/GlobalSearch/SearchResult.tsx @@ -1,14 +1,15 @@ -import Link from "next/link"; import { FC } from "react"; import { graphql, useFragment } from "react-relay"; import { components, type OptionProps } from "react-select"; -import { SearchResult$key } from "@/__generated__/SearchResult.graphql"; -import { SearchOption } from "."; +import { Link } from "../ui/Link"; +import { SearchOption } from "./"; import { SearchResultGroup } from "./SearchResultGroup"; import { SearchResultModel } from "./SearchResultModel"; import { SearchResultRelativeValuesDefinition } from "./SearchResultRelativeValuesDefinition"; import { SearchResultUser } from "./SearchResultUser"; + +import { SearchResult$key } from "@/__generated__/SearchResult.graphql"; import { SearchResultEdge$key } from "@/__generated__/SearchResultEdge.graphql"; export function useEdgeFragment(edgeFragment: SearchResultEdge$key) { diff --git a/packages/hub/src/components/layout/RootLayout/PageFooter.tsx b/packages/hub/src/components/layout/RootLayout/PageFooter.tsx index 8a7027a84b..3a804ceb46 100644 --- a/packages/hub/src/components/layout/RootLayout/PageFooter.tsx +++ b/packages/hub/src/components/layout/RootLayout/PageFooter.tsx @@ -1,10 +1,10 @@ "use client"; import Image from "next/image"; -import Link from "next/link"; import { FC } from "react"; import { FaDiscord, FaGithub, FaRss } from "react-icons/fa"; import { SiGraphql } from "react-icons/si"; +import { Link } from "@/components/ui/Link"; import { DISCORD_URL, GITHUB_URL, diff --git a/packages/hub/src/components/layout/RootLayout/PageMenu.tsx b/packages/hub/src/components/layout/RootLayout/PageMenu.tsx index 7271f4b886..080f9f2a21 100644 --- a/packages/hub/src/components/layout/RootLayout/PageMenu.tsx +++ b/packages/hub/src/components/layout/RootLayout/PageMenu.tsx @@ -1,5 +1,4 @@ import { signIn, useSession } from "next-auth/react"; -import Link from "next/link"; import { FC, useState } from "react"; import { useFragment } from "react-relay"; import { graphql } from "relay-runtime"; @@ -16,6 +15,7 @@ import { UserCircleIcon, } from "@quri/ui"; +import { Link } from "@/components/ui/Link"; import { useUsername } from "@/hooks/useUsername"; import { SQUIGGLE_DOCS_URL } from "@/lib/common"; import { aboutRoute, newModelRoute } from "@/routes"; diff --git a/packages/hub/src/components/layout/RootLayout/PageMenuLink.tsx b/packages/hub/src/components/layout/RootLayout/PageMenuLink.tsx index ce9ff17a78..be25ffea90 100644 --- a/packages/hub/src/components/layout/RootLayout/PageMenuLink.tsx +++ b/packages/hub/src/components/layout/RootLayout/PageMenuLink.tsx @@ -1,9 +1,9 @@ -import Link from "next/link"; import { FC } from "react"; import { EmptyIcon } from "@quri/ui"; import { DropdownMenuNextLinkItem } from "@/components/ui/DropdownMenuNextLinkItem"; +import { Link } from "@/components/ui/Link"; import { IconProps } from "@/relative-values/components/ui/icons/Icon"; export type MenuLinkModeProps = diff --git a/packages/hub/src/components/ui/DropdownMenuNextLinkItem.tsx b/packages/hub/src/components/ui/DropdownMenuNextLinkItem.tsx index 16bcc9b9f4..1159677967 100644 --- a/packages/hub/src/components/ui/DropdownMenuNextLinkItem.tsx +++ b/packages/hub/src/components/ui/DropdownMenuNextLinkItem.tsx @@ -1,8 +1,9 @@ -import Link from "next/link"; import { FC } from "react"; import { DropdownMenuItemLayout, IconProps } from "@quri/ui"; +import { Link } from "./Link"; + type Props = { href: string; title: string; diff --git a/packages/hub/src/components/ui/EntityTab.tsx b/packages/hub/src/components/ui/EntityTab.tsx index 6e6db67765..0f8bbbd8cd 100644 --- a/packages/hub/src/components/ui/EntityTab.tsx +++ b/packages/hub/src/components/ui/EntityTab.tsx @@ -1,10 +1,11 @@ import clsx from "clsx"; -import Link from "next/link"; import { usePathname } from "next/navigation"; import { FC, ReactNode } from "react"; import { IconProps } from "@quri/ui"; +import { Link } from "./Link"; + type TabDivProps = { name: string; count?: number; diff --git a/packages/hub/src/components/ui/StyledDefinitionLink.tsx b/packages/hub/src/components/ui/StyledDefinitionLink.tsx index da349c32d7..a3e65d7226 100644 --- a/packages/hub/src/components/ui/StyledDefinitionLink.tsx +++ b/packages/hub/src/components/ui/StyledDefinitionLink.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; -import Link from "next/link"; + +import { Link } from "./Link"; export const StyledDefinitionLink: React.FC< React.AnchorHTMLAttributes & { href: string } diff --git a/packages/hub/src/components/ui/StyledTabLink.tsx b/packages/hub/src/components/ui/StyledTabLink.tsx index 377c8df683..cb62edc1dd 100644 --- a/packages/hub/src/components/ui/StyledTabLink.tsx +++ b/packages/hub/src/components/ui/StyledTabLink.tsx @@ -1,9 +1,10 @@ -import Link from "next/link"; import { usePathname } from "next/navigation"; import React, { FC, ReactNode } from "react"; import { IconProps, StyledTab } from "@quri/ui"; +import { Link } from "./Link"; + type StyledTabLinkProps = { name: string; href: string; diff --git a/packages/hub/src/models/components/ModelCard.tsx b/packages/hub/src/models/components/ModelCard.tsx index a041cd9c2c..6589749860 100644 --- a/packages/hub/src/models/components/ModelCard.tsx +++ b/packages/hub/src/models/components/ModelCard.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import { FC } from "react"; import { useFragment } from "react-relay"; import { graphql } from "relay-runtime"; @@ -13,6 +12,7 @@ import { PrivateBadge, UpdatedStatus, } from "@/components/EntityCard"; +import { Link } from "@/components/ui/Link"; import { totalImportLength, VariableRevision, diff --git a/packages/hub/src/variables/components/VariableCard.tsx b/packages/hub/src/variables/components/VariableCard.tsx index 8c68399f19..e5b592db33 100644 --- a/packages/hub/src/variables/components/VariableCard.tsx +++ b/packages/hub/src/variables/components/VariableCard.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import { FC } from "react"; import { useFragment } from "react-relay"; import { graphql } from "relay-runtime"; @@ -14,6 +13,7 @@ import { PrivateBadge, UpdatedStatus, } from "@/components/EntityCard"; +import { Link } from "@/components/ui/Link"; import { exportTypeIcon } from "@/lib/typeIcon"; import { modelRoute, variableRoute } from "@/routes"; From 235f492bc20c38c323fbb642a9fc45f270d62178 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Fri, 12 Jul 2024 15:39:43 -0300 Subject: [PATCH 4/7] forbid next/link import --- packages/hub/.eslintrc.json | 11 ++++++++++- packages/hub/src/components/ui/Link.tsx | 8 +++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/hub/.eslintrc.json b/packages/hub/.eslintrc.json index bffb357a71..406564cbc6 100644 --- a/packages/hub/.eslintrc.json +++ b/packages/hub/.eslintrc.json @@ -1,3 +1,12 @@ { - "extends": "next/core-web-vitals" + "extends": "next/core-web-vitals", + "rules": { + "no-restricted-imports": [ + "error", + { + "name": "next/link", + "message": "Please use src/components/ui/Link.tsx instead." + } + ] + } } diff --git a/packages/hub/src/components/ui/Link.tsx b/packages/hub/src/components/ui/Link.tsx index 16f21caba2..2a53c7a9eb 100644 --- a/packages/hub/src/components/ui/Link.tsx +++ b/packages/hub/src/components/ui/Link.tsx @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import NextLink, { LinkProps } from "next/link"; import { forwardRef, useMemo } from "react"; @@ -6,14 +7,11 @@ import { useIsIntercepting, } from "../NavigationBlocker/hooks"; -// Link type, copy-pasted from next/link for reference: - -// declare const Link: React.ForwardRefExoticComponent, keyof InternalLinkProps> & InternalLinkProps & { -// children?: React.ReactNode; -// } & React.RefAttributes>; +// This component patches the original from next/link to intercept clicks and prevent navigation if the user is on the page that should be blocked with `NavigationBlocker`. export const Link = forwardRef< HTMLAnchorElement, + // type copy-pasted from next/link Omit, keyof LinkProps> & LinkProps & { children?: React.ReactNode; From 679d9c8c8b01a3f898b44380a06008f3f971593c Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Fri, 12 Jul 2024 16:04:11 -0300 Subject: [PATCH 5/7] "use client" for links --- packages/hub/src/components/ui/Link.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/hub/src/components/ui/Link.tsx b/packages/hub/src/components/ui/Link.tsx index 2a53c7a9eb..eabae781be 100644 --- a/packages/hub/src/components/ui/Link.tsx +++ b/packages/hub/src/components/ui/Link.tsx @@ -1,3 +1,4 @@ +"use client"; // eslint-disable-next-line no-restricted-imports import NextLink, { LinkProps } from "next/link"; import { forwardRef, useMemo } from "react"; From 3533a30ca7aa8111cf2bb8f942934999bf8a15cd Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Thu, 18 Jul 2024 15:18:22 -0300 Subject: [PATCH 6/7] rename ExitConfirmationWrapper and hooks --- .../[slug]/EditSquiggleSnippetModel.tsx | 6 +- .../ConfirmNavigationModal.tsx} | 12 +-- .../ExitConfirmationWrapper/context.ts | 75 +++++++++++++++++++ .../ExitConfirmationWrapper/hooks.ts | 54 +++++++++++++ .../ExitConfirmationWrapper/index.tsx | 28 +++++++ .../NavigationBlockerProvider.tsx | 25 ------- .../components/NavigationBlocker/context.ts | 69 ----------------- .../src/components/NavigationBlocker/hooks.ts | 54 ------------- packages/hub/src/components/ReactRoot.tsx | 6 +- packages/hub/src/components/ui/Link.tsx | 16 ++-- 10 files changed, 177 insertions(+), 168 deletions(-) rename packages/hub/src/components/{NavigationBlocker/InterceptedLinkModal.tsx => ExitConfirmationWrapper/ConfirmNavigationModal.tsx} (73%) create mode 100644 packages/hub/src/components/ExitConfirmationWrapper/context.ts create mode 100644 packages/hub/src/components/ExitConfirmationWrapper/hooks.ts create mode 100644 packages/hub/src/components/ExitConfirmationWrapper/index.tsx delete mode 100644 packages/hub/src/components/NavigationBlocker/NavigationBlockerProvider.tsx delete mode 100644 packages/hub/src/components/NavigationBlocker/context.ts delete mode 100644 packages/hub/src/components/NavigationBlocker/hooks.ts diff --git a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx index e818b8625b..e66b06c214 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx @@ -35,8 +35,8 @@ import { versionSupportsOnOpenExport, } from "@quri/versioned-squiggle-components"; +import { useExitConfirmation } from "@/components/ExitConfirmationWrapper/hooks"; import { EditRelativeValueExports } from "@/components/exports/EditRelativeValueExports"; -import { useBlockNavigation } from "@/components/NavigationBlocker/hooks"; import { ReactRoot } from "@/components/ReactRoot"; import { FormModal } from "@/components/ui/FormModal"; import { SAMPLE_COUNT_DEFAULT, XY_POINT_LENGTH_DEFAULT } from "@/constants"; @@ -278,8 +278,8 @@ export const EditSquiggleSnippetModel: FC = ({ } }; - // block navigation if code is edited - useBlockNavigation( + // confirm navigation if code is edited + useExitConfirmation( useCallback( () => form.getValues("code") !== content.code, [form, content.code] diff --git a/packages/hub/src/components/NavigationBlocker/InterceptedLinkModal.tsx b/packages/hub/src/components/ExitConfirmationWrapper/ConfirmNavigationModal.tsx similarity index 73% rename from packages/hub/src/components/NavigationBlocker/InterceptedLinkModal.tsx rename to packages/hub/src/components/ExitConfirmationWrapper/ConfirmNavigationModal.tsx index caf7be7b0b..bcef6bb5d1 100644 --- a/packages/hub/src/components/NavigationBlocker/InterceptedLinkModal.tsx +++ b/packages/hub/src/components/ExitConfirmationWrapper/ConfirmNavigationModal.tsx @@ -3,17 +3,17 @@ import { FC, use, useCallback } from "react"; import { Button, Modal } from "@quri/ui"; -import { NavigationBlockerContext } from "./context"; +import { ExitConfirmationWrapperContext } from "./context"; -export const InterceptedLinkModal: FC = () => { - const { state, dispatch } = use(NavigationBlockerContext); +export const ConfirmNavigationModal: FC = () => { + const { state, dispatch } = use(ExitConfirmationWrapperContext); const close = useCallback(() => { - dispatch({ type: "clearInterceptedLink" }); + dispatch({ type: "clearPendingLink" }); }, [dispatch]); const router = useRouter(); - if (!state.interceptedLink) { + if (!state.pendingLink) { return null; } @@ -28,7 +28,7 @@ export const InterceptedLinkModal: FC = () => {