Skip to content
Closed
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
106 changes: 106 additions & 0 deletions src/generic/TypeXToConfirmModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { fireEvent, render, screen } from '@testing-library/react';

import TypeXToConfirmModal from './TypeXToConfirmModal';

const defaultProps = () => ({
label: 'Delete item',
bodyText: 'Dangerous action',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
X: 'DELETE',
context: { id: 7 },
isOpen: true,
onConfirm: jest.fn(),
onCancel: jest.fn(),
setContext: jest.fn(),
});

const renderModal = (props = defaultProps()) =>
render(
<IntlProvider locale="en" messages={{}}>
<TypeXToConfirmModal {...props} />
</IntlProvider>,
);

describe('TypeXToConfirmModal', () => {
it('keeps the destructive confirm button disabled until the typed value exactly matches the required confirmation phrase', () => {
renderModal();

const input = screen.getByRole('textbox');
const confirmButton = screen.getByRole('button', { name: 'Delete' });

expect(confirmButton).toBeDisabled();
fireEvent.change(input, { target: { value: 'DEL' } });
expect(confirmButton).toBeDisabled();
fireEvent.change(input, { target: { value: 'DELETE' } });
expect(confirmButton).toBeEnabled();
});

it('does not enable confirmation for partial, differently cased, or whitespace-padded confirmation text', () => {
renderModal();

const input = screen.getByRole('textbox');
const confirmButton = screen.getByRole('button', { name: 'Delete' });

['DEL', 'delete', ' DELETE', 'DELETE ', ' Delete '].forEach(value => {
fireEvent.change(input, { target: { value } });
expect(confirmButton).toBeDisabled();
});
});

it('submits on Enter only after the exact confirmation phrase has been entered', async () => {
const user = userEvent.setup();
const props = defaultProps();
renderModal(props);

const input = screen.getByRole('textbox');

await user.click(input);
await user.keyboard('{Enter}');
expect(props.onConfirm).not.toHaveBeenCalled();

await user.type(input, 'DELETE');
await user.keyboard('{Enter}');
expect(props.onConfirm).toHaveBeenCalledWith(props.context);
});

it('resets confirmation state when the modal closes so a reopened dialog starts disabled again', async () => {
const user = userEvent.setup();
const props = defaultProps();
const { rerender } = renderModal(props);

await user.type(screen.getByRole('textbox'), 'DELETE');
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled();

rerender(
<IntlProvider locale="en" messages={{}}>
<TypeXToConfirmModal {...props} isOpen={false} />
</IntlProvider>,
);

rerender(
<IntlProvider locale="en" messages={{}}>
<TypeXToConfirmModal {...props} isOpen />
</IntlProvider>,
);

expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
});

it('clears the provided context when the modal is closed without confirming', () => {
const props = defaultProps();
const { rerender } = renderModal(props);

rerender(
<IntlProvider locale="en" messages={{}}>
<TypeXToConfirmModal {...props} isOpen={false} />
</IntlProvider>,
);

expect(props.setContext).toHaveBeenCalledWith(null);
expect(props.onConfirm).not.toHaveBeenCalled();
});
});
125 changes: 125 additions & 0 deletions src/generic/TypeXToConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import React, { useEffect } from 'react';
import { Button, Card, Form, Icon, ModalDialog } from '@openedx/paragon';
import { WarningFilled } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';

interface TypeXToConfirmModalProps {
label: string;
bodyText: string | React.ReactNode;
confirmLabel: string;
cancelLabel: string;
X: string;
// any additional context that the caller wants to pass to the onConfirm callback; not a React context.
context?: Record<string, any> | null;
isOpen: boolean;
onConfirm: (context?: Record<string, any> | null) => void;
onCancel: () => void;
setContext?: (context: Record<string, any> | null) => void;
}

const TypeXToConfirmModal: React.FC<TypeXToConfirmModalProps> = ({
label,
X,
bodyText,
confirmLabel,
cancelLabel,
isOpen,
context,
onConfirm,
onCancel,
setContext,
}) => {
const [confirmedByTyping, setConfirmedByTyping] = React.useState(false);
const intl = useIntl();

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!confirmedByTyping) { return; }
if (e.key === 'Enter') {
onConfirm(context);
}
};

const handleConfirm = () => {
if (!confirmedByTyping) { return; }
setConfirmedByTyping(false);
onConfirm(context);
};

const handleCancel = () => {
setConfirmedByTyping(false);
onCancel();
};

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === X) {
setConfirmedByTyping(true);
} else {
setConfirmedByTyping(false);
}
};

// Don't remove. This is necessary to prevent an old state from erroneously enabling the confirm button
useEffect(() => {
if (!isOpen) {
setConfirmedByTyping(false);
if (setContext) {
// reset onConfirm callback context when modal is closed
setContext(null);
}
}
}, [X, isOpen, context, setContext]);

return (
<ModalDialog
title={label}
isOpen={isOpen}
onClose={handleCancel}
isOverflowVisible
>
<ModalDialog.Header>
<ModalDialog.Title>{label}</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<Card className="bg-warning-100">
<Card.Section>
<div className="d-flex align-items-start mb-2">
<Icon src={WarningFilled} className="text-warning-500 mr-2" />
<div className="small">{bodyText}</div>
</div>
</Card.Section>
</Card>
<div className="mt-3">
<div>
{(() => {
const messageText = intl.formatMessage(messages.typeToConfirmInstruction, { X });
const parts = messageText.split(X);
return (
<>
{parts[0]}
<strong>{X}</strong>
{parts[1]}
</>
);
})()}
</div>
<Form.Control
onKeyDown={handleKeyDown}
onChange={handleChange}
className="mt-4"
/>
</div>
<ModalDialog.Footer>
<Button variant="tertiary" onClick={handleCancel}>
{cancelLabel}
</Button>
<Button onClick={handleConfirm} disabled={!confirmedByTyping} variant="danger">
{confirmLabel}
</Button>
</ModalDialog.Footer>
</ModalDialog.Body>
</ModalDialog>
);
};

export default React.memo(TypeXToConfirmModal);
10 changes: 10 additions & 0 deletions src/generic/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

const messages = defineMessages({
typeToConfirmInstruction: {
id: 'course-authoring.generic.type-to-confirm-instruction',
defaultMessage: 'Type {X} to confirm',
},
});

export default messages;
1 change: 1 addition & 0 deletions src/taxonomy/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const apiUrls = {
tagsPlanImport: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/import/plan/`),
createTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
updateTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
deleteTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
} satisfies Record<string, (...args: any[]) => string>;

/**
Expand Down
20 changes: 20 additions & 0 deletions src/taxonomy/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,23 @@ export const useUpdateTag = (taxonomyId: number) => {
},
});
};

export const useDeleteTag = (taxonomyId: number) => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({ value, withSubtags }: { value: string; withSubtags: boolean; }) => {
const body = { tags: [value], with_subtags: withSubtags };
await getAuthenticatedHttpClient().delete(apiUrls.deleteTag(taxonomyId), {
data: body,
});
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId),
});
// In the metadata, 'tagsCount' (and possibly other fields) will have changed:
queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) });
},
});
};
Loading