This commit is contained in:
Ryan Vazquez
2021-01-28 18:55:36 -05:00
parent 6c460dac58
commit 70906eed4c
23 changed files with 646 additions and 620 deletions
@@ -72,7 +72,6 @@ export class KubernetesMigrationAlert implements MigrationAlert {
// 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,
@@ -94,7 +93,7 @@ export class KubernetesMigrationAlert implements MigrationAlert {
get element() {
const subheader = `${pluralize(
'Compute Engine role',
'Service',
this.data.services.length,
true,
)}, sorted by cost`;
@@ -116,7 +115,7 @@ export class KubernetesMigrationAlert implements MigrationAlert {
const alerts = await this.api.getAlerts(options.group);
return new Promise(resolve =>
setTimeout(resolve, 750, [
...alerts.slice(0, 2),
...alerts.filter(a => a.title !== this.title),
{
title: this.title,
subtitle: this.subtitle,
@@ -133,7 +132,7 @@ export class KubernetesMigrationAlert implements MigrationAlert {
const alerts = await this.api.getAlerts(options.group);
return new Promise(resolve =>
setTimeout(resolve, 750, [
...alerts.slice(0, 2),
...alerts.filter(a => a.title !== this.title),
{
title: this.title,
subtitle: this.subtitle,
@@ -148,7 +147,7 @@ export class KubernetesMigrationAlert implements MigrationAlert {
const alerts = await this.api.getAlerts(options.group);
return new Promise(resolve =>
setTimeout(resolve, 750, [
...alerts.slice(0, 2),
...alerts.filter(a => a.title !== this.title),
{
title: this.title,
subtitle: this.subtitle,
+2 -2
View File
@@ -183,8 +183,8 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
new ProjectGrowthAlert(projectGrowthData),
new UnlabeledDataflowAlert(unlabeledDataflowData),
new KubernetesMigrationAlert(this, {
startDate: today.format(DEFAULT_DATE_FORMAT),
endDate: today.add(30, 'day').format(DEFAULT_DATE_FORMAT),
startDate: today.subtract(30, 'day').format(DEFAULT_DATE_FORMAT),
endDate: today.format(DEFAULT_DATE_FORMAT),
change: {
ratio: 0,
amount: 0,
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import React from 'react';
import { capitalize } from '@material-ui/core';
import { AlertDialog } from './AlertDialog';
import { render } from '@testing-library/react';
import { Alert, AlertFormProps } from '../../types';
import { Alert, AlertFormProps, AlertStatus } from '../../types';
type MockFormDataProps = AlertFormProps<Alert>;
@@ -40,7 +41,7 @@ const dimissableAlert: Alert = {
onDismissed: jest.fn(),
};
const acceptAlert: Alert = {
const acceptableAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onAccepted: jest.fn(),
@@ -87,98 +88,87 @@ const nullSnoozeAlert: Alert = {
onSnoozed: jest.fn(),
SnoozeForm: null,
};
describe('<AlertDialog />', () => {
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();
});
},
);
alert | status | action | text
${acceptableAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'}
${dimissableAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'}
${snoozableAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'}
`('Default forms', ({ alert, status, action: [action, actioned], text }) => {
it(`Displays a default ${action} form`, () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
alert={alert}
status={status}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(getByText(text)).toBeInTheDocument();
expect(
getByText(`${capitalize(action)} this action item?`),
).toBeInTheDocument();
expect(
getByText(`This action item will be ${actioned} for all of Ramones.`),
).toBeInTheDocument();
});
});
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();
});
},
);
alert | status | action | text
${customAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'}
${customDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'}
${customSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'}
`('Custom forms', ({ alert, status, action: [action, actioned] }) => {
it(`Displays a custom ${capitalize(action)} form`, () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
alert={alert}
status={status}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(getByText(`You. ${capitalize(action)}. Me.`)).toBeInTheDocument();
expect(
getByText(`${capitalize(action)} this action item?`),
).toBeInTheDocument();
expect(
getByText(`This action item will be ${actioned} 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();
});
},
);
alert | status | action | text
${nullAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'}
${nullDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'}
${nullSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'}
`('Null forms', ({ alert, status, action: [action, actioned], text }) => {
it(`Does NOT display a ${capitalize(action)} form`, () => {
const { getByText, getByRole, queryByText } = render(
<AlertDialog
open
group="Ramones"
alert={alert}
status={status}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
);
expect(queryByText(text)).not.toBeInTheDocument();
expect(getByRole('button', { name: action })).toBeInTheDocument();
expect(
getByText(`${capitalize(action)} this action item?`),
).toBeInTheDocument();
expect(
getByText(`This action item will be ${actioned} for all of Ramones.`),
).toBeInTheDocument();
});
});
});
@@ -15,8 +15,8 @@
*/
import React, { useEffect, useRef, useState } from 'react';
import { default as CloseIcon } from '@material-ui/icons/Close';
import {
capitalize,
Box,
Button,
Divider,
@@ -26,23 +26,18 @@ import {
DialogContent,
Typography,
} from '@material-ui/core';
import {
AlertAcceptForm,
AlertDismissForm,
AlertSnoozeForm,
} from '../../forms';
import { default as CloseIcon } from '@material-ui/icons/Close';
import { useAlertDialogStyles as useStyles } from '../../utils/styles';
import { choose } from '../../utils/alerts';
import { Alert, AlertForm, Maybe } from '../../types';
import { Alert, AlertStatus, Maybe } from '../../types';
import { choose, formOf } from '../../utils/alerts';
const DEFAULT_FORM_ID = 'alert-form';
type AlertDialogProps = {
open: boolean;
group: string;
snoozed: Maybe<Alert>;
accepted: Maybe<Alert>;
dismissed: Maybe<Alert>;
alert: Maybe<Alert>;
status: Maybe<AlertStatus>;
onClose: () => void;
onSubmit: (data: any) => void;
};
@@ -50,87 +45,50 @@ type AlertDialogProps = {
export const AlertDialog = ({
open,
group,
snoozed,
accepted,
dismissed,
alert,
status,
onClose,
onSubmit,
}: AlertDialogProps) => {
const classes = useStyles();
const [isButtonDisabled, setDisabled] = useState(true);
const acceptRef = useRef<Maybe<HTMLFormElement>>(null);
const snoozeRef = useRef<Maybe<HTMLFormElement>>(null);
const dismissRef = useRef<Maybe<HTMLFormElement>>(null);
const [isSubmitDisabled, setSubmitDisabled] = useState(true);
const formRef = useRef<Maybe<HTMLFormElement>>(null);
useEffect(() => {
if (open) {
setDisabled(true);
} else {
setDisabled(false);
}
setSubmitDisabled(open);
}, [open]);
function disableSubmit(isDisabled: boolean) {
setDisabled(isDisabled);
setSubmitDisabled(isDisabled);
}
function onDialogClose() {
onClose();
setDisabled(true);
setSubmitDisabled(true);
}
const SnoozeForm: Maybe<AlertForm> = snoozed?.SnoozeForm ?? AlertSnoozeForm;
const AcceptForm: Maybe<AlertForm> = accepted?.AcceptForm ?? AlertAcceptForm;
const DismissForm: Maybe<AlertForm> =
dismissed?.DismissForm ?? AlertDismissForm;
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 = [
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 [action, actioned] = choose(
status,
[
['snooze', 'snoozed'],
['accept', 'accepted'],
['dismiss', 'dismissed'],
],
['', ''],
);
const TransitionProps = {
mountOnEnter: true,
unmountOnExit: true,
// Wait for child component to mount; avoid recycling refs.
onEntered() {
if (acceptRef.current) {
acceptRef.current.id = DEFAULT_FORM_ID;
}
if (snoozeRef.current) {
snoozeRef.current.id = DEFAULT_FORM_ID;
}
if (dismissRef.current) {
dismissRef.current.id = DEFAULT_FORM_ID;
if (formRef.current) {
formRef.current.id = DEFAULT_FORM_ID;
}
},
};
const Form = formOf(alert, status);
return (
<Dialog
open={open}
@@ -143,7 +101,7 @@ export const AlertDialog = ({
<IconButton
className={classes.icon}
disableRipple
aria-label="close dialog"
aria-label="Close"
onClick={onDialogClose}
>
<CloseIcon />
@@ -152,7 +110,7 @@ export const AlertDialog = ({
<DialogContent className={classes.content}>
<Box mb={1.5}>
<Typography variant="h5">
<b>{Action} this action item?</b>
<b>{capitalize(action)} this action item?</b>
</Typography>
<Typography variant="h6" color="textSecondary">
<b>
@@ -169,30 +127,14 @@ export const AlertDialog = ({
borderRadius={4}
>
<Typography>
<b>{title}</b>
<b>{alert?.title}</b>
</Typography>
<Typography color="textSecondary">{subtitle}</Typography>
<Typography color="textSecondary">{alert?.subtitle}</Typography>
</Box>
{isSnoozingEnabled && !isSnoozeFormDisabled && (
<SnoozeForm
ref={snoozeRef}
alert={snoozed!}
onSubmit={onSubmit}
disableSubmit={disableSubmit}
/>
)}
{isDismissingEnabled && !isDismissFormDisabled && (
<DismissForm
ref={dismissRef}
alert={dismissed!}
onSubmit={onSubmit}
disableSubmit={disableSubmit}
/>
)}
{isAcceptingEnabled && !isAcceptFormDisabled && (
<AcceptForm
ref={acceptRef}
alert={accepted!}
{Form && (
<Form
ref={formRef}
alert={alert}
onSubmit={onSubmit}
disableSubmit={disableSubmit}
/>
@@ -200,7 +142,18 @@ export const AlertDialog = ({
</DialogContent>
<Divider />
<DialogActions className={classes.actions} disableSpacing>
{isFormDisabled ? (
{Form ? (
<Button
type="submit"
color="primary"
variant="contained"
aria-label={action}
form={DEFAULT_FORM_ID}
disabled={isSubmitDisabled}
>
{capitalize(action)}
</Button>
) : (
<Button
type="button"
color="primary"
@@ -208,18 +161,7 @@ export const AlertDialog = ({
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}
{capitalize(action)}
</Button>
)}
</DialogActions>
@@ -17,18 +17,12 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { AlertInsights } from './AlertInsights';
import {
MockScrollProvider,
MockAlertsProvider,
MockLoadingProvider,
} from '../../utils/tests';
import { MockScrollProvider, MockLoadingProvider } from '../../utils/tests';
function renderInContext(children: JSX.Element) {
return render(
<MockLoadingProvider>
<MockScrollProvider>
<MockAlertsProvider>{children}</MockAlertsProvider>
</MockScrollProvider>
<MockScrollProvider>{children}</MockScrollProvider>
</MockLoadingProvider>,
);
}
@@ -47,6 +41,7 @@ describe('<AlertInsights />', () => {
snoozed={[]}
accepted={[]}
dismissed={[]}
onChange={jest.fn()}
/>,
);
expect(
@@ -70,6 +65,7 @@ describe('<AlertInsights />', () => {
]}
accepted={[]}
dismissed={[]}
onChange={jest.fn()}
/>,
);
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import pluralize from 'pluralize';
import { Box, Grid, Snackbar } from '@material-ui/core';
import { default as MuiAlert } from '@material-ui/lab/Alert';
@@ -24,15 +24,20 @@ import { AlertStatusSummaryButton } from './AlertStatusSummaryButton';
import { AlertInsightsHeader } from './AlertInsightsHeader';
import { AlertInsightsSection } from './AlertInsightsSection';
import {
useAlerts,
useScroll,
useLoading,
ScrollType,
MapLoadingToProps,
} from '../../hooks';
import { DefaultLoadingAction } from '../../utils/loading';
import { Alert, AlertOptions, Maybe } from '../../types';
import { sumOfAllAlerts } from '../../utils/alerts';
import { Alert, AlertOptions, AlertStatus, Maybe } from '../../types';
import {
isStatusSnoozed,
isStatusAccepted,
isStatusDismissed,
sumOfAllAlerts,
} from '../../utils/alerts';
import { ScrollAnchor } from '../../utils/scroll';
type MapLoadingtoAlerts = (isLoading: boolean) => void;
@@ -47,6 +52,7 @@ type AlertInsightsProps = {
snoozed: Alert[];
accepted: Alert[];
dismissed: Alert[];
onChange: (alerts: Alert[]) => void;
};
export const AlertInsights = ({
@@ -55,10 +61,12 @@ export const AlertInsights = ({
snoozed,
accepted,
dismissed,
onChange,
}: AlertInsightsProps) => {
const [alerts, setAlerts] = useAlerts();
const [scroll, , ScrollAnchor] = useScroll();
const [scroll] = useScroll();
const [alert, setAlert] = useState<Maybe<Alert>>(null);
const dispatchLoadingAlerts = useLoading(mapLoadingToAlerts);
const [status, setStatus] = useState<Maybe<AlertStatus>>(null);
// Allow users to pass null values for data.
const [data, setData] = useState<Maybe<any>>(undefined);
const [error, setError] = useState<Maybe<Error>>(null);
@@ -66,22 +74,19 @@ export const AlertInsights = ({
const [isSummaryOpen, setSummaryOpen] = useState(false);
const [isSnackbarOpen, setSnackbarOpen] = useState(false);
const closeDialog = useCallback(() => {
setData(undefined);
setDialogOpen(false);
setAlerts({ dismissed: null, snoozed: null, accepted: null });
}, [setAlerts]);
useEffect(() => {
async function callHandler(
async function callAlertHook(
options: AlertOptions,
callback: (options: AlertOptions) => Promise<Alert[]>,
) {
closeDialog();
setAlert(null);
setStatus(null);
setData(undefined);
setDialogOpen(false);
dispatchLoadingAlerts(true);
try {
const a: Alert[] = await callback(options);
setAlerts({ alerts: a });
const alerts: Alert[] = await callback(options);
onChange(alerts);
} catch (e) {
setError(e);
} finally {
@@ -90,22 +95,20 @@ export const AlertInsights = ({
}
const options: AlertOptions = { data, group };
const onSnoozed = alerts.snoozed?.onSnoozed?.bind(alerts.snoozed) ?? null;
const onAccepted =
alerts.accepted?.onAccepted?.bind(alerts.accepted) ?? null;
const onDismissed =
alerts.dismissed?.onDismissed?.bind(alerts.dismissed) ?? null;
const onSnoozed = alert?.onSnoozed?.bind(alert);
const onAccepted = alert?.onAccepted?.bind(alert);
const onDismissed = alert?.onDismissed?.bind(alert);
if (data !== undefined) {
if (onSnoozed) {
callHandler(options, onSnoozed);
} else if (onAccepted) {
callHandler(options, onAccepted);
} else if (onDismissed) {
callHandler(options, onDismissed);
if (isStatusSnoozed(status) && onSnoozed) {
callAlertHook(options, onSnoozed);
} else if (isStatusAccepted(status) && onAccepted) {
callAlertHook(options, onAccepted);
} else if (isStatusDismissed(status) && onDismissed) {
callAlertHook(options, onDismissed);
}
}
}, [group, data, alerts, setAlerts, closeDialog, dispatchLoadingAlerts]);
}, [group, data, alert, status, onChange, dispatchLoadingAlerts]);
useEffect(() => {
if (scroll === ScrollType.AlertSummary) {
@@ -114,34 +117,38 @@ export const AlertInsights = ({
}, [scroll]);
useEffect(() => {
if (error) {
setSnackbarOpen(true);
} else {
setSnackbarOpen(false);
}
}, [error]);
setDialogOpen(!!status);
}, [status]);
useEffect(() => {
function toggleDialogOnStatusChange() {
const isAlertSnoozed = !!alerts.snoozed;
const isAlertAccepted = !!alerts.accepted;
const isAlertDismissed = !!alerts.dismissed;
setSnackbarOpen(!!error);
}, [error]);
if (isAlertSnoozed || isAlertDismissed || isAlertAccepted) {
setDialogOpen(true);
} else {
setDialogOpen(false);
}
}
function onSnooze(alert: Alert) {
setAlert(alert);
setStatus(AlertStatus.Snoozed);
}
toggleDialogOnStatusChange();
}, [alerts.snoozed, alerts.dismissed, alerts.accepted]);
function onAccept(alert: Alert) {
setAlert(alert);
setStatus(AlertStatus.Accepted);
}
function onDismiss(alert: Alert) {
setAlert(alert);
setStatus(AlertStatus.Dismissed);
}
function onSnackbarClose() {
setError(null);
}
function onDialogSubmit(data: any) {
function onDialogClose() {
setAlert(null);
setStatus(null);
}
function onDialogFormSubmit(data: any) {
setData(data);
}
@@ -153,7 +160,6 @@ export const AlertInsights = ({
const isAlertStatusSummaryDisplayed = !!total;
const isAlertInsightSectionDisplayed = !!active.length;
// AlertInsights will not display if there aren't any active or hidden items.
return (
<Grid container direction="column" spacing={2}>
@@ -171,7 +177,13 @@ export const AlertInsights = ({
<Grid item container direction="column" spacing={4}>
{active.map((alert, index) => (
<Grid item key={`alert-insights-section-${index}`}>
<AlertInsightsSection alert={alert} number={index + 1} />
<AlertInsightsSection
alert={alert}
number={index + 1}
onSnooze={onSnooze}
onAccept={onAccept}
onDismiss={onDismiss}
/>
</Grid>
))}
</Grid>
@@ -195,11 +207,10 @@ export const AlertInsights = ({
<AlertDialog
group={group}
open={isDialogOpen}
snoozed={alerts.snoozed}
accepted={alerts.accepted}
dismissed={alerts.dismissed}
onClose={closeDialog}
onSubmit={onDialogSubmit}
alert={alert}
status={status}
onClose={onDialogClose}
onSubmit={onDialogFormSubmit}
/>
<Snackbar
open={isSnackbarOpen}
@@ -17,7 +17,7 @@
import React from 'react';
import { Box, Typography } from '@material-ui/core';
import { useCostInsightsStyles as useStyles } from '../../utils/styles';
import { useScroll } from '../../hooks';
import { ScrollAnchor } from '../../utils/scroll';
import { DefaultNavigation } from '../../utils/navigation';
type AlertInsightsHeaderProps = {
@@ -30,7 +30,6 @@ export const AlertInsightsHeader = ({
subtitle,
}: AlertInsightsHeaderProps) => {
const classes = useStyles();
const [, , ScrollAnchor] = useScroll();
return (
<Box mb={6} position="relative">
@@ -17,8 +17,7 @@ import React from 'react';
import { AlertInsightsSection } from './AlertInsightsSection';
import { render } from '@testing-library/react';
import { Alert } from '../../types';
import { AlertState } from '../../hooks';
import { MockScrollProvider, MockAlertsProvider } from '../../utils/tests';
import { MockScrollProvider } from '../../utils/tests';
const mockAlert: Alert = {
subtitle:
@@ -27,14 +26,20 @@ const mockAlert: Alert = {
url: '/cost-insights/test',
};
function renderInContext(children: JSX.Element) {
return render(<MockScrollProvider>{children}</MockScrollProvider>);
}
describe('<AlertInsightsSection/>', () => {
it('Renders alert without exploding', () => {
const { getByText, queryByText } = render(
<MockAlertsProvider>
<MockScrollProvider>
<AlertInsightsSection alert={mockAlert} number={1} />
</MockScrollProvider>
</MockAlertsProvider>,
const { getByText, queryByText } = renderInContext(
<AlertInsightsSection
alert={mockAlert}
number={1}
onSnooze={jest.fn()}
onDismiss={jest.fn()}
onAccept={jest.fn()}
/>,
);
expect(getByText(mockAlert.title)).toBeInTheDocument();
expect(getByText(mockAlert.subtitle)).toBeInTheDocument();
@@ -49,12 +54,14 @@ describe('<AlertInsightsSection/>', () => {
...mockAlert,
url: undefined,
};
const { queryByText } = render(
<MockAlertsProvider>
<MockScrollProvider>
<AlertInsightsSection alert={alert} number={1} />
</MockScrollProvider>
</MockAlertsProvider>,
const { queryByText } = renderInContext(
<AlertInsightsSection
alert={alert}
number={1}
onSnooze={jest.fn()}
onDismiss={jest.fn()}
onAccept={jest.fn()}
/>,
);
expect(queryByText('View Instructions')).not.toBeInTheDocument();
});
@@ -65,19 +72,14 @@ describe('<AlertInsightsSection/>', () => {
onSnoozed: jest.fn(),
};
const context: AlertState = {
alerts: [],
snoozed: alert,
dismissed: null,
accepted: null,
};
const { queryByText, getByText } = render(
<MockAlertsProvider alerts={context}>
<MockScrollProvider>
<AlertInsightsSection alert={alert} number={1} />
</MockScrollProvider>
</MockAlertsProvider>,
const { queryByText, getByText } = renderInContext(
<AlertInsightsSection
alert={alert}
number={1}
onSnooze={jest.fn()}
onDismiss={jest.fn()}
onAccept={jest.fn()}
/>,
);
expect(getByText('Snooze')).toBeInTheDocument();
@@ -90,19 +92,15 @@ describe('<AlertInsightsSection/>', () => {
...mockAlert,
onDismissed: jest.fn(),
};
const context: AlertState = {
alerts: [],
snoozed: null,
dismissed: alert,
accepted: null,
};
const { queryByText, getByText } = render(
<MockAlertsProvider alerts={context}>
<MockScrollProvider>
<AlertInsightsSection alert={alert} number={1} />
</MockScrollProvider>
</MockAlertsProvider>,
const { queryByText, getByText } = renderInContext(
<AlertInsightsSection
alert={alert}
number={1}
onSnooze={jest.fn()}
onDismiss={jest.fn()}
onAccept={jest.fn()}
/>,
);
expect(getByText('Dismiss')).toBeInTheDocument();
@@ -116,19 +114,14 @@ describe('<AlertInsightsSection/>', () => {
onAccepted: jest.fn(),
};
const context: AlertState = {
alerts: [],
snoozed: null,
dismissed: null,
accepted: alert,
};
const { queryByText, getByText } = render(
<MockAlertsProvider alerts={context}>
<MockScrollProvider>
<AlertInsightsSection alert={alert} number={1} />
</MockScrollProvider>
</MockAlertsProvider>,
const { queryByText, getByText } = renderInContext(
<AlertInsightsSection
alert={alert}
number={1}
onSnooze={jest.fn()}
onDismiss={jest.fn()}
onAccept={jest.fn()}
/>,
);
expect(getByText('Accept')).toBeInTheDocument();
@@ -19,23 +19,31 @@ import { default as SnoozeIcon } from '@material-ui/icons/AccessTime';
import { default as AcceptIcon } from '@material-ui/icons/Check';
import { default as DismissIcon } from '@material-ui/icons/Delete';
import { AlertInsightsSectionHeader } from './AlertInsightsSectionHeader';
import { useAlerts } from '../../hooks';
import { Alert } from '../../types';
import {
isSnoozeEnabled,
isAcceptEnabled,
isDismissEnabled,
} from '../../utils/alerts';
type AlertInsightsSectionProps = {
alert: Alert;
number: number;
onSnooze: (alert: Alert) => void;
onAccept: (alert: Alert) => void;
onDismiss: (alert: Alert) => void;
};
export const AlertInsightsSection = ({
alert,
number,
onSnooze,
onAccept,
onDismiss,
}: AlertInsightsSectionProps) => {
const [, setAlerts] = useAlerts();
const isSnoozeButtonDisplayed = !!alert.onSnoozed;
const isAcceptButtonDisplayed = !!alert.onAccepted;
const isDismissButtonDisplayed = !!alert.onDismissed;
const isSnoozeButtonDisplayed = isSnoozeEnabled(alert);
const isAcceptButtonDisplayed = isAcceptEnabled(alert);
const isDismissButtonDisplayed = isDismissEnabled(alert);
const isButtonGroupDisplayed =
isSnoozeButtonDisplayed ||
isAcceptButtonDisplayed ||
@@ -52,7 +60,7 @@ export const AlertInsightsSection = ({
color="primary"
variant="contained"
aria-label="accept"
onClick={() => setAlerts({ accepted: alert })}
onClick={() => onAccept(alert)}
startIcon={<AcceptIcon />}
>
Accept
@@ -66,7 +74,7 @@ export const AlertInsightsSection = ({
variant="outlined"
aria-label="snooze"
disableElevation
onClick={() => setAlerts({ snoozed: alert })}
onClick={() => onSnooze(alert)}
startIcon={<SnoozeIcon />}
>
Snooze
@@ -79,7 +87,7 @@ export const AlertInsightsSection = ({
variant="outlined"
aria-label="dismiss"
disableElevation
onClick={() => setAlerts({ dismissed: alert })}
onClick={() => onDismiss(alert)}
startIcon={<DismissIcon />}
>
Dismiss
@@ -17,7 +17,7 @@
import React from 'react';
import { Avatar, Box, Button, Grid, Typography } from '@material-ui/core';
import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles';
import { useScroll } from '../../hooks';
import { ScrollAnchor } from '../../utils/scroll';
import { Alert } from '../../types';
type AlertInsightsSectionHeaderProps = {
@@ -29,7 +29,6 @@ export const AlertInsightsSectionHeader = ({
alert,
number,
}: AlertInsightsSectionHeaderProps) => {
const [, , ScrollAnchor] = useScroll();
const classes = useStyles();
const isViewInstructionsButtonDisplayed = !!alert.url;
@@ -15,7 +15,7 @@
*/
import React, { Fragment } from 'react';
import { Avatar, Box, Collapse, Divider } from '@material-ui/core';
import { Avatar, Box, Collapse, Divider, Tooltip } from '@material-ui/core';
import { default as AcceptIcon } from '@material-ui/icons/Check';
import { default as DismissIcon } from '@material-ui/icons/Delete';
import { default as SnoozeIcon } from '@material-ui/icons/AccessTime';
@@ -23,6 +23,35 @@ import { ActionItemCard } from '../ActionItems';
import { Alert, AlertStatus } from '../../types';
import { useActionItemCardStyles as useStyles } from '../../utils/styles';
type AlertGroupProps = {
alerts: Alert[];
status: AlertStatus;
title: string;
icon: JSX.Element;
};
const AlertGroup = ({ alerts, status, title, icon }: AlertGroupProps) => {
const classes = useStyles();
return (
<Box p={1}>
{alerts.map((alert, index) => (
<Fragment key={`alert-${status}-${index}`}>
<ActionItemCard
disableScroll
alert={alert}
avatar={
<Tooltip title={title}>
<Avatar className={classes.avatar}>{icon}</Avatar>
</Tooltip>
}
/>
{index < alerts.length - 1 && <Divider />}
</Fragment>
))}
</Box>
);
};
type AlertStatusSummaryProps = {
open: boolean;
snoozed: Alert[];
@@ -36,8 +65,6 @@ export const AlertStatusSummary = ({
accepted,
dismissed,
}: AlertStatusSummaryProps) => {
const classes = useStyles();
const isSnoozedListDisplayed = !!snoozed.length;
const isAcceptedListDisplayed = !!accepted.length;
const isDismissedListDisplayed = !!dismissed.length;
@@ -45,71 +72,46 @@ export const AlertStatusSummary = ({
return (
<Collapse in={open}>
{isAcceptedListDisplayed && (
<Box p={1}>
{accepted.map((alert, index) => (
<Fragment key={`alert-accepted-${index}`}>
<ActionItemCard
disableScroll
alert={alert}
avatar={
<Avatar className={classes.avatar}>
{/* Icons indicate alert status. Do not hide from accesibility tree */}
<AcceptIcon
aria-hidden={false}
role="img"
aria-label={AlertStatus.Accepted}
/>
</Avatar>
}
/>
{index < accepted.length - 1 && <Divider />}
</Fragment>
))}
</Box>
<AlertGroup
title="Accepted"
alerts={accepted}
status={AlertStatus.Accepted}
icon={
<AcceptIcon
role="img"
aria-hidden={false}
aria-label={AlertStatus.Accepted}
/>
}
/>
)}
{isSnoozedListDisplayed && (
<Box p={1}>
{snoozed.map((alert, index) => (
<Fragment key={`alert-accepted-${index}`}>
<ActionItemCard
disableScroll
alert={alert}
avatar={
<Avatar className={classes.avatar}>
<SnoozeIcon
aria-hidden={false}
role="img"
aria-label={AlertStatus.Snoozed}
/>
</Avatar>
}
/>
{index < snoozed.length - 1 && <Divider />}
</Fragment>
))}
</Box>
<AlertGroup
title="Snoozed"
alerts={snoozed}
status={AlertStatus.Snoozed}
icon={
<SnoozeIcon
role="img"
aria-hidden={false}
aria-label={AlertStatus.Snoozed}
/>
}
/>
)}
{isDismissedListDisplayed && (
<Box p={1}>
{dismissed.map((alert, index) => (
<Fragment key={`alert-dismissed-${index}`}>
<ActionItemCard
disableScroll
alert={alert}
avatar={
<Avatar className={classes.avatar}>
<DismissIcon
aria-hidden={false}
role="img"
aria-label={AlertStatus.Dismissed}
/>
</Avatar>
}
/>
{index < dismissed.length - 1 && <Divider />}
</Fragment>
))}
</Box>
<AlertGroup
title="Dismissed"
alerts={dismissed}
status={AlertStatus.Dismissed}
icon={
<DismissIcon
role="img"
aria-hidden={false}
aria-label={AlertStatus.Dismissed}
/>
}
/>
)}
</Collapse>
);
@@ -44,22 +44,21 @@ import { ProductInsights } from '../ProductInsights';
import {
useConfig,
useCurrency,
useAlerts,
useFilters,
useGroups,
useLastCompleteBillingDate,
useLoading,
} from '../../hooks';
import { Cost, Maybe, MetricData, Product, Project } from '../../types';
import { Alert, Cost, Maybe, MetricData, Product, Project } from '../../types';
import { mapLoadingToProps } from './selector';
import { ProjectSelect } from '../ProjectSelect';
import { intervalsOf } from '../../utils/duration';
import { useSubtleTypographyStyles } from '../../utils/styles';
import {
isActive,
isAccepted,
isDismissed,
isSnoozed,
isAlertActive,
isAlertAccepted,
isAlertDismissed,
isAlertSnoozed,
} from '../../utils/alerts';
export const CostInsightsPage = () => {
@@ -68,7 +67,7 @@ export const CostInsightsPage = () => {
const config = useConfig();
const groups = useGroups();
const lastCompleteBillingDate = useLastCompleteBillingDate();
const [alerts, setAlerts] = useAlerts();
const [alerts, setAlerts] = useState<Alert[]>([]);
const [currency, setCurrency] = useCurrency();
const [projects, setProjects] = useState<Maybe<Project[]>>(null);
const [products, setProducts] = useState<Maybe<Product[]>>(null);
@@ -78,21 +77,13 @@ export const CostInsightsPage = () => {
const { pageFilters, setPageFilters } = useFilters(p => p);
const snoozed = useMemo(() => alerts.alerts.filter(isSnoozed), [
alerts.alerts,
]);
const accepted = useMemo(() => alerts.alerts.filter(isAccepted), [
alerts.alerts,
]);
const dismissed = useMemo(() => alerts.alerts.filter(isDismissed), [
alerts.alerts,
]);
const activeAlerts = useMemo(() => alerts.alerts.filter(isActive), [
alerts.alerts,
]);
const active = useMemo(() => alerts.filter(isAlertActive), [alerts]);
const snoozed = useMemo(() => alerts.filter(isAlertSnoozed), [alerts]);
const accepted = useMemo(() => alerts.filter(isAlertAccepted), [alerts]);
const dismissed = useMemo(() => alerts.filter(isAlertDismissed), [alerts]);
const isActionItemsDisplayed = !!activeAlerts.length;
const isAlertInsightsDisplayed = !!alerts.alerts.length;
const isActionItemsDisplayed = !!active.length;
const isAlertInsightsDisplayed = !!alerts.length;
const {
loadingActions,
@@ -150,7 +141,7 @@ export const CostInsightsPage = () => {
: client.getGroupDailyCost(pageFilters.group, intervals),
]);
setProjects(fetchedProjects);
setAlerts({ alerts: fetchedAlerts });
setAlerts(fetchedAlerts);
setMetricData(fetchedMetricData);
setDailyCost(fetchedDailyCost);
} else {
@@ -175,7 +166,6 @@ export const CostInsightsPage = () => {
loadingActions,
loadingGroups,
loadingBillingDate,
setAlerts,
dispatchLoadingInsights,
dispatchLoadingInitial,
dispatchLoadingNone,
@@ -259,7 +249,7 @@ export const CostInsightsPage = () => {
<Box position="sticky" top={20}>
<CostInsightsNavigation
products={products}
alerts={activeAlerts.length}
alerts={active.length}
/>
</Box>
</Grid>
@@ -280,14 +270,14 @@ export const CostInsightsPage = () => {
owner={pageFilters.group}
groups={groups}
hasCostData={!!dailyCost.aggregation.length}
alerts={activeAlerts.length}
alerts={active.length}
/>
</Grid>
<Collapse in={isActionItemsDisplayed} enter={false}>
<Grid item xs>
<Box px={3} py={6}>
<ActionItems
active={activeAlerts}
active={active}
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
@@ -315,10 +305,11 @@ export const CostInsightsPage = () => {
<Box px={6} py={6} mx={-3} bgcolor="alertBackground">
<AlertInsights
group={pageFilters.group}
active={activeAlerts}
active={active}
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
onChange={setAlerts}
/>
</Box>
</Grid>
@@ -20,7 +20,6 @@ import { FilterProvider } from '../../hooks/useFilters';
import { LoadingProvider } from '../../hooks/useLoading';
import { GroupsProvider } from '../../hooks/useGroups';
import { CurrencyProvider } from '../../hooks/useCurrency';
import { AlertsProvider } from '../../hooks/useAlerts';
import { ScrollProvider } from '../../hooks/useScroll';
import { ConfigProvider } from '../../hooks/useConfig';
import { BillingDateProvider } from '../../hooks/useLastCompleteBillingDate';
@@ -35,9 +34,7 @@ export const CostInsightsPageRoot = () => (
<FilterProvider>
<ScrollProvider>
<CurrencyProvider>
<AlertsProvider>
<CostInsightsPage />
</AlertsProvider>
<CostInsightsPage />
</CurrencyProvider>
</ScrollProvider>
</FilterProvider>
@@ -30,12 +30,13 @@ import { CostOverviewBreakdownChart } from './CostOverviewBreakdownChart';
import { CostOverviewHeader } from './CostOverviewHeader';
import { MetricSelect } from '../MetricSelect';
import { PeriodSelect } from '../PeriodSelect';
import { useConfig, useFilters, useScroll } from '../../hooks';
import { useConfig, useFilters } from '../../hooks';
import { mapFiltersToProps } from './selector';
import { DefaultNavigation } from '../../utils/navigation';
import { findAlways } from '../../utils/assert';
import { Cost, CostInsightsTheme, Maybe, MetricData } from '../../types';
import { useOverviewTabsStyles } from '../../utils/styles';
import { ScrollAnchor } from '../../utils/scroll';
export type CostOverviewCardProps = {
dailyCostData: Cost;
@@ -49,7 +50,6 @@ export const CostOverviewCard = ({
const theme = useTheme<CostInsightsTheme>();
const styles = useOverviewTabsStyles(theme);
const config = useConfig();
const [, , ScrollAnchor] = useScroll();
const [tabIndex, setTabIndex] = useState(0);
const { setDuration, setProject, setMetric, ...filters } = useFilters(
mapFiltersToProps,
@@ -34,9 +34,9 @@ import {
MapLoadingToProps,
useLastCompleteBillingDate,
useLoading,
useScroll,
} from '../../hooks';
import { findAnyKey } from '../../utils/assert';
import { ScrollAnchor } from '../../utils/scroll';
type LoadingProps = (isLoading: boolean) => void;
@@ -60,7 +60,6 @@ export const ProductInsightsCard = ({
}: PropsWithChildren<ProductInsightsCardProps>) => {
const classes = useStyles();
const mountedRef = useRef(false);
const [, , ScrollAnchor] = useScroll();
const [error, setError] = useState<Maybe<Error>>(null);
const dispatchLoading = useLoading(mapLoadingToProps);
const lastCompleteBillingDate = useLastCompleteBillingDate();
@@ -24,7 +24,6 @@ import React, {
import dayjs from 'dayjs';
import {
Box,
Collapse,
FormControl,
FormControlLabel,
RadioGroup,
@@ -50,19 +49,18 @@ export const AlertSnoozeForm = forwardRef<
AlertSnoozeFormProps
>(({ onSubmit, disableSubmit }, ref) => {
const classes = useStyles();
const [error, setError] = useState<Maybe<Error>>(null);
const [duration, setDuration] = useState<Maybe<Duration>>(Duration.P7D);
useEffect(() => disableSubmit(false), [disableSubmit]);
const onFormSubmit: FormEventHandler = e => {
e.preventDefault();
if (duration) {
const repeatInterval = 1;
const inclusiveEndDate = dayjs().format(DEFAULT_DATE_FORMAT);
const today = dayjs().format(DEFAULT_DATE_FORMAT);
onSubmit({
intervals: intervalsOf(duration, inclusiveEndDate, repeatInterval),
intervals: intervalsOf(duration, today, repeatInterval),
});
} else {
setError(new Error('Please select an option.'));
}
};
@@ -73,26 +71,12 @@ export const AlertSnoozeForm = forwardRef<
setDuration(value as Duration);
};
useEffect(() => {
function clearErrorOnFormDataChange() {
disableSubmit(false);
setError(prevError => (prevError ? null : prevError));
}
clearErrorOnFormDataChange();
}, [duration, disableSubmit]);
const isErrorMessageDisplayed = !!error;
return (
<form ref={ref} onSubmit={onFormSubmit}>
<FormControl component="fieldset" error={!!error} fullWidth>
<FormControl component="fieldset" fullWidth>
<Typography color="textPrimary">
<b>For how long?</b>
</Typography>
<Collapse in={isErrorMessageDisplayed}>
<Typography color="error">{error?.message}</Typography>
</Collapse>
<Box mb={1}>
<RadioGroup
name="snooze-alert-options"
-1
View File
@@ -16,7 +16,6 @@
export * from './useConfig';
export * from './useCurrency';
export * from './useAlerts';
export * from './useFilters';
export * from './useCurrency';
export * from './useGroups';
@@ -1,76 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
useReducer,
Dispatch,
SetStateAction,
createContext,
useContext,
PropsWithChildren,
} from 'react';
import { Alert, Maybe } from '../types';
export type AlertsContextProps = {
alerts: AlertState;
setAlerts: Dispatch<SetStateAction<Partial<AlertState>>>;
};
export const AlertsContext = createContext<AlertsContextProps | undefined>(
undefined,
);
export type AlertState = {
alerts: Alert[];
snoozed: Maybe<Alert>;
accepted: Maybe<Alert>;
dismissed: Maybe<Alert>;
};
const initialState: AlertState = {
alerts: [],
snoozed: null,
accepted: null,
dismissed: null,
};
const reducer = (
prevState: AlertState,
action: SetStateAction<Partial<AlertState>>,
): AlertState => ({
...prevState,
...action,
});
export const AlertsProvider = ({ children }: PropsWithChildren<{}>) => {
const [alerts, setAlerts] = useReducer(reducer, initialState);
return (
<AlertsContext.Provider value={{ alerts, setAlerts }}>
{children}
</AlertsContext.Provider>
);
};
export function useAlerts() {
const context = useContext(AlertsContext);
return context
? ([context.alerts, context.setAlerts] as const)
: assertNever();
}
function assertNever(): never {
throw new Error('useAlerts cannot be used outside AlertsContext provider');
}
+3 -53
View File
@@ -15,12 +15,9 @@
*/
import React, {
Dispatch,
ElementType,
SetStateAction,
useState,
useContext,
useEffect,
useRef,
PropsWithChildren,
} from 'react';
import { Maybe } from '../types';
@@ -30,61 +27,16 @@ export type ScrollTo = Maybe<string>;
export type ScrollContextProps = {
scroll: ScrollTo;
setScroll: Dispatch<SetStateAction<ScrollTo>>;
ScrollAnchor: ElementType<ScrollAnchorProps>;
};
export interface ScrollAnchorProps extends ScrollIntoViewOptions {
id: ScrollTo;
top?: number;
left?: number;
}
export const ScrollContext = React.createContext<
ScrollContextProps | undefined
>(undefined);
export const ScrollAnchor = ({
id,
block,
inline,
left = 0,
top = -20,
behavior = 'smooth',
}: ScrollAnchorProps) => {
const divRef = useRef<HTMLDivElement>(null);
const [scroll, setScroll] = useScroll();
useEffect(() => {
function scrollIntoView() {
const options = {
behavior: behavior || 'auto',
block: block || 'start',
inline: inline || 'nearest',
};
if (divRef.current && scroll === id) {
divRef.current.scrollIntoView(options);
setScroll(null);
}
}
scrollIntoView();
}, [scroll, setScroll, id, behavior, block, inline]);
return (
<div
ref={divRef}
style={{ position: 'absolute', height: 0, width: 0, top, left }}
data-testid={`scroll-test-${id}`}
/>
);
};
export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => {
const [scroll, setScroll] = useState<ScrollTo>(null);
return (
<ScrollContext.Provider value={{ scroll, setScroll, ScrollAnchor }}>
<ScrollContext.Provider value={{ scroll, setScroll }}>
{children}
</ScrollContext.Provider>
);
@@ -101,11 +53,9 @@ export function useScroll() {
assertNever();
}
return [context.scroll, context.setScroll, context.ScrollAnchor] as const;
return [context.scroll, context.setScroll] as const;
}
function assertNever(): never {
throw new Error(
`Cannot use useScroll or ScrollAnchor outside ScrollProvider`,
);
throw new Error(`Cannot use useScroll outside ScrollProvider`);
}
@@ -0,0 +1,109 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ReactNode } from 'react';
import { formOf } from './alerts';
import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms';
import { Alert, AlertStatus, AlertFormProps } from '../types';
type Props = AlertFormProps<Alert, any>;
const createMockForm = (children: ReactNode) =>
React.forwardRef<HTMLFormElement, Props>((props, ref) => (
<form ref={ref} onSubmit={props.onSubmit}>
{children}
</form>
));
const snoozeDefault: Alert = {
title: 'title',
subtitle: 'subtitle',
onSnoozed: jest.fn(),
};
const snoozeCustom: Alert = {
title: 'title',
subtitle: 'subtitle',
onSnoozed: jest.fn(),
SnoozeForm: createMockForm('Snooze'),
};
const snoozeNull: Alert = {
title: 'title',
subtitle: 'subtitle',
onSnoozed: jest.fn(),
SnoozeForm: null,
};
const acceptDefault: Alert = {
title: 'title',
subtitle: 'subtitle',
onAccepted: jest.fn(),
};
const acceptCustom: Alert = {
title: 'title',
subtitle: 'subtitle',
onAccepted: jest.fn(),
AcceptForm: createMockForm('Accept'),
};
const acceptNull: Alert = {
title: 'title',
subtitle: 'subtitle',
onAccepted: jest.fn(),
AcceptForm: null,
};
const dismissDefault: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
};
const dismissCustom: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
DismissForm: createMockForm('Dismiss'),
};
const dismissNull: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
DismissForm: null,
};
describe('formOf', () => {
describe.each`
msg | alert | status | expected
${'default snooze form'} | ${snoozeDefault} | ${AlertStatus.Snoozed} | ${AlertSnoozeForm}
${'custom snooze form'} | ${snoozeCustom} | ${AlertStatus.Snoozed} | ${snoozeCustom.SnoozeForm}
${'null snooze form'} | ${snoozeNull} | ${AlertStatus.Snoozed} | ${null}
${'default accept form'} | ${acceptDefault} | ${AlertStatus.Accepted} | ${AlertAcceptForm}
${'custom accept form'} | ${acceptCustom} | ${AlertStatus.Accepted} | ${acceptCustom.AcceptForm}
${'null accept form'} | ${acceptNull} | ${AlertStatus.Accepted} | ${null}
${'default dismiss form'} | ${dismissDefault} | ${AlertStatus.Dismissed} | ${AlertDismissForm}
${'custom dismiss form'} | ${dismissCustom} | ${AlertStatus.Dismissed} | ${dismissCustom.DismissForm}
${'null dismiss form'} | ${dismissNull} | ${AlertStatus.Dismissed} | ${null}
${'no form or status'} | ${null} | ${null} | ${null}
`('Should render the correct form', ({ msg, alert, status, expected }) => {
it(`for ${msg}`, () => {
const result = formOf(alert, status);
expect(result).toBe(expected);
});
});
});
+110 -14
View File
@@ -14,22 +14,118 @@
* limitations under the License.
*/
import { Alert, AlertStatus } from '../types';
import { Alert, AlertForm, AlertStatus, Maybe } from '../types';
import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms';
const createStatusHandler = (status?: string) => (alert: Alert) =>
const createAlertHandler = (status?: AlertStatus) => (alert: Alert) =>
alert.status === status;
export const isActive = createStatusHandler();
export const isSnoozed = createStatusHandler(AlertStatus.Snoozed);
export const isAccepted = createStatusHandler(AlertStatus.Accepted);
export const isDismissed = createStatusHandler(AlertStatus.Dismissed);
export const isAlertActive = (alert: Alert) => !hasProperty(alert, 'status');
export const isAlertSnoozed = createAlertHandler(AlertStatus.Snoozed);
export const isAlertAccepted = createAlertHandler(AlertStatus.Accepted);
export const isAlertDismissed = createAlertHandler(AlertStatus.Dismissed);
const createStatusHandler = (status: AlertStatus) => (s: Maybe<AlertStatus>) =>
s === status;
export const isStatusSnoozed = createStatusHandler(AlertStatus.Snoozed);
export const isStatusAccepted = createStatusHandler(AlertStatus.Accepted);
export const isStatusDismissed = createStatusHandler(AlertStatus.Dismissed);
const createAlertEventHandler = (
onEvent: 'onSnoozed' | 'onAccepted' | 'onDismissed',
) => (alert: Maybe<Alert>): boolean => hasProperty(alert, onEvent);
export const isSnoozeEnabled = createAlertEventHandler('onSnoozed');
export const isAcceptEnabled = createAlertEventHandler('onAccepted');
export const isDismissEnabled = createAlertEventHandler('onDismissed');
const createFormEnabledHandler = (
Form: 'SnoozeForm' | 'AcceptForm' | 'DismissForm',
) => (alert: Maybe<Alert>): boolean => {
if (!alert) return false;
if (alert[Form] === null) return false;
switch (Form) {
case 'SnoozeForm':
return isSnoozeEnabled(alert);
case 'AcceptForm':
return isAcceptEnabled(alert);
case 'DismissForm':
return isDismissEnabled(alert);
default:
return false;
}
};
export const isSnoozeFormEnabled = createFormEnabledHandler('SnoozeForm');
export const isAcceptFormEnabled = createFormEnabledHandler('AcceptForm');
export const isDismissFormEnabled = createFormEnabledHandler('DismissForm');
/**
* Utility for determining if a form is disabled.
* When a form is disabled, the dialog button's type should convert from submit to button.
* @param alert
* @param status
*/
export const isFormDisabled = (
alert: Maybe<Alert>,
status: Maybe<AlertStatus>,
): boolean => {
switch (status) {
case AlertStatus.Snoozed:
return alert?.SnoozeForm === null;
case AlertStatus.Accepted:
return alert?.AcceptForm === null;
case AlertStatus.Dismissed:
return alert?.DismissForm === null;
default:
return false;
}
};
export function formOf(
alert: Maybe<Alert>,
status: Maybe<AlertStatus>,
): Maybe<AlertForm> {
switch (status) {
case AlertStatus.Snoozed: {
const SnoozeForm = alert?.SnoozeForm ?? AlertSnoozeForm;
return isSnoozeFormEnabled(alert) ? SnoozeForm : null;
}
case AlertStatus.Accepted: {
const AcceptForm = alert?.AcceptForm ?? AlertAcceptForm;
return isAcceptFormEnabled(alert) ? AcceptForm : null;
}
case AlertStatus.Dismissed: {
const DismissForm = alert?.DismissForm ?? AlertDismissForm;
return isDismissFormEnabled(alert) ? DismissForm : null;
}
default:
return null;
}
}
/**
* Utility for choosing from a fixed set of values for a given alert status.
* @param status
* @param values
*/
export function choose<T>(
status: Maybe<AlertStatus>,
values: [T, T, T],
none: T,
): T {
switch (status) {
case AlertStatus.Snoozed:
return values[0];
case AlertStatus.Accepted:
return values[1];
case AlertStatus.Dismissed:
return values[2];
default:
return none;
}
}
export function hasProperty(alert: Maybe<Alert>, prop: keyof Alert): boolean {
return prop in (alert ?? {});
}
export const sumOfAllAlerts = (sum: number, alerts: Alert[]) =>
sum + alerts.length;
export function choose<T>(
status: readonly [boolean, boolean, boolean],
values: [T, T, T],
): T | undefined {
const i = status.indexOf(true);
return values[i];
}
@@ -0,0 +1,65 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useRef } from 'react';
import { ScrollTo, useScroll } from '../hooks/useScroll';
/*
Utility component use in conjuction with useScroll that allows scrollable components to control behavior and offset.
1. ScrollAnchor must be a direct child of a scrollable component.
2. ScrollAnchor's parent position must be relative.
3. ScrollAnchor's id must be unique.
*/
export interface ScrollAnchorProps extends ScrollIntoViewOptions {
id: ScrollTo;
top?: number;
left?: number;
}
export const ScrollAnchor = ({
id,
left = 0,
top = -20,
block = 'start',
inline = 'nearest',
behavior = 'smooth',
}: ScrollAnchorProps) => {
const divRef = useRef<HTMLDivElement>(null);
const [scroll, setScroll] = useScroll();
useEffect(() => {
function scrollIntoView() {
if (divRef.current && scroll === id) {
divRef.current.scrollIntoView({
block,
inline,
behavior,
});
setScroll(null);
}
}
scrollIntoView();
}, [scroll, setScroll, id, behavior, block, inline]);
return (
<div
ref={divRef}
style={{ position: 'absolute', height: 0, width: 0, top, left }}
data-testid={`scroll-test-${id}`}
/>
);
};
-27
View File
@@ -22,7 +22,6 @@ import {
IdentityApi,
identityApiRef,
} from '@backstage/core';
import { AlertsContext, AlertsContextProps } from '../hooks/useAlerts';
import { LoadingContext, LoadingContextProps } from '../hooks/useLoading';
import { GroupsContext, GroupsContextProps } from '../hooks/useGroups';
import { FilterContext, FilterContextProps } from '../hooks/useFilters';
@@ -166,7 +165,6 @@ export const MockScrollProvider = ({ children }: MockScrollProviderProps) => {
const defaultContext: ScrollContextProps = {
scroll: null,
setScroll: jest.fn(),
ScrollAnchor: jest.fn(() => <div />),
};
return (
<ScrollContext.Provider value={defaultContext}>
@@ -233,28 +231,3 @@ export const MockCostInsightsApiProvider = ({
return <ApiProvider apis={defaultContext}>{children}</ApiProvider>;
};
export type MockAlertsProviderContextProps = PartialPropsWithChildren<
AlertsContextProps
>;
export const MockAlertsProvider = ({
children,
...context
}: MockAlertsProviderContextProps) => {
const defaultContext: AlertsContextProps = {
alerts: {
alerts: [],
snoozed: null,
accepted: null,
dismissed: null,
},
setAlerts: jest.fn(),
};
return (
<AlertsContext.Provider value={{ ...defaultContext, ...context }}>
{children}
</AlertsContext.Provider>
);
};