Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ConfirmDialog: ts unit test storybook #54954

Merged
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- `Modal`: fix closing when contained iframe is focused ([#51602](https://github.com/WordPress/gutenberg/pull/51602)).

### Internal

- `ConfirmDialog`: Migrate to TypeScript. ([#54954](https://github.com/WordPress/gutenberg/pull/54954)).

## 25.9.0 (2023-10-05)

### Enhancements
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/confirm-dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,4 @@ The optional custom text to display as the confirmation button's label
- Required: No
- Default: "Cancel"

The optional custom text to display as the cancelation button's label
The optional custom text to display as the cancellation button's label
92 changes: 79 additions & 13 deletions packages/components/src/confirm-dialog/component.tsx
margolisj marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
/**
* External dependencies
*/
import type { ForwardedRef, KeyboardEvent } from 'react';

/**
* WordPress dependencies
*/
Expand All @@ -13,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
* Internal dependencies
*/
import Modal from '../modal';
import type { OwnProps, DialogInputEvent } from './types';
import type { ConfirmDialogProps, DialogInputEvent } from './types';
import type { WordPressComponentProps } from '../context';
import { useContextSystem, contextConnect } from '../context';
import { Flex } from '../flex';
Expand All @@ -23,10 +18,10 @@ import { VStack } from '../v-stack';
import * as styles from './styles';
import { useCx } from '../utils/hooks/use-cx';

function ConfirmDialog(
props: WordPressComponentProps< OwnProps, 'div', false >,
forwardedRef: ForwardedRef< any >
margolisj marked this conversation as resolved.
Show resolved Hide resolved
) {
const UnconnectedConfirmDialog = (
props: WordPressComponentProps< ConfirmDialogProps, 'div', false >,
forwardedRef: React.ForwardedRef< any >
) => {
const {
isOpen: isOpenProp,
onConfirm,
Expand Down Expand Up @@ -67,7 +62,7 @@ function ConfirmDialog(
);

const handleEnter = useCallback(
( event: KeyboardEvent< HTMLDivElement > ) => {
( event: React.KeyboardEvent< HTMLDivElement > ) => {
// Avoid triggering the 'confirm' action when a button is focused,
// as this can cause a double submission.
const isConfirmOrCancelButton =
Expand Down Expand Up @@ -120,6 +115,77 @@ function ConfirmDialog(
) }
</>
);
}
};

export default contextConnect( ConfirmDialog, 'ConfirmDialog' );
/**
* `ConfirmDialog` is built of top of [`Modal`](/packages/components/src/modal/README.md)
* and displays a confirmation dialog, with _confirm_ and _cancel_ buttons.
* The dialog is confirmed by clicking the _confirm_ button or by pressing the `Enter` key.
* It is cancelled (closed) by clicking the _cancel_ button, by pressing the `ESC` key, or by
* clicking outside the dialog focus (i.e, the overlay).
*
* `ConfirmDialog` has two main implicit modes: controlled and uncontrolled.
*
* UnControlled:
*
* Allows the component to be used standalone, just by declaring it as part of another React's component render method:
* - It will be automatically open (displayed) upon mounting;
* - It will be automatically closed when clicking the _cancel_ button, by pressing the `ESC` key, or by clicking outside the dialog focus (i.e, the overlay);
* - `onCancel` is not mandatory but can be passed. Even if passed, the dialog will still be able to close itself.
*
* Activating this mode is as simple as omitting the `isOpen` prop. The only mandatory prop, in this case, is the `onConfirm` callback. The message is passed as the `children`. You can pass any JSX you'd like, which allows to further format the message or include sub-component if you'd like:
*
* ```jsx
* import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components';
*
* function Example() {
* return (
* <ConfirmDialog onConfirm={ () => console.debug( ' Confirmed! ' ) }>
* Are you sure? <strong>This action cannot be undone!</strong>
* </ConfirmDialog>
* );
* }
* ```
*
*
* Controlled mode:
* Let the parent component control when the dialog is open/closed. It's activated when a
* boolean value is passed to `isOpen`:
* - It will not be automatically closed. You need to let it know when to open/close by updating the value of the `isOpen` prop;
* - Both `onConfirm` and the `onCancel` callbacks are mandatory props in this mode;
* - You'll want to update the state that controls `isOpen` by updating it from the `onCancel` and `onConfirm` callbacks.
*
*```jsx
* import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
* function Example() {
* const [ isOpen, setIsOpen ] = useState( true );
*
* const handleConfirm = () => {
* console.debug( 'Confirmed!' );
* setIsOpen( false );
* };
*
* const handleCancel = () => {
* console.debug( 'Cancelled!' );
* setIsOpen( false );
* };
*
* return (
* <ConfirmDialog
* isOpen={ isOpen }
* onConfirm={ handleConfirm }
* onCancel={ handleCancel }
* >
* Are you sure? <strong>This action cannot be undone!</strong>
* </ConfirmDialog>
* );
* }
* ```
*/
export const ConfirmDialog = contextConnect(
UnconnectedConfirmDialog,
'ConfirmDialog'
);
export default ConfirmDialog;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Storybook examples are still not displaying correctly all of the inferred type information — we need to:

  • update the meta object, adding the correct configuration for generating controls and removing the hardcoded ones
  • export the ConfirmDialog component as a named export in component.tsx and use the name export in Storybook (there is abug that prevents storybook from generating controls from default exports)

In the process, we can also:

  • tweak the args configuration, so that it follows the same way we've been configuring it in other stories
  • tweak the code so that it doesn't need to import types explicitly
Here is a diff with all those changes applied
diff --git a/packages/components/src/confirm-dialog/component.tsx b/packages/components/src/confirm-dialog/component.tsx
index ff46d77c05..750e7030de 100644
--- a/packages/components/src/confirm-dialog/component.tsx
+++ b/packages/components/src/confirm-dialog/component.tsx
@@ -184,8 +184,8 @@ const UnconnectedConfirmDialog = (
  * }
  * ```
  */
-const ConnectedConfirmDialog = contextConnect(
+export const ConfirmDialog = contextConnect(
 	UnconnectedConfirmDialog,
 	'ConfirmDialog'
 );
-export default ConnectedConfirmDialog;
+export default ConfirmDialog;
diff --git a/packages/components/src/confirm-dialog/stories/index.story.tsx b/packages/components/src/confirm-dialog/stories/index.story.tsx
index 3eb72b2829..85636c0ddc 100644
--- a/packages/components/src/confirm-dialog/stories/index.story.tsx
+++ b/packages/components/src/confirm-dialog/stories/index.story.tsx
@@ -12,32 +12,21 @@ import { useState } from '@wordpress/element';
  * Internal dependencies
  */
 import Button from '../../button';
-import { ConfirmDialog } from '..';
-import type { ConfirmDialogProps, DialogInputEvent } from '../types';
+import { ConfirmDialog } from '../component';
 
 const meta: Meta< typeof ConfirmDialog > = {
 	component: ConfirmDialog,
 	title: 'Components (Experimental)/ConfirmDialog',
 	argTypes: {
-		children: {
-			control: { type: 'text' },
-		},
-		confirmButtonText: {
-			control: { type: 'text' },
-		},
-		cancelButtonText: {
-			control: { type: 'text' },
-		},
 		isOpen: {
 			control: { type: null },
 		},
-		onConfirm: { action: 'onConfirm' },
-		onCancel: { action: 'onCancel' },
-	},
-	args: {
-		children: 'Would you like to privately publish the post now?',
 	},
 	parameters: {
+		actions: { argTypesRegex: '^on.*' },
+		controls: {
+			expanded: true,
+		},
 		docs: { canvas: { sourceState: 'shown' } },
 	},
 };
@@ -48,15 +37,15 @@ const Template: StoryFn< typeof ConfirmDialog > = ( {
 	onConfirm,
 	onCancel,
 	...args
-}: ConfirmDialogProps ) => {
+} ) => {
 	const [ isOpen, setIsOpen ] = useState( false );
 
-	const handleConfirm = ( confirmArgs: DialogInputEvent ) => {
+	const handleConfirm: typeof onConfirm = ( confirmArgs ) => {
 		onConfirm( confirmArgs );
 		setIsOpen( false );
 	};
 
-	const handleCancel = ( cancelArgs: DialogInputEvent ) => {
+	const handleCancel: typeof onCancel = ( cancelArgs ) => {
 		onCancel?.( cancelArgs );
 		setIsOpen( false );
 	};
@@ -80,7 +69,7 @@ const Template: StoryFn< typeof ConfirmDialog > = ( {
 };
 
 // Simplest usage: just declare the component with the required `onConfirm` prop. Note: the `onCancel` prop is optional here, unless you'd like to render the component in Controlled mode (see below)
-export const _default = Template.bind( {} );
+export const Default = Template.bind( {} );
 const _defaultSnippet = `() => {
   const [ isOpen, setIsOpen ] = useState( false );
   const [ confirmVal, setConfirmVal ] = useState('');
@@ -113,8 +102,10 @@ const _defaultSnippet = `() => {
     </>
   );
 };`;
-_default.args = {};
-_default.parameters = {
+Default.args = {
+	children: 'Would you like to privately publish the post now?',
+};
+Default.parameters = {
 	docs: {
 		source: {
 			code: _defaultSnippet,
@@ -127,6 +118,7 @@ _default.parameters = {
 // To customize button text, pass the `cancelButtonText` and/or `confirmButtonText` props.
 export const WithCustomButtonLabels = Template.bind( {} );
 WithCustomButtonLabels.args = {
+	...Default.args,
 	cancelButtonText: 'No thanks',
 	confirmButtonText: 'Yes please!',
 };

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully I got that copied right. Really should just look up how to apply a git patch haha. Lmk if there's anything else.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After copying the contents of the patch, on MacOS I can do that by executing pbpaste | git apply

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully I got that copied right

Almost

Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';

/**
* WordPress dependencies
*/
Expand All @@ -7,47 +12,41 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import Button from '../../button';
import { ConfirmDialog } from '..';
import { ConfirmDialog } from '../component';

const meta = {
const meta: Meta< typeof ConfirmDialog > = {
component: ConfirmDialog,
title: 'Components (Experimental)/ConfirmDialog',
argTypes: {
children: {
control: { type: 'text' },
},
confirmButtonText: {
control: { type: 'text' },
},
cancelButtonText: {
control: { type: 'text' },
},
isOpen: {
control: { type: null },
},
onConfirm: { action: 'onConfirm' },
onCancel: { action: 'onCancel' },
},
args: {
children: 'Would you like to privately publish the post now?',
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: {
expanded: true,
},
docs: { canvas: { sourceState: 'shown' } },
},
};

export default meta;

const Template = ( { onConfirm, onCancel, ...args } ) => {
const Template: StoryFn< typeof ConfirmDialog > = ( {
onConfirm,
onCancel,
...args
} ) => {
const [ isOpen, setIsOpen ] = useState( false );

const handleConfirm = ( ...confirmArgs ) => {
onConfirm( ...confirmArgs );
const handleConfirm: typeof onConfirm = ( confirmArgs ) => {
onConfirm( confirmArgs );
setIsOpen( false );
};

const handleCancel = ( ...cancelArgs ) => {
onCancel( ...cancelArgs );
const handleCancel: typeof onCancel = ( cancelArgs ) => {
onCancel?.( cancelArgs );
setIsOpen( false );
};

Expand All @@ -70,7 +69,7 @@ const Template = ( { onConfirm, onCancel, ...args } ) => {
};

// Simplest usage: just declare the component with the required `onConfirm` prop. Note: the `onCancel` prop is optional here, unless you'd like to render the component in Controlled mode (see below)
export const _default = Template.bind( {} );
export const Default = Template.bind( {} );
const _defaultSnippet = `() => {
const [ isOpen, setIsOpen ] = useState( false );
const [ confirmVal, setConfirmVal ] = useState('');
Expand Down Expand Up @@ -103,8 +102,10 @@ const _defaultSnippet = `() => {
</>
);
};`;
_default.args = {};
_default.parameters = {
Default.args = {
children: 'Would you like to privately publish the post now?',
};
Default.parameters = {
docs: {
source: {
code: _defaultSnippet,
Expand All @@ -117,6 +118,7 @@ _default.parameters = {
// To customize button text, pass the `cancelButtonText` and/or `confirmButtonText` props.
export const WithCustomButtonLabels = Template.bind( {} );
WithCustomButtonLabels.args = {
...Default.args,
cancelButtonText: 'No thanks',
confirmButtonText: 'Yes please!',
};
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe( 'Confirm', () => {
expect( onCancel ).toHaveBeenCalled();
} );

it( 'should be dismissable even if an `onCancel` callback is not provided', async () => {
it( 'should be dismissible even if an `onCancel` callback is not provided', async () => {
const user = userEvent.setup();

render(
Expand Down Expand Up @@ -144,7 +144,7 @@ describe( 'Confirm', () => {

// Disable reason: Semantic queries can’t reach the overlay.
// eslint-disable-next-line testing-library/no-node-access
await user.click( confirmDialog.parentElement );
await user.click( confirmDialog.parentElement! );

expect( confirmDialog ).not.toBeInTheDocument();
expect( onCancel ).toHaveBeenCalled();
Expand Down Expand Up @@ -325,7 +325,7 @@ describe( 'Confirm', () => {

// Disable reason: Semantic queries can’t reach the overlay.
// eslint-disable-next-line testing-library/no-node-access
await user.click( confirmDialog.parentElement );
await user.click( confirmDialog.parentElement! );

expect( onCancel ).toHaveBeenCalled();
} );
Expand Down
44 changes: 32 additions & 12 deletions packages/components/src/confirm-dialog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,41 @@ export type DialogInputEvent =
| KeyboardEvent< HTMLDivElement >
| MouseEvent< HTMLButtonElement >;

type BaseProps = {
export type ConfirmDialogProps = {
/**
* The actual message for the dialog. It's passed as children and any valid `ReactNode` is accepted.
*/
children: ReactNode;
/**
* The callback that's called when the user confirms.
* A confirmation can happen when the `OK` button is clicked or when `Enter` is pressed.
*/
onConfirm: ( event: DialogInputEvent ) => void;
/**
* The optional custom text to display as the confirmation button's label.
*/
confirmButtonText?: string;
/**
* The optional custom text to display as the cancellation button's label.
*/
cancelButtonText?: string;
};

type ControlledProps = BaseProps & {
onCancel: ( event: DialogInputEvent ) => void;
isOpen: boolean;
};

type UncontrolledProps = BaseProps & {
/**
* The callback that's called when the user cancels. A cancellation can happen
* when the `Cancel` button is clicked, when the `ESC` key is pressed, or when
* a click outside of the dialog focus is detected (i.e. in the overlay).
*
* It's not required if `isOpen` is not set (uncontrolled mode), as the component
* will take care of closing itself, but you can still pass a callback if something
* must be done upon cancelling (the component will still close itself in this case).
*
* If `isOpen` is set (controlled mode), then it's required, and you need to set
* the state that defines `isOpen` to `false` as part of this callback if you want the
* dialog to close when the user cancels.
*/
onCancel?: ( event: DialogInputEvent ) => void;
isOpen?: never;
/**
* Defines if the dialog is open (displayed) or closed (not rendered/displayed).
* It also implicitly toggles the controlled mode if set or the uncontrolled mode if it's not set.
*/
isOpen?: boolean;
};

export type OwnProps = ControlledProps | UncontrolledProps;