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/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx index cab2fbbdaf..e66b06c214 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"; @@ -28,6 +35,7 @@ import { versionSupportsOnOpenExport, } from "@quri/versioned-squiggle-components"; +import { useExitConfirmation } from "@/components/ExitConfirmationWrapper/hooks"; import { EditRelativeValueExports } from "@/components/exports/EditRelativeValueExports"; import { ReactRoot } from "@/components/ReactRoot"; import { FormModal } from "@/components/ui/FormModal"; @@ -270,6 +278,14 @@ export const EditSquiggleSnippetModel: FC = ({ } }; + // confirm navigation if code is edited + useExitConfirmation( + 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/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/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/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/ExitConfirmationWrapper/ConfirmNavigationModal.tsx b/packages/hub/src/components/ExitConfirmationWrapper/ConfirmNavigationModal.tsx new file mode 100644 index 0000000000..bcef6bb5d1 --- /dev/null +++ b/packages/hub/src/components/ExitConfirmationWrapper/ConfirmNavigationModal.tsx @@ -0,0 +1,42 @@ +import { useRouter } from "next/navigation"; +import { FC, use, useCallback } from "react"; + +import { Button, Modal } from "@quri/ui"; + +import { ExitConfirmationWrapperContext } from "./context"; + +export const ConfirmNavigationModal: FC = () => { + const { state, dispatch } = use(ExitConfirmationWrapperContext); + const close = useCallback(() => { + dispatch({ type: "clearPendingLink" }); + }, [dispatch]); + + const router = useRouter(); + + if (!state.pendingLink) { + 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/ExitConfirmationWrapper/context.ts b/packages/hub/src/components/ExitConfirmationWrapper/context.ts new file mode 100644 index 0000000000..9e8d31a7b3 --- /dev/null +++ b/packages/hub/src/components/ExitConfirmationWrapper/context.ts @@ -0,0 +1,70 @@ +import { createContext, Reducer } from "react"; + +type State = { + // Checks are functions; this way we can check dynamic parameters such as + // form values to decide whether navigation should be intercepted. + // If any of the checks returns a true value, the exit confirmation will be shown. + checks: Record boolean>; + // Link was intercepted, need to show a modal. + pendingLink: string | undefined; +}; + +type Action = + | { + type: "addExitConfirmationCheck"; + payload: { + key: string; + check: () => boolean; + }; + } + | { + type: "removeExitConfirmationCheck"; + payload: { + key: string; + }; + } + | { + type: "intercept"; + payload: { + link: string; + }; + } + | { + type: "clearPendingLink"; + }; + +export const exitConfirmationReducer: Reducer = ( + state, + action +) => { + switch (action.type) { + case "addExitConfirmationCheck": + return { + ...state, + checks: { + ...state.checks, + [action.payload.key]: action.payload.check, + }, + }; + case "removeExitConfirmationCheck": + const { [action.payload.key]: _, ...blockers } = state.checks; + return { ...state, checks: blockers }; + case "intercept": + return { ...state, pendingLink: action.payload.link }; + case "clearPendingLink": + return { ...state, pendingLink: undefined }; + default: + return state; + } +}; + +export const ExitConfirmationWrapperContext = createContext<{ + state: State; + dispatch: (action: Action) => void; +}>({ + state: { + checks: {}, + pendingLink: undefined, + }, + dispatch: () => {}, +}); diff --git a/packages/hub/src/components/ExitConfirmationWrapper/hooks.ts b/packages/hub/src/components/ExitConfirmationWrapper/hooks.ts new file mode 100644 index 0000000000..11fde6b2fa --- /dev/null +++ b/packages/hub/src/components/ExitConfirmationWrapper/hooks.ts @@ -0,0 +1,57 @@ +import { use, useCallback, useEffect, useId } from "react"; + +import { ExitConfirmationWrapperContext } from "./context"; + +/* + * Be very careful with this hook! `check` parameter, if set, must have stable identity. + * + * If it's an inline function, it will cause an infinite render loop: + * - any change in the check function will cause all consumers of this hook to re-render + * - this will cause the check function to re-create, causing another re-render. + */ +export function useExitConfirmation(check: () => boolean = () => true) { + const { dispatch } = use(ExitConfirmationWrapperContext); + + const key = useId(); + + useEffect(() => { + dispatch({ + type: "addExitConfirmationCheck", + payload: { + key, + check, + }, + }); + + const listener = (e: BeforeUnloadEvent) => { + if (check()) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", listener); + + return () => { + window.removeEventListener("beforeunload", listener); + dispatch({ + type: "removeExitConfirmationCheck", + payload: { key }, + }); + }; + }, [dispatch, key, check]); +} + +// set `pendingLink`, show a modal +export function useConfirmNavigation() { + const { dispatch } = use(ExitConfirmationWrapperContext); + + return useCallback( + (link: string) => dispatch({ type: "intercept", payload: { link } }), + [dispatch] + ); +} + +// used by `Link` component to detect if exit confirmation is active +export function useIsExitConfirmationActive() { + const { checks: blockers } = use(ExitConfirmationWrapperContext).state; + return () => Object.values(blockers).some((check) => check()); +} diff --git a/packages/hub/src/components/ExitConfirmationWrapper/index.tsx b/packages/hub/src/components/ExitConfirmationWrapper/index.tsx new file mode 100644 index 0000000000..295dbfc95d --- /dev/null +++ b/packages/hub/src/components/ExitConfirmationWrapper/index.tsx @@ -0,0 +1,43 @@ +import { FC, PropsWithChildren, useReducer } from "react"; + +import { ConfirmNavigationModal } from "./ConfirmNavigationModal"; +import { + exitConfirmationReducer, + ExitConfirmationWrapperContext, +} from "./context"; + +/* + * This component wraps the application and provides the context for exit + * confirmation when some component in the tree below requires it. + * + * There are two types of exits: + * 1. Navigation to another page. + * 2. Closing the tab. + * + * To enable exit confirmation, use the `useExitConfirmation` hook. + * + * It will set up the `beforeunload` event listener and register the check. + * + * On navigation, `` component will check if there are any active checks, + * and activate the confirmation modal if necessary. + */ +export const ExitConfirmationWrapper: FC = ({ + children, +}) => { + const [state, dispatch] = useReducer(exitConfirmationReducer, { + checks: {}, + pendingLink: undefined, + }); + + return ( + + + {children} + + ); +}; 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/ReactRoot.tsx b/packages/hub/src/components/ReactRoot.tsx index 2da58e883b..6349357d53 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 { ExitConfirmationWrapper } from "./ExitConfirmationWrapper"; + // 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/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/Link.tsx b/packages/hub/src/components/ui/Link.tsx new file mode 100644 index 0000000000..81056e575e --- /dev/null +++ b/packages/hub/src/components/ui/Link.tsx @@ -0,0 +1,35 @@ +"use client"; +// eslint-disable-next-line no-restricted-imports +import NextLink, { LinkProps } from "next/link"; +import { forwardRef, useMemo } from "react"; + +import { + useConfirmNavigation, + useIsExitConfirmationActive, +} from "../ExitConfirmationWrapper/hooks"; + +// 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; + } +>(function Link({ onClick, ...props }, ref) { + const isConfirmationActive = useIsExitConfirmationActive(); + const confirmNavigation = useConfirmNavigation(); + + const patchedOnClick = useMemo(() => { + return (e: React.MouseEvent) => { + if (!isConfirmationActive()) { + return onClick?.(e); + } + confirmNavigation(e.currentTarget.href); + e.preventDefault(); + }; + }, [onClick, isConfirmationActive, confirmNavigation]); + + return ; +}); 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/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 } 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";