Skip to content

Commit

Permalink
Merge pull request #3330 from quantified-uncertainty/controlled-links
Browse files Browse the repository at this point in the history
Draft improvements
  • Loading branch information
berekuk authored Jul 19, 2024
2 parents 17f7a5c + c40adcc commit b0bdab8
Show file tree
Hide file tree
Showing 23 changed files with 329 additions and 46 deletions.
11 changes: 10 additions & 1 deletion packages/hub/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -270,6 +278,14 @@ export const EditSquiggleSnippetModel: FC<Props> = ({
}
};

// 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -27,6 +27,10 @@ function localStorageExists() {
return Boolean(typeof window !== "undefined" && window.localStorage);
}

const Hint: FC<PropsWithChildren> = ({ children }) => (
<div className="text-sm text-slate-700">{children}</div>
);

const CopyToClipboardButton: FC<{ draftLocator: DraftLocator }> = ({
draftLocator,
}) => {
Expand Down Expand Up @@ -168,29 +172,25 @@ export const SquiggleSnippetDraftDialog: FC<Props> = ({
<Modal.Header>Unsaved Draft</Modal.Header>
<Modal.Body>You have an unsaved draft for this model.</Modal.Body>
<Modal.Footer>
<div className="flex items-center justify-end gap-2">
<TextTooltip text="Draft will be ignored but you'll see this prompt again on next load.">
<div>
<Button onClick={skip}>Ignore</Button>
</div>
</TextTooltip>
<TextTooltip text="Draft will be discarded.">
<div>
<Button onClick={discard}>Discard</Button>
</div>
</TextTooltip>
<TextTooltip text="Draft will be copied to clipboard.">
<div>
<CopyToClipboardButton draftLocator={draftLocator} />
</div>
</TextTooltip>
<TextTooltip text="Code and version will be replaced by draft version. You'll still need to save it manually.">
<div>
<Button theme="primary" onClick={_restore}>
Restore
</Button>
</div>
</TextTooltip>
<div className="grid grid-cols-[minmax(180px,max-content),1fr] items-center gap-4 p-2">
<Button onClick={skip}>Ignore</Button>
<Hint>
{
"Draft will be ignored but you'll see this prompt again on next load."
}
</Hint>
<Button onClick={discard}>Discard</Button>
<Hint>Draft will be discarded and forgotten.</Hint>
<CopyToClipboardButton draftLocator={draftLocator} />
<Hint>Draft will be copied to clipboard.</Hint>
<Button theme="primary" onClick={_restore}>
Restore
</Button>
<Hint>
{
"Code and version will be replaced by draft version. You'll still need to save it manually."
}
</Hint>
</div>
</Modal.Footer>
</Modal>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion packages/hub/src/components/EntityCard.tsx
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down
3 changes: 2 additions & 1 deletion packages/hub/src/components/EntityInfo.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Modal close={close}>
<Modal.Header>You have unsaved changes</Modal.Header>
<Modal.Body>
Are you sure you want to leave this page? You changes are not saved.
</Modal.Body>
<Modal.Footer>
<div className="flex justify-end gap-2">
<Button onClick={() => close()}>Stay on this page</Button>
<Button
onClick={() => {
router.push(state.pendingLink!);
close();
}}
theme="primary"
>
Continue
</Button>
</div>
</Modal.Footer>
</Modal>
);
};
70 changes: 70 additions & 0 deletions packages/hub/src/components/ExitConfirmationWrapper/context.ts
Original file line number Diff line number Diff line change
@@ -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<string, () => 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> = (
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: () => {},
});
57 changes: 57 additions & 0 deletions packages/hub/src/components/ExitConfirmationWrapper/hooks.ts
Original file line number Diff line number Diff line change
@@ -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());
}
43 changes: 43 additions & 0 deletions packages/hub/src/components/ExitConfirmationWrapper/index.tsx
Original file line number Diff line number Diff line change
@@ -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, `<Link>` component will check if there are any active checks,
* and activate the confirmation modal if necessary.
*/
export const ExitConfirmationWrapper: FC<PropsWithChildren> = ({
children,
}) => {
const [state, dispatch] = useReducer(exitConfirmationReducer, {
checks: {},
pendingLink: undefined,
});

return (
<ExitConfirmationWrapperContext.Provider
value={{
state,
dispatch,
}}
>
<ConfirmNavigationModal />
{children}
</ExitConfirmationWrapperContext.Provider>
);
};
7 changes: 4 additions & 3 deletions packages/hub/src/components/GlobalSearch/SearchResult.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Loading

0 comments on commit b0bdab8

Please sign in to comment.