From 0b314382a211f967abe4291be65b05ade9f39f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Wed, 5 Feb 2025 16:52:11 +0700 Subject: [PATCH] Always resolve the promise with confirmation status and close reason --- README.md | 86 +++++++++-------- src/ConfirmProvider.js | 52 ++++++++--- src/index.d.ts | 12 ++- stories/index.stories.js | 31 +------ test/useConfirm.test.js | 195 +++++++++++++++++++++++++++------------ 5 files changed, 233 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index 05e7f2c..d01e76c 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,17 @@ import { useConfirm } from "material-ui-confirm"; const Item = () => { const confirm = useConfirm(); - const handleClick = () => { - confirm({ description: "This action is permanent!" }) - .then(() => { - /* ... */ - }) - .catch(() => { - /* ... */ - }); + const handleClick = async () => { + const { confirmed, reason } = await confirm({ + description: "This action is permanent!", + }); + + if (confirmed) { + /* ... */ + } + + console.log(reason); + //=> "confirm" | "cancel" | "natural" | "unmount" }; return ; @@ -66,42 +69,43 @@ This component is required in order to render a dialog in the component tree. ##### Props -| Name | Type | Default | Description | -| -------------------- | -------- | ------- | ----------------------------------------------------------------------- | -| **`defaultOptions`** | `object` | `{}` | Overrides the default options used by [`confirm`](#useconfirm-confirm). | +| Name | Type | Default | Description | +| --------------------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`defaultOptions`** | `object` | `{}` | Overrides the default options used by [`confirm`](#useconfirm-confirm). | +| **`useLegacyReturn`** | `boolean` | `false` | When set to `true`, restores the `confirm` behaviour from v3: the returned promise is resolved on confirm, rejected on cancel, and kept pending on natural close. | #### `useConfirm() => confirm` This hook returns the `confirm` function. -#### `confirm([options]) => Promise` +#### `confirm([options]) => Promise<{ confirmed: boolean; reason: "confirm" | "cancel" | "natural" | "unmount"; }>` This function opens a confirmation dialog and returns a promise representing the user choice (resolved on confirmation and rejected on cancellation). ##### Options -| Name | Type | Default | Description | -| --------------------------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`title`** | `ReactNode` | `'Are you sure?'` | Dialog title. | -| **`description`** | `ReactNode` | `''` | Dialog content, automatically wrapped in `DialogContentText`. | -| **`content`** | `ReactNode` | `null` | Dialog content, same as `description` but not wrapped in `DialogContentText`. Supersedes `description` if present. | -| **`confirmationText`** | `ReactNode` | `'Ok'` | Confirmation button caption. | -| **`cancellationText`** | `ReactNode` | `'Cancel'` | Cancellation button caption. | -| **`dialogProps`** | `object` | `{}` | Material-UI [Dialog](https://mui.com/material-ui/api/dialog/#props) props. | -| **`dialogActionsProps`** | `object` | `{}` | Material-UI [DialogActions](https://mui.com/material-ui/api/dialog-actions/#props) props. | -| **`confirmationButtonProps`** | `object` | `{}` | Material-UI [Button](https://mui.com/material-ui/api/button/#props) props for the confirmation button. | -| **`cancellationButtonProps`** | `object` | `{}` | Material-UI [Button](https://mui.com/material-ui/api/dialog/#props) props for the cancellation button. | -| **`titleProps`** | `object` | `{}` | Material-UI [DialogTitle](https://mui.com/api/dialog-title/#props) props for the dialog title. | -| **`contentProps`** | `object` | `{}` | Material-UI [DialogContent](https://mui.com/api/dialog-content/#props) props for the dialog content. | -| **`allowClose`** | `boolean` | `true` | Whether natural close (escape or backdrop click) should close the dialog. When set to `false` force the user to either cancel or confirm explicitly. | -| **`confirmationKeyword`** | `string` | `undefined` | If provided the confirmation button will be disabled by default and an additional textfield will be rendered. The confirmation button will only be enabled when the contents of the textfield match the value of `confirmationKeyword` | -| **`confirmationKeywordTextFieldProps`** | `object` | `{}` | Material-UI [TextField](https://mui.com/material-ui/api/text-field/) props for the confirmation keyword textfield. | -| **`acknowledgement`** | `string` | `undefined` | If provided shows the acknowledge checkbox with this string as checkbox label and disables the confirm button while the checkbox is unchecked. | -| **`acknowledgementFormControlLabelProps`** | `object` | `{}` | Material-UI [FormControlLabel](https://mui.com/material-ui/api/form-control-label/#props) props for the form control label. | -| **`acknowledgementCheckboxProps`** | `object` | `{}` | Material-UI [Checkbox](https://mui.com/material-ui/api/checkbox/#props) props for the acknowledge checkbox. | -| **`hideCancelButton`** | `boolean` | `false` | Whether to hide the cancel button. | -| **`buttonOrder`** | `string[]` | `["cancel", "confirm"]` | Specify the order of confirm and cancel buttons. | +| Name | Type | Default | Description | +| ------------------------------------------ | ----------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`title`** | `ReactNode` | `'Are you sure?'` | Dialog title. | +| **`description`** | `ReactNode` | `''` | Dialog content, automatically wrapped in `DialogContentText`. | +| **`content`** | `ReactNode` | `null` | Dialog content, same as `description` but not wrapped in `DialogContentText`. Supersedes `description` if present. | +| **`confirmationText`** | `ReactNode` | `'Ok'` | Confirmation button caption. | +| **`cancellationText`** | `ReactNode` | `'Cancel'` | Cancellation button caption. | +| **`dialogProps`** | `object` | `{}` | Material-UI [Dialog](https://mui.com/material-ui/api/dialog/#props) props. | +| **`dialogActionsProps`** | `object` | `{}` | Material-UI [DialogActions](https://mui.com/material-ui/api/dialog-actions/#props) props. | +| **`confirmationButtonProps`** | `object` | `{}` | Material-UI [Button](https://mui.com/material-ui/api/button/#props) props for the confirmation button. | +| **`cancellationButtonProps`** | `object` | `{}` | Material-UI [Button](https://mui.com/material-ui/api/dialog/#props) props for the cancellation button. | +| **`titleProps`** | `object` | `{}` | Material-UI [DialogTitle](https://mui.com/api/dialog-title/#props) props for the dialog title. | +| **`contentProps`** | `object` | `{}` | Material-UI [DialogContent](https://mui.com/api/dialog-content/#props) props for the dialog content. | +| **`allowClose`** | `boolean` | `true` | Whether natural close (escape or backdrop click) should close the dialog. When set to `false` force the user to either cancel or confirm explicitly. | +| **`confirmationKeyword`** | `string` | `undefined` | If provided the confirmation button will be disabled by default and an additional textfield will be rendered. The confirmation button will only be enabled when the contents of the textfield match the value of `confirmationKeyword` | +| **`confirmationKeywordTextFieldProps`** | `object` | `{}` | Material-UI [TextField](https://mui.com/material-ui/api/text-field/) props for the confirmation keyword textfield. | +| **`acknowledgement`** | `string` | `undefined` | If provided shows the acknowledge checkbox with this string as checkbox label and disables the confirm button while the checkbox is unchecked. | +| **`acknowledgementFormControlLabelProps`** | `object` | `{}` | Material-UI [FormControlLabel](https://mui.com/material-ui/api/form-control-label/#props) props for the form control label. | +| **`acknowledgementCheckboxProps`** | `object` | `{}` | Material-UI [Checkbox](https://mui.com/material-ui/api/checkbox/#props) props for the acknowledge checkbox. | +| **`hideCancelButton`** | `boolean` | `false` | Whether to hide the cancel button. | +| **`buttonOrder`** | `string[]` | `["cancel", "confirm"]` | Specify the order of confirm and cancel buttons. | ## Useful notes @@ -117,14 +121,14 @@ naturally triggers a click. const MyComponent = () => { // ... - const handleClick = () => { - confirm({ confirmationButtonProps: { autoFocus: true } }) - .then(() => { - /* ... */ - }) - .catch(() => { - /* ... */ - }); + const handleClick = async () => { + const { confirmed } = await confirm({ + confirmationButtonProps: { autoFocus: true }, + }); + + if (confirmed) { + /* ... */ + } }; // ... diff --git a/src/ConfirmProvider.js b/src/ConfirmProvider.js index 57bec57..94d9b6e 100644 --- a/src/ConfirmProvider.js +++ b/src/ConfirmProvider.js @@ -85,7 +85,11 @@ const buildOptions = (defaultOptions, options) => { let confirmGlobal; -const ConfirmProvider = ({ children, defaultOptions = {} }) => { +const ConfirmProvider = ({ + children, + defaultOptions = {}, + useLegacyReturn = false, +}) => { // State that we clear on close (to avoid dangling references to resolve and // reject). If this is null, the dialog is closed. const [state, setState] = useState(null); @@ -94,17 +98,38 @@ const ConfirmProvider = ({ children, defaultOptions = {} }) => { const [options, setOptions] = useState({}); const [key, setKey] = useState(0); - const confirmBase = useCallback((parentId, options = {}) => { - return new Promise((resolve, reject) => { - setKey((key) => key + 1); - setOptions(options); - setState({ resolve, reject, parentId }); - }); - }, []); + const confirmBase = useCallback( + (parentId, options = {}) => { + const promise = new Promise((resolve, _reject) => { + setKey((key) => key + 1); + setOptions(options); + setState({ resolve, parentId }); + }); + + // Converts the promise into the legacy promise from v3 + if (useLegacyReturn) { + return new Promise((resolve, reject) => { + promise.then(({ confirmed, reason }) => { + if (confirmed === true && reason === "confirm") { + resolve(); + } + + if (confirmed === false && reason === "cancel") { + reject(); + } + }); + }); + } + + return promise; + }, + [useLegacyReturn], + ); const closeOnParentUnmount = useCallback((parentId) => { setState((state) => { if (state && state.parentId === parentId) { + state && state.resolve({ confirmed: false, reason: "unmount" }); return null; } else { return state; @@ -113,26 +138,29 @@ const ConfirmProvider = ({ children, defaultOptions = {} }) => { }, []); const handleClose = useCallback(() => { - setState(null); + setState((state) => { + state && state.resolve({ confirmed: false, reason: "natural" }); + return null; + }); }, []); const handleCancel = useCallback(() => { setState((state) => { - state && state.reject(); + state && state.resolve({ confirmed: false, reason: "cancel" }); return null; }); }, []); const handleConfirm = useCallback(() => { setState((state) => { - state && state.resolve(); + state && state.resolve({ confirmed: true, reason: "confirm" }); return null; }); }, []); confirmGlobal = useCallback((options) => { return confirmBase("global", options); - }); + }, []); return ( diff --git a/src/index.d.ts b/src/index.d.ts index f2e4b4a..1d883db 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -32,9 +32,17 @@ export interface ConfirmOptions { export interface ConfirmProviderProps { children: React.ReactNode; defaultOptions?: ConfirmOptions; + useLegacyReturn?: boolean; +} + +export interface ConfirmResult { + confirmed: boolean; + reason: "confirm" | "cancel" | "natural" | "unmount"; } export const ConfirmProvider: React.ComponentType; -export const useConfirm: () => (options?: ConfirmOptions) => Promise; -export const confirm: (options?: ConfirmOptions) => Promise; +export const useConfirm: () => ( + options?: ConfirmOptions, +) => Promise; +export const confirm: (options?: ConfirmOptions) => Promise; diff --git a/stories/index.stories.js b/stories/index.stories.js index a54d899..fd3e72f 100644 --- a/stories/index.stories.js +++ b/stories/index.stories.js @@ -7,15 +7,14 @@ import Tooltip from "@mui/material/Tooltip"; import { ConfirmProvider, useConfirm } from "../src/index"; import { confirm as staticConfirm } from "../src/index"; -const confirmationAction = action("confirmed"); -const cancellationAction = action("cancelled"); +const closeAction = action("closed"); const ConfirmationDialog = (options) => { const confirm = useConfirm(); return ( + ); }, }; @@ -75,28 +72,6 @@ export const WithCustomButtonProps = { }, }; -// You can't just inline this into the render proeprty, it needs to be a JSX -// component to pick up the context -function CustomCallbacksComponent() { - const confirm = useConfirm(); - return ( - - ); -} - -export const WithCustomCallbacks = { - render: () => , -}; - export const WithCustomElements = { args: { title: ( diff --git a/test/useConfirm.test.js b/test/useConfirm.test.js index 97508bd..ebf5013 100644 --- a/test/useConfirm.test.js +++ b/test/useConfirm.test.js @@ -9,8 +9,8 @@ import { import { ConfirmProvider, useConfirm } from "../src/index"; describe("useConfirm", () => { - const deleteConfirmed = jest.fn(); - const deleteCancelled = jest.fn(); + const thenCallback = jest.fn(); + const catchCallback = jest.fn(); const DeleteButton = ({ confirmOptions, text = "Delete" }) => { const confirm = useConfirm(); @@ -18,7 +18,7 @@ describe("useConfirm", () => { return ( + + ); + }; + + const { getByText, queryByText } = render(); + + fireEvent.click(getByText("Delete")); + expect(queryByText("Are you sure?")).toBeTruthy(); + + // Remove from the tree + fireEvent.click(getByText("Unmount child")); + + await waitForElementToBeRemoved(() => queryByText("Are you sure?")); + + expect(thenCallback).toHaveBeenCalledWith({ + confirmed: false, + reason: "unmount", + }); + }); + + test("does not close the modal when another component with useConfirm is unmounted", async () => { + const ParentComponent = ({}) => { + const [alive, setAlive] = useState(true); + + return ( + + {alive && } + + + + ); + }; + + const { getByText, queryByText } = render(); + + fireEvent.click(getByText("Delete 2")); + expect(queryByText("Are you sure?")).toBeTruthy(); + + // Remove the first from the tree + fireEvent.click(getByText("Unmount child")); + + fireEvent.click(getByText("Ok")); + await waitForElementToBeRemoved(() => queryByText("Are you sure?")); + expect(thenCallback).toHaveBeenCalledWith({ + confirmed: true, + reason: "confirm", + }); }); describe("options", () => { @@ -381,68 +455,69 @@ describe("useConfirm", () => { const wrapperStyles = window.getComputedStyle(checkboxWrapper); expect(wrapperStyles.marginRight).toBe("15px"); }); + }); - test("closes the modal when the opening component is unmounted", async () => { - const ParentComponent = ({}) => { - const [alive, setAlive] = useState(true); - - return ( - - {alive && } - - - ); - }; + describe("missing ConfirmProvider", () => { + test("throws an error when ConfirmProvider is missing", () => { + const { result } = renderHook(() => useConfirm()); + expect(() => result.current()).toThrowError("Missing ConfirmProvider"); + }); - const { getByText, queryByText } = render(); + test("does not throw an error if it's not used", () => { + expect(() => render()).not.toThrow(); + }); + }); + describe("legacy return", () => { + test("resolves the promise on confirm", async () => { + const { getByText, queryByText } = render( + , + ); + expect(queryByText("Are you sure?")).toBeFalsy(); fireEvent.click(getByText("Delete")); expect(queryByText("Are you sure?")).toBeTruthy(); - - // Remove from the tree - fireEvent.click(getByText("Unmount child")); - + fireEvent.click(getByText("Ok")); await waitForElementToBeRemoved(() => queryByText("Are you sure?")); - - expect(deleteConfirmed).not.toHaveBeenCalled(); - expect(deleteCancelled).not.toHaveBeenCalled(); + expect(thenCallback).toHaveBeenCalled(); + expect(catchCallback).not.toHaveBeenCalled(); }); - test("does not close the modal when another component with useConfirm is unmounted", async () => { - const ParentComponent = ({}) => { - const [alive, setAlive] = useState(true); - - return ( - - {alive && } - - - - ); - }; - - const { getByText, queryByText } = render(); - - fireEvent.click(getByText("Delete 2")); + test("rejects the promise on cancel", async () => { + const { getByText, queryByText } = render( + , + ); + expect(queryByText("Are you sure?")).toBeFalsy(); + fireEvent.click(getByText("Delete")); expect(queryByText("Are you sure?")).toBeTruthy(); - - // Remove the first from the tree - fireEvent.click(getByText("Unmount child")); - - fireEvent.click(getByText("Ok")); + fireEvent.click(getByText("Cancel")); await waitForElementToBeRemoved(() => queryByText("Are you sure?")); - expect(deleteConfirmed).toHaveBeenCalled(); - }); - }); - - describe("missing ConfirmProvider", () => { - test("throws an error when ConfirmProvider is missing", () => { - const { result } = renderHook(() => useConfirm()); - expect(() => result.current()).toThrowError("Missing ConfirmProvider"); + expect(thenCallback).not.toHaveBeenCalled(); + expect(catchCallback).toHaveBeenCalled(); }); - test("does not throw an error if it's not used", () => { - expect(() => render()).not.toThrow(); + test("keeps the promise pending on natural close", async () => { + const { getByText, queryByText } = render( + , + ); + expect(queryByText("Are you sure?")).toBeFalsy(); + fireEvent.click(getByText("Delete")); + expect(queryByText("Are you sure?")).toBeTruthy(); + fireEvent.keyDown(queryByText("Are you sure?"), { key: "Escape" }); + await waitForElementToBeRemoved(() => queryByText("Are you sure?")); + expect(thenCallback).not.toHaveBeenCalled(); + expect(catchCallback).not.toHaveBeenCalled(); }); }); });