add support for disabling alert dialog forms

This commit is contained in:
Ryan Vazquez
2021-01-26 17:03:14 -05:00
parent 2c68edd102
commit 027ee1a7cf
5 changed files with 239 additions and 197 deletions
@@ -52,9 +52,15 @@ export interface MigrationAlert extends Alert {
* by status in a collapsed view below Alert Insights section and a badge will appear in Action Items
* showing the total alerts of that status.
*
* Default forms can be overridden by providing a valid React form component. Form components
* must return valid form elements, and accept a ref and onSubmit event handler. See /forms
* for example implementations. Custom forms must implement a corresponding event hook.
* Customizing Alerts
* Default forms can be overridden in two ways - by setting a form property to null or defining a custom component.
*
* If a form property is set to null, the Dialog will not render a form. This can be useful in scenarios
* where data isn't needed from the user such as when a user accepts an action item's recommendation.
*
* If a form property is set to a React component, the Dialog will render the form component in place of the default form.
* Form components must return valid form elements, and accept a ref and onSubmit event handler.
* Custom forms must implement the corresponding event hook. See /forms for example implementations.
*/
export class KubernetesMigrationAlert implements MigrationAlert {
@@ -64,7 +70,10 @@ export class KubernetesMigrationAlert implements MigrationAlert {
subtitle =
'Services running on Kubernetes are estimated to save 50% or more compared to Compute Engine.';
// Override default dismiss form with a custom form component.
// Dialog will not render a form if form property set to null.
AcceptForm = null;
// Overrides default Dismiss form with a custom form component.
DismissForm: AlertForm<
MigrationAlert,
MigrationDismissFormData
@@ -100,7 +109,7 @@ export class KubernetesMigrationAlert implements MigrationAlert {
);
}
/* Fires when the onSubmit event is raised on a DismissAlert form. Displays a custom dismiss form. */
/* Fires when the onSubmit event is raised on a Dismiss form. Displays custom dismiss form. */
async onDismissed(
options: AlertOptions<MigrationDismissFormData>,
): Promise<Alert[]> {
@@ -117,7 +126,7 @@ export class KubernetesMigrationAlert implements MigrationAlert {
);
}
/* Fires when the onSubmit event is raised on an SnoozeAlert form. Displays default snooze form. */
/* Fires when the onSubmit event is raised on a Snooze form. Displays default snooze form. */
async onSnoozed(
options: AlertOptions<AlertSnoozeFormData>,
): Promise<Alert[]> {
@@ -134,7 +143,7 @@ export class KubernetesMigrationAlert implements MigrationAlert {
);
}
/* Fires when the onSubmit event is raised on an AcceptAlert form. Displays default accept form. */
/* Fires when the Accept button is clicked. Dialog does not render a form. See KubernetesMigrationAlert.AcceptForm */
async onAccepted(options: AlertOptions<null>): Promise<Alert[]> {
const alerts = await this.api.getAlerts(options.group);
return new Promise(resolve =>
@@ -16,154 +16,169 @@
import React from 'react';
import { AlertDialog } from './AlertDialog';
import { render } from '@testing-library/react';
import {
Alert,
AlertFormProps,
AlertSnoozeOptions,
AlertDismissOptions,
} from '../../types';
import { Alert, AlertFormProps } from '../../types';
type MockFormDataProps = AlertFormProps<Alert>;
const MockForm = React.forwardRef<HTMLFormElement, MockFormDataProps>(
(props, ref) => (
function createForm(title: string) {
return React.forwardRef<HTMLFormElement, MockFormDataProps>((props, ref) => (
<form ref={ref} onSubmit={props.onSubmit}>
You. Complete. Me.
You. {title}. Me.
</form>
),
);
));
}
const snoozableAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onSnoozed: jest.fn(),
};
const dimissableAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
};
const acceptAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onAccepted: jest.fn(),
};
const customSnoozeAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onSnoozed: jest.fn(),
SnoozeForm: createForm('Snooze'),
};
const customDismissAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
DismissForm: createForm('Dismiss'),
};
const customAcceptAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onAccepted: jest.fn(),
AcceptForm: createForm('Accept'),
};
const nullAcceptAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onAccepted: jest.fn(),
AcceptForm: null,
};
const nullDismissAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onDismissed: jest.fn(),
DismissForm: null,
};
const nullSnoozeAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onSnoozed: jest.fn(),
SnoozeForm: null,
};
describe('<AlertDialog />', () => {
const snoozableAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onSnoozed: jest.fn(),
};
describe.each`
accepted | dismissed | snoozed | action | text
${acceptAlert} | ${null} | ${null} | ${['Accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'}
${null} | ${dimissableAlert} | ${null} | ${['Dismiss', 'dismissed']} | ${'Reason for dismissing?'}
${null} | ${null} | ${snoozableAlert} | ${['Snooze', 'snoozed']} | ${'For how long?'}
`(
'Default forms',
({ accepted, dismissed, snoozed, action: [action, actioned], text }) => {
it(`Displays a default ${action} form`, () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(getByText(text)).toBeInTheDocument();
expect(getByText(`${action} this action item?`)).toBeInTheDocument();
expect(
getByText(`This action item will be ${actioned} for all of Ramones.`),
).toBeInTheDocument();
});
},
);
const dimissableAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
};
describe.each`
accepted | dismissed | snoozed | action
${customAcceptAlert} | ${null} | ${null} | ${['Accept', 'accepted']}
${null} | ${customDismissAlert} | ${null} | ${['Dismiss', 'dismissed']}
${null} | ${null} | ${customSnoozeAlert} | ${['Snooze', 'snoozed']}
`(
'Custom forms',
({ accepted, dismissed, snoozed, action: [Action, actioned] }) => {
it(`Displays a custom ${Action} form`, () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(getByText(`You. ${Action}. Me.`)).toBeInTheDocument();
expect(getByText(`${Action} this action item?`)).toBeInTheDocument();
expect(
getByText(`This action item will be ${actioned} for all of Ramones.`),
).toBeInTheDocument();
});
},
);
const customSnoozeAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onSnoozed: jest.fn(),
SnoozeForm: MockForm,
};
const customDismissAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
DismissForm: MockForm,
};
const customAcceptAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onAccepted: jest.fn(),
AcceptForm: MockForm,
};
it('Displays a default snooze form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={snoozableAlert}
accepted={null}
dismissed={null}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(getByText('For how long?')).toBeInTheDocument();
expect(getByText('Snooze this action item?')).toBeInTheDocument();
expect(
getByText('This action item will be snoozed for all of Ramones.'),
).toBeInTheDocument();
AlertSnoozeOptions.forEach(a =>
expect(getByText(a.label)).toBeInTheDocument(),
);
});
it('Displays a custom snooze form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={customSnoozeAlert}
accepted={null}
dismissed={null}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(getByText('You. Complete. Me.')).toBeInTheDocument();
expect(getByText('Snooze this action item?')).toBeInTheDocument();
expect(
getByText('This action item will be snoozed for all of Ramones.'),
).toBeInTheDocument();
});
it('Displays a default dismiss form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={null}
accepted={null}
dismissed={dimissableAlert}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(getByText('Dismiss this action item?')).toBeInTheDocument();
expect(
getByText('This action item will be dismissed for all of Ramones.'),
).toBeInTheDocument();
AlertDismissOptions.forEach(a =>
expect(getByText(a.label)).toBeInTheDocument(),
);
});
it('Displays a custom dismiss form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={null}
accepted={null}
dismissed={customDismissAlert}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(getByText('Dismiss this action item?')).toBeInTheDocument();
expect(getByText('You. Complete. Me.')).toBeInTheDocument();
expect(
getByText('This action item will be dismissed for all of Ramones.'),
).toBeInTheDocument();
});
it('Displays a custom accept form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={null}
accepted={customAcceptAlert}
dismissed={null}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(getByText('Accept this action item?')).toBeInTheDocument();
expect(getByText('You. Complete. Me.')).toBeInTheDocument();
expect(
getByText('This action item will be accepted for all of Ramones.'),
).toBeInTheDocument();
});
describe.each`
accepted | dismissed | snoozed | action | text
${nullAcceptAlert} | ${null} | ${null} | ${['Accept', 'accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'}
${null} | ${nullDismissAlert} | ${null} | ${['Dismiss', 'dismiss', 'dismissed']} | ${'Reason for dismissing?'}
${null} | ${null} | ${nullSnoozeAlert} | ${['Snooze', 'snooze', 'snoozed']} | ${'For how long?'}
`(
'Null forms',
({
accepted,
dismissed,
snoozed,
action: [Action, action, actioned],
text,
}) => {
it(`Does NOT display a ${Action} form`, () => {
const { getByText, getByRole, queryByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(queryByText(text)).not.toBeInTheDocument();
expect(getByRole('button', { name: action })).toBeInTheDocument();
expect(getByText(`${Action} this action item?`)).toBeInTheDocument();
expect(
getByText(`This action item will be ${actioned} for all of Ramones.`),
).toBeInTheDocument();
});
},
);
});
@@ -33,7 +33,7 @@ import {
} from '../../forms';
import { useAlertDialogStyles as useStyles } from '../../utils/styles';
import { choose } from '../../utils/alerts';
import { Alert, Maybe } from '../../types';
import { Alert, AlertForm, Maybe } from '../../types';
const DEFAULT_FORM_ID = 'alert-form';
@@ -79,20 +79,41 @@ export const AlertDialog = ({
setDisabled(true);
}
const SnoozeForm = snoozed?.SnoozeForm ?? AlertSnoozeForm;
const AcceptForm = accepted?.AcceptForm ?? AlertAcceptForm;
const DismissForm = dismissed?.DismissForm ?? AlertDismissForm;
const SnoozeForm: Maybe<AlertForm> = snoozed?.SnoozeForm ?? AlertSnoozeForm;
const AcceptForm: Maybe<AlertForm> = accepted?.AcceptForm ?? AlertAcceptForm;
const DismissForm: Maybe<AlertForm> =
dismissed?.DismissForm ?? AlertDismissForm;
const isSnoozeFormDisplayed = !!snoozed?.onSnoozed;
const isAcceptFormDisplayed = !!accepted?.onAccepted;
const isDismissFormDisplayed = !!dismissed?.onDismissed;
const isSnoozingEnabled = !!snoozed?.onSnoozed;
const isAcceptingEnabled = !!accepted?.onAccepted;
const isDismissingEnabled = !!dismissed?.onDismissed;
const isSnoozeFormDisabled = snoozed?.SnoozeForm === null;
const isAcceptFormDisabled = accepted?.AcceptForm === null;
const isDismissFormDisabled = dismissed?.DismissForm === null;
const isFormDisabled =
isSnoozeFormDisabled || isAcceptFormDisabled || isDismissFormDisabled;
const status = [
isAcceptFormDisplayed,
isSnoozeFormDisplayed,
isDismissFormDisplayed,
isSnoozingEnabled,
isAcceptingEnabled,
isDismissingEnabled,
] as const;
const [Action, action, actioned] =
choose(status, [
['Snooze', 'snooze', 'snoozed'],
['Accept', 'accept', 'accepted'],
['Dismiss', 'dismiss', 'dismissed'],
]) ?? [];
const [title, subtitle] =
choose(status, [
[snoozed?.title, snoozed?.subtitle],
[accepted?.title, accepted?.subtitle],
[dismissed?.title, dismissed?.subtitle],
]) ?? [];
const TransitionProps = {
mountOnEnter: true,
unmountOnExit: true,
@@ -122,24 +143,20 @@ export const AlertDialog = ({
<IconButton
className={classes.icon}
disableRipple
aria-label="close dialog"
onClick={onDialogClose}
>
<CloseIcon aria-label="close dialog" />
<CloseIcon />
</IconButton>
</Box>
<DialogContent className={classes.content}>
<Box mb={1.5}>
<Typography variant="h5">
<b>
{choose(status, ['Accept', 'Snooze', 'Dismiss'])} this action
item?
</b>
<b>{Action} this action item?</b>
</Typography>
<Typography variant="h6" color="textSecondary">
<b>
This action item will be{' '}
{choose(status, ['accepted', 'snoozed', 'dismissed'])} for all of{' '}
{group}.
This action item will be {actioned} for all of {group}.
</b>
</Typography>
</Box>
@@ -152,23 +169,11 @@ export const AlertDialog = ({
borderRadius={4}
>
<Typography>
<b>
{choose(status, [
accepted?.title,
snoozed?.title,
dismissed?.title,
])}
</b>
</Typography>
<Typography color="textSecondary">
{choose(status, [
accepted?.subtitle,
snoozed?.subtitle,
dismissed?.subtitle,
])}
<b>{title}</b>
</Typography>
<Typography color="textSecondary">{subtitle}</Typography>
</Box>
{isSnoozeFormDisplayed && (
{isSnoozingEnabled && !isSnoozeFormDisabled && (
<SnoozeForm
ref={snoozeRef}
alert={snoozed!}
@@ -176,7 +181,7 @@ export const AlertDialog = ({
disableSubmit={disableSubmit}
/>
)}
{isDismissFormDisplayed && (
{isDismissingEnabled && !isDismissFormDisabled && (
<DismissForm
ref={dismissRef}
alert={dismissed!}
@@ -184,7 +189,7 @@ export const AlertDialog = ({
disableSubmit={disableSubmit}
/>
)}
{isAcceptFormDisplayed && (
{isAcceptingEnabled && !isAcceptFormDisabled && (
<AcceptForm
ref={acceptRef}
alert={accepted!}
@@ -195,15 +200,28 @@ export const AlertDialog = ({
</DialogContent>
<Divider />
<DialogActions className={classes.actions} disableSpacing>
<Button
disabled={isButtonDisabled}
type="submit"
form={DEFAULT_FORM_ID}
variant="contained"
color="primary"
>
{choose(status, ['Accept', 'Snooze', 'Dismiss'])}
</Button>
{isFormDisabled ? (
<Button
type="button"
color="primary"
variant="contained"
aria-label={action}
onClick={() => onSubmit(null)}
>
{Action}
</Button>
) : (
<Button
type="submit"
color="primary"
variant="contained"
form={DEFAULT_FORM_ID}
aria-label={action}
disabled={isButtonDisabled}
>
{Action}
</Button>
)}
</DialogActions>
</Dialog>
);
+3 -3
View File
@@ -40,9 +40,9 @@ export type Alert = {
status?: AlertStatus;
url?: string;
buttonText?: string; // Default: View Instructions
SnoozeForm?: AlertForm;
AcceptForm?: AlertForm;
DismissForm?: AlertForm;
SnoozeForm?: Maybe<AlertForm>;
AcceptForm?: Maybe<AlertForm>;
DismissForm?: Maybe<AlertForm>;
onSnoozed?(options: AlertOptions): Promise<Alert[]>;
onAccepted?(options: AlertOptions): Promise<Alert[]>;
onDismissed?(options: AlertOptions): Promise<Alert[]>;
+2 -2
View File
@@ -29,7 +29,7 @@ export const sumOfAllAlerts = (sum: number, alerts: Alert[]) =>
export function choose<T>(
status: readonly [boolean, boolean, boolean],
values: [T, T, T],
): T | null {
): T | undefined {
const i = status.indexOf(true);
return i < 0 ? null : values[i];
return values[i];
}