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

fix: Enhance confirmation modal provider to offer customizable option… #1545

Merged
merged 1 commit into from
Oct 9, 2024
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
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
Loading