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

Add Confirm Dialog component #5361

Merged
merged 15 commits into from
Jul 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions assets/blocks/editor-components/confirm-dialog/confirm-dialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* WordPress dependencies
*/
import { Modal, Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useEffect } from '@wordpress/element';
import { ENTER } from '@wordpress/keycodes';

/**
* Controlled Component that shows a modal containing a confirm dialog. Inspired by Gutenberg's experimental
* Confirm Dialog.
*
* @see https://developer.wordpress.org/block-editor/reference-guides/components/confirm-dialog/
* @param {Object} props Component props.
* @param {boolean} props.isOpen Determines if the confirm dialog is open or not.
* @param {string} props.title Title for the confirm dialog.
* @param {string} props.children Content for the confirm dialog, can be any React component.
* @param {Function} props.onConfirm Callback called when the user click on "OK" or press Enter with the modal open.
* @param {Function} props.onCancel Callback called when the user click on "Cancel" or press ESC with the modal open.
* @param {string} props.confirmButtonText Optional custom text to display as the confirmation button's label.
* @param {string} props.cancelButtonText Optional custom text to display as the cancellation button's label.
*/
const ConfirmDialog = ( {
isOpen = false,
title,
children,
onConfirm,
onCancel,
cancelButtonText = __( 'Cancel', 'sensei-lms' ),
confirmButtonText = __( 'OK', 'sensei-lms' ),
} ) => {
useConfirmOnEnter( isOpen, onConfirm );
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice code separation here! 😀

if ( ! isOpen ) {
return null;
}
return (
<Modal
title={ title }
onRequestClose={ onCancel }
shouldCloseOnClickOutside={ false }
className="sensei-confirm-dialog"
>
<div className="sensei-confirm-dialog__message">{ children }</div>
<div className="sensei-confirm-dialog__button-container">
<Button
variant="tertiary"
onClick={ onCancel }
className="sensei-confirm-dialog__button"
>
{ cancelButtonText }
</Button>
<Button
variant="primary"
onClick={ onConfirm }
className="sensei-confirm-dialog__button"
>
{ confirmButtonText }
</Button>
</div>
</Modal>
);
};

/**
* Calls onConfirm when registerListener is true and the user press ENTER.
*
* @param {boolean} shouldRegisterListener If the listener should be set up or not.
* @param {Function} fn The callback to call when the user press ENTER, if registerListener is true.
*/
const useConfirmOnEnter = ( shouldRegisterListener, fn ) => {
useEffect( () => {
if ( ! shouldRegisterListener ) {
return;
}
const callback = ( event ) => {
if ( event.keyCode === ENTER && ! event.defaultPrevented ) {
event.preventDefault();
fn();
}
};
document.body.addEventListener( 'keydown', callback, false );
return () =>
document.body.removeEventListener( 'keydown', callback, false );
}, [ shouldRegisterListener, fn ] );
};

export default ConfirmDialog;
12 changes: 12 additions & 0 deletions assets/blocks/editor-components/confirm-dialog/confirm-dialog.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.sensei-confirm-dialog {
&__message {
margin: 1em;
}
&__button-container {
display: flex;
justify-content: end;
}
&__button {
margin-left: 8px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* WordPress dependencies
*/
import { ENTER } from '@wordpress/keycodes';

/**
* External dependencies
*/
import { render, fireEvent } from '@testing-library/react';

/**
* Internal dependencies
*/
import ConfirmDialog from './confirm-dialog';

describe( '<ConfirmDialog />', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Great tests!

it( 'Should not render if the dialog is not open', () => {
const { queryByText } = render(
<ConfirmDialog isOpen={ false } title="Hey Title">
Hey Content
</ConfirmDialog>
);

expect( queryByText( 'Hey Title' ) ).toBeFalsy();
expect( queryByText( 'Hey Content' ) ).toBeFalsy();
} );

it( 'Should render modal if the dialog is open', () => {
const { queryByText } = render(
<ConfirmDialog isOpen={ true } title="Hey Title">
Hey Content
</ConfirmDialog>
);

expect( queryByText( 'Hey Title' ) ).toBeTruthy();
expect( queryByText( 'Hey Content' ) ).toBeTruthy();
} );

it( 'Should cancel the modal if click on Cancel button', () => {
const onCancel = jest.fn();
const { queryByText } = render(
<ConfirmDialog
isOpen={ true }
title="Hey Title"
onCancel={ onCancel }
>
Hey Content
</ConfirmDialog>
);

expect( onCancel ).not.toHaveBeenCalled();
fireEvent.click( queryByText( 'Cancel' ) );
expect( onCancel ).toHaveBeenCalled();
} );

it( 'Should confirm the modal if click on OK button', () => {
const onConfirm = jest.fn();
const { queryByText } = render(
<ConfirmDialog
isOpen={ true }
title="Hey Title"
onConfirm={ onConfirm }
>
Hey Content
</ConfirmDialog>
);

expect( onConfirm ).not.toHaveBeenCalled();
fireEvent.click( queryByText( 'OK' ) );
expect( onConfirm ).toHaveBeenCalled();
} );

it( 'Should confirm the modal if the user press ENTER', () => {
const onConfirm = jest.fn();
render(
<ConfirmDialog
isOpen={ true }
title="Hey Title"
onConfirm={ onConfirm }
>
Hey Content
</ConfirmDialog>
);

expect( onConfirm ).not.toHaveBeenCalled();
fireEvent.keyDown( document.body, {
key: 'Enter',
code: 'Enter',
keyCode: ENTER,
} );
expect( onConfirm ).toHaveBeenCalled();
} );
} );
5 changes: 5 additions & 0 deletions assets/blocks/editor-components/confirm-dialog/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Internal dependencies
*/
export { default as ConfirmDialog } from './confirm-dialog';
export { default as useConfirmDialogProps } from './use-confirm-dialog-props';
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';

/**
* Hook that returns the props for the component ConfirmDialog, with an additional async function that mimics the
* synchronous native confirm() API. Loosely inspired by react-confirm HOC.
*
* @see https://github.com/haradakunihiko/react-confirm
* @return {Array} The first item is the props to pass to ConfirmDialog, the second one is the async function to call to
* trigger ConfirmDialog.
*/
export const useConfirmDialogProps = () => {
const [ props, setProps ] = useState( { isOpen: false } );
/**
* Shows the ConfirmDialog component and returns a boolean with the result asynchronously.
*
* @param {string} text Text of the Confirm Dialog.
* @param {Object} newProps Additional properties to use on the ConfirmDialog.
* @param {string} newProps.title Title of the Confirm Dialog.
* @param {string} newProps.cancelButtonText Text of the Cancel button on the Confirm Dialog.
* @param {string} newProps.okButtonText Text of the Ok button on the Confirm Dialog.
* @return {Promise<boolean>} true if the user clicked the OK button or pressed Enter. false if the user clicked the
* Cancel button or pressed ESC.
*/
const confirm = ( text, newProps = {} ) => {
return new Promise( ( resolve ) => {
const callback = ( value ) => () => {
resolve( value );
setProps( { isOpen: false } );
};
setProps( {
...newProps,
isOpen: true,
children: text,
onConfirm: callback( true ),
onCancel: callback( false ),
} );
} );
};
return [ props, confirm ];
};

export default useConfirmDialogProps;
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { renderHook, act } from '@testing-library/react-hooks';

/**
* Internal dependencies
*/
import useConfirmDialogProps from './use-confirm-dialog-props';

describe( 'useConfirmDialogProps()', () => {
it( 'Should return isOpen as false by default', () => {
const { result } = renderHook( () => useConfirmDialogProps() );
const [ props ] = result.current;
expect( props.isOpen ).toBe( false );
} );

it( 'Should set Confirm Dialog props when calling confirm', () => {
const { result } = renderHook( () => useConfirmDialogProps() );
let [ props, confirm ] = result.current;
expect( props.isOpen ).toBe( false );
act( () => {
confirm( 'Hey Content', { title: 'Hey Title' } );
} );
[ props, confirm ] = result.current;
expect( props.isOpen ).toBe( true );
expect( props.title ).toBe( 'Hey Title' );
expect( props.children ).toBe( 'Hey Content' );
expect( props.onConfirm ).toBeInstanceOf( Function );
expect( props.onCancel ).toBeInstanceOf( Function );
} );

it( 'confirm should return true when onConfirm is called', async () => {
const { result } = renderHook( () => useConfirmDialogProps() );
let [ props, confirm ] = result.current;
expect( props.isOpen ).toBe( false );
const confirmResponse = act( () =>
expect(
confirm( 'Hey Content', { title: 'Hey Title' } )
).resolves.toBe( true )
);
[ props, confirm ] = result.current;
expect( props.isOpen ).toBe( true );
act( () => props.onConfirm() );
// We need to verify AFTER calling the props.on* callback, otherwise, the promise won't be resolved yet.
await confirmResponse;
[ props, confirm ] = result.current;
expect( props.isOpen ).toBe( false );
} );

it( 'confirm should return false when onCancel is called', async () => {
const { result } = renderHook( () => useConfirmDialogProps() );
let [ props, confirm ] = result.current;
expect( props.isOpen ).toBe( false );
const confirmResponse = act( () =>
expect(
confirm( 'Hey Content', { title: 'Hey Title' } )
).resolves.toBe( false )
);
[ props, confirm ] = result.current;
expect( props.isOpen ).toBe( true );
act( () => props.onCancel() );
// We need to verify AFTER calling the props.on* callback, otherwise, the promise won't be resolved yet.
await confirmResponse;
[ props, confirm ] = result.current;
expect( props.isOpen ).toBe( false );
} );
} );
3 changes: 2 additions & 1 deletion assets/blocks/editor-components/editor-components-style.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@import './confirm-dialog/confirm-dialog';
@import './input-control/input-control';
@import './number-control/number-control';
@import './toolbar-dropdown/toolbar-dropdown';
Expand All @@ -9,4 +10,4 @@
h3 {
margin: 0;
}
}
}