Skip to content

Commit

Permalink
fix: Enhance confirmation modal provider to offer customizable option…
Browse files Browse the repository at this point in the history
…s/buttons

Fixes: #1537
  • Loading branch information
igarashitm committed Oct 9, 2024
1 parent f95f50e commit 8e975a0
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ exports[`ActionConfirmationModalProvider should allow consumers to update the mo
data-ouia-component-id="OUIA-Generated-Button-danger-3"
data-ouia-component-type="PF5/Button"
data-ouia-safe="true"
data-testid="action-confirmation-modal-btn-1"
type="button"
>
Confirm
Expand All @@ -100,6 +101,7 @@ exports[`ActionConfirmationModalProvider should allow consumers to update the mo
data-ouia-component-id="OUIA-Generated-Button-link-3"
data-ouia-component-type="PF5/Button"
data-ouia-safe="true"
data-testid="action-confirmation-modal-btn-0"
type="button"
>
Cancel
Expand Down
92 changes: 69 additions & 23 deletions packages/ui/src/providers/action-confirmation-modal.provider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import { Button, Modal, ModalVariant } from '@patternfly/react-core';
import { Button, ButtonVariant, Modal, ModalVariant } from '@patternfly/react-core';
import { FunctionComponent, PropsWithChildren, createContext, useCallback, useMemo, useRef, useState } from 'react';

export const ACTION_INDEX_CANCEL = 0;
export const ACTION_INDEX_CONFIRM = 1;
export interface ActionConfirmationButtonOption {
index: number;
buttonText: string;
variant: ButtonVariant;
isDanger?: boolean;
}

interface ActionConfirmationModalContextValue {
actionConfirmation: (options: { title?: string; text?: string }) => Promise<boolean>;
actionConfirmation: (options: {
title?: string;
text?: string;
buttonOptions?: ActionConfirmationButtonOption[];
additionalModalText?: string;
}) => Promise<number>;
}

export const ActionConfirmationModalContext = createContext<ActionConfirmationModalContextValue | undefined>(undefined);
Expand All @@ -14,35 +28,52 @@ export const ActionConfirmationModalContext = createContext<ActionConfirmationMo
export const ActionConfirmationModalContextProvider: FunctionComponent<PropsWithChildren> = (props) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [title, setTitle] = useState('');
const [text, setText] = useState('');

const [textParagraphs, setTextParagraphs] = useState<string[]>([]);
const [buttonOptions, setButtonOptions] = useState<ActionConfirmationButtonOption[]>([]);
const actionConfirmationRef = useRef<{
resolve: (confirm: boolean) => void;
resolve: (index: number) => void;
reject: (error: unknown) => unknown;
}>();

const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
actionConfirmationRef.current?.resolve(false);
actionConfirmationRef.current?.resolve(ACTION_INDEX_CANCEL);
}, []);

const handleActionConfirm = useCallback(() => {
const handleAction = useCallback((index: number) => {
setIsModalOpen(false);
actionConfirmationRef.current?.resolve(true);
actionConfirmationRef.current?.resolve(index);
}, []);

const actionConfirmation = useCallback((options: { title?: string; text?: string } = {}) => {
const actionConfirmationPromise = new Promise<boolean>((resolve, reject) => {
/** Set both resolve and reject functions to be used once the user choose an action */
actionConfirmationRef.current = { resolve, reject };
});
const actionConfirmation = useCallback(
(
options: {
title?: string;
text?: string;
additionalModalText?: string;
buttonOptions?: ActionConfirmationButtonOption[];
} = {},
) => {
const actionConfirmationPromise = new Promise<number>((resolve, reject) => {
/** Set both resolve and reject functions to be used once the user choose an action */
actionConfirmationRef.current = { resolve, reject };
});

setTitle(options.title ?? 'Delete?');
setText(options.text ?? 'Are you sure you want to delete?');
setIsModalOpen(true);
setTitle(options.title ?? 'Delete?');
const textParagraphs = [options.text ?? 'Are you sure you want to delete?'];
if (options.additionalModalText) {
textParagraphs.push(options.additionalModalText);
}
setTextParagraphs(textParagraphs);
options.buttonOptions
? setButtonOptions(options.buttonOptions)
: setButtonOptions([{ index: ACTION_INDEX_CONFIRM, buttonText: 'Confirm', variant: ButtonVariant.danger }]);
setIsModalOpen(true);

return actionConfirmationPromise;
}, []);
return actionConfirmationPromise;
},
[],
);

const value: ActionConfirmationModalContextValue = useMemo(
() => ({
Expand All @@ -64,15 +95,30 @@ export const ActionConfirmationModalContextProvider: FunctionComponent<PropsWith
onClose={handleCloseModal}
ouiaId="ActionConfirmationModal"
actions={[
<Button key="confirm" variant="danger" onClick={handleActionConfirm}>
Confirm
</Button>,
<Button key="cancel" variant="link" onClick={handleCloseModal}>
...buttonOptions.map((op) => (
<Button
key={op.index}
variant={op.variant}
onClick={() => handleAction(op.index)}
data-testid={`action-confirmation-modal-btn-${op.index}`}
isDanger={op.isDanger}
>
{op.buttonText}
</Button>
)),
<Button
key="cancel"
variant="link"
onClick={handleCloseModal}
data-testid={`action-confirmation-modal-btn-${ACTION_INDEX_CANCEL}`}
>
Cancel
</Button>,
]}
>
{text}
{textParagraphs.length === 1
? textParagraphs[0]
: textParagraphs.map((paragraph, index) => <p key={index}>{paragraph}</p>)}
</Modal>
)}
</ActionConfirmationModalContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { FunctionComponent, useContext } from 'react';
import {
ActionConfirmationButtonOption,
ActionConfirmationModalContext,
ActionConfirmationModalContextProvider,
} from './action-confirmation-modal.provider';
import { ButtonVariant } from '@patternfly/react-core';

let actionConfirmationResult: boolean | undefined;
let actionConfirmationResult: number | undefined;

describe('ActionConfirmationModalProvider', () => {
beforeEach(() => {
Expand All @@ -26,7 +28,7 @@ describe('ActionConfirmationModalProvider', () => {
fireEvent.click(confirmButton);

// Wait for actionConfirmation promise to resolve
await waitFor(() => expect(actionConfirmationResult).toEqual(true));
await waitFor(() => expect(actionConfirmationResult).toEqual(1));
});

it('calls actionConfirmation with false when Cancel button is clicked', async () => {
Expand All @@ -43,7 +45,7 @@ describe('ActionConfirmationModalProvider', () => {
fireEvent.click(cancelButton);

// Wait for actionConfirmation promise to resolve
await waitFor(() => expect(actionConfirmationResult).toEqual(false));
await waitFor(() => expect(actionConfirmationResult).toEqual(0));
});

it('should allow consumers to update the modal title and text', () => {
Expand All @@ -64,11 +66,79 @@ describe('ActionConfirmationModalProvider', () => {
expect(wrapper.queryByText('Custom title')).toBeInTheDocument;
expect(wrapper.queryByText('Custom text')).toBeInTheDocument;
});

it('should show 3 options to choose', async () => {
const wrapper = render(
<ActionConfirmationModalContextProvider>
<TestComponent
title="Custom title"
text="Custom text"
additionalModalText="Additional text is added in the modal description"
buttonOptions={[
{
index: 1,
buttonText: 'Delete the step, and delete the file(s)',
variant: ButtonVariant.danger,
},
{
index: 2,
buttonText: 'Delete the step, but keep the file(s)',
variant: ButtonVariant.secondary,
isDanger: true,
},
]}
/>
</ActionConfirmationModalContextProvider>,
);

act(() => {
const deleteButton = wrapper.getByText('Delete');
fireEvent.click(deleteButton);
});
const modalDialog = wrapper.getByRole('dialog');
expect(modalDialog.textContent).toContain('Additional text is added in the modal description');
act(() => {
const cancelButton = wrapper.getByTestId('action-confirmation-modal-btn-0');
expect(cancelButton.textContent).toEqual('Cancel');
fireEvent.click(cancelButton);
});
await waitFor(() => {
expect(actionConfirmationResult).toEqual(0);
});

act(() => {
const deleteButton = wrapper.getByText('Delete');
fireEvent.click(deleteButton);
});
act(() => {
const deleteStepAndFileButton = wrapper.getByTestId('action-confirmation-modal-btn-1');
expect(deleteStepAndFileButton.textContent).toEqual('Delete the step, and delete the file(s)');
fireEvent.click(deleteStepAndFileButton);
});
await waitFor(() => {
expect(actionConfirmationResult).toEqual(1);
});

act(() => {
const deleteButton = wrapper.getByText('Delete');
fireEvent.click(deleteButton);
});
act(() => {
const deleteStepOnlyButton = wrapper.getByTestId('action-confirmation-modal-btn-2');
expect(deleteStepOnlyButton.textContent).toEqual('Delete the step, but keep the file(s)');
fireEvent.click(deleteStepOnlyButton);
});
await waitFor(() => {
expect(actionConfirmationResult).toEqual(2);
});
});
});

interface TestComponentProps {
title: string;
text: string;
additionalModalText?: string;
buttonOptions?: ActionConfirmationButtonOption[];
}

const TestComponent: FunctionComponent<TestComponentProps> = (props) => {
Expand Down

0 comments on commit 8e975a0

Please sign in to comment.