refactor
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user