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 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
75 changes: 75 additions & 0 deletions assets/blocks/editor-components/confirm-dialog/confirm-dialog.js
@@ -0,0 +1,75 @@
/**
* 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
fjorgemota marked this conversation as resolved.
Show resolved Hide resolved
* @param {string} props.title Title for the confirm dialog. Default is window.location.host value.
* @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.
*/
const ConfirmDialog = ( {
isOpen = false,
title = window.location.host,
renatho marked this conversation as resolved.
Show resolved Hide resolved
children,
onConfirm,
onCancel,
} ) => {
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 }>
{ __( 'Cancel', 'sensei-lms' ) }
</Button>
<Button variant="primary" onClick={ onConfirm }>
{ __( 'OK', 'sensei-lms' ) }
</Button>
</div>
</Modal>
);
};

/**
* Calls onConfirm when registerListener is true and the user press ENTER.
*
* @param {boolean} registerListener If the listener should be set up or not.
renatho marked this conversation as resolved.
Show resolved Hide resolved
* @param {Function} fn The callback to call when the user press ENTER, if registerListener is true.
*/
const useConfirmOnEnter = ( registerListener, fn ) => {
useEffect( () => {
if ( ! registerListener ) {
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 );
}, [ registerListener, fn ] );
};

export default ConfirmDialog;
@@ -0,0 +1,9 @@
.sensei-confirm-dialog {
&__message {
margin: 1em;
}
&__button-container {
display: flex;
justify-content: end;
}
}
@@ -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
@@ -0,0 +1,5 @@
/**
* Internal dependencies
*/
export { default as ConfirmDialog } from './confirm-dialog';
export { default as useConfirmDialogProps } from './use-confirm-dialog-props';
@@ -0,0 +1,34 @@
/**
* 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 } );
const confirm = ( text, title ) => {
return new Promise( ( resolve ) => {
const callback = ( value ) => () => {
resolve( value );
setProps( { isOpen: false } );
};
setProps( {
isOpen: true,
children: text,
title,
onConfirm: callback( true ),
onCancel: callback( false ),
} );
} );
};
return [ props, confirm ];
};

export default useConfirmDialogProps;
@@ -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', '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', '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', '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
@@ -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;
}
}
}