add alert hooks

This commit is contained in:
Ryan Vazquez
2021-01-12 16:44:30 -05:00
parent c3a3e607e7
commit d65b34409a
49 changed files with 2669 additions and 266 deletions
@@ -0,0 +1,156 @@
/*
* 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 from 'react';
import pluralize from 'pluralize';
import { MigrationAlertCard } from '../components/MigrationAlertCard';
import { CostInsightsApi } from '../api';
import {
Alert,
AlertForm,
AlertOptions,
AlertStatus,
AlertSnoozeFormData,
ChangeStatistic,
Entity,
} from '../types';
import { MigrationDismissForm, MigrationDismissFormData } from '../forms';
export interface MigrationData {
startDate: string;
endDate: string;
change: ChangeStatistic;
services: Array<Entity>;
}
export interface MigrationAlert extends Alert {
api: CostInsightsApi;
data: MigrationData;
}
/**
* The alert below is an example of an Alert implementation using event hooks.
*
* Alerts can be customized to be accepted, dismissed snoozed or any combination
* by defining a corresponding hook on the alert instance.
*
* For example, defining an onDismissed hook will render a dismiss button that, when clicked, will
* generate a dialog prompting the user to provide a reason for dismissing the alert.
* Dismiss form data will be passed to the hook, which must eventually return a new set of alerts.
* Errors thrown within hooks will generate a snackbar, which can be used to display a
* user-friendly error message.
*
* Cost Insights provides default forms for each hook, which can be overriden by providing a custom form component.
*/
export class KubernetesMigrationAlert implements MigrationAlert {
api: CostInsightsApi;
data: MigrationData;
subtitle =
'Services running on Kubernetes are estimated to save 50% or more compared to Compute Engine.';
// Override default dismiss form with custom form component.
// SnoozeForm: AlertForm<MigrationAlert, MigrationSnoozeFormData> = MigrationSnoozeForm;
// AcceptForm: AlertForm<MigrationAlert, MigrationAcceptFormData> = MigrationAcceptForm;
DismissForm: AlertForm<
MigrationAlert,
MigrationDismissFormData
> = MigrationDismissForm;
constructor(api: CostInsightsApi, data: MigrationData) {
this.api = api;
this.data = data;
}
get title() {
return `Consider migrating ${pluralize(
'service',
this.data.services.length,
true,
)} to Kubernetes.`;
}
get element() {
const subheader = `${pluralize(
'Compute Engine role',
this.data.services.length,
true,
)}, sorted by cost`;
return (
<MigrationAlertCard
data={this.data}
title="Migrate to Kubernetes"
subheader={subheader}
currentProduct="Compute Engine"
comparedProduct="Kubernetes"
/>
);
}
/* Displays a custom dismiss form. */
async onDismissed(
options: AlertOptions<MigrationDismissFormData>,
): Promise<Alert[]> {
const alerts = await this.api.getAlerts(options.group);
return new Promise(resolve =>
setTimeout(resolve, 750, [
...alerts.slice(0, 2),
{
title: this.title,
subtitle: this.subtitle,
/**
* If a status property is defined, the alert will be filtered from the action items list
* but still appear grouped with other action items of the same status in the Hidden Action Items section.
*/
status: AlertStatus.Dismissed,
},
]),
);
}
/* Displays default accept form. */
async onSnoozed(
options: AlertOptions<AlertSnoozeFormData>,
): Promise<Alert[]> {
const alerts = await this.api.getAlerts(options.group);
return new Promise(resolve =>
setTimeout(resolve, 750, [
...alerts.slice(0, 2),
{
title: this.title,
subtitle: this.subtitle,
status: AlertStatus.Snoozed,
},
]),
);
}
/* Displays default accept form. */
async onAccepted(options: AlertOptions): Promise<Alert[]> {
const alerts = await this.api.getAlerts(options.group);
return new Promise(resolve =>
setTimeout(resolve, 750, [
...alerts.slice(0, 2),
{
title: this.title,
subtitle: this.subtitle,
status: AlertStatus.Accepted,
},
]),
);
}
}
@@ -0,0 +1,45 @@
/*
* 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 from 'react';
import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard';
import { Alert, ProjectGrowthData } from '../types';
/**
* The alert below is an example of an Alert implementation; the CostInsightsApi permits returning
* any implementation of the Alert type, so adopters can create their own. The CostInsightsApi
* fetches alert data from the backend, then creates Alert classes with the data.
*/
export class ProjectGrowthAlert implements Alert {
data: ProjectGrowthData;
url = '/cost-insights/investigating-growth';
subtitle =
'Cost growth outpacing business growth is unsustainable long-term.';
constructor(data: ProjectGrowthData) {
this.data = data;
}
get title() {
return `Investigate cost growth in project ${this.data.project}`;
}
get element() {
return <ProjectGrowthAlertCard alert={this.data} />;
}
}
@@ -0,0 +1,43 @@
/*
* 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 from 'react';
import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard';
import { Alert, AlertStatus, UnlabeledDataflowData } from '../types';
/**
* The alert below is an example of an Alert implementation; the CostInsightsApi permits returning
* any implementation of the Alert type, so adopters can create their own. The CostInsightsApi
* fetches alert data from the backend, then creates Alert classes with the data.
*/
export class UnlabeledDataflowAlert implements Alert {
data: UnlabeledDataflowData;
status?: AlertStatus;
url = '/cost-insights/labeling-jobs';
title = 'Add labels to workflows';
subtitle =
'Labels show in billing data, enabling cost insights for each workflow.';
constructor(data: UnlabeledDataflowData) {
this.data = data;
}
get element() {
return <UnlabeledDataflowAlertCard alert={this.data} />;
}
}
@@ -13,22 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { Fragment } from 'react';
import { Paper, Divider } from '@material-ui/core';
import { AlertActionCard } from './AlertActionCard';
import { Alert } from '../../types';
type AlertActionCardList = {
alerts: Array<Alert>;
};
export const AlertActionCardList = ({ alerts }: AlertActionCardList) => (
<Paper>
{alerts.map((alert, index) => (
<Fragment key={`alert-${index}`}>
<AlertActionCard alert={alert} number={index + 1} />
{index < alerts.length - 1 && <Divider variant="fullWidth" />}
</Fragment>
))}
</Paper>
);
export { ProjectGrowthAlert } from './ProjectGrowthAlert';
export { UnlabeledDataflowAlert } from './UnlabeledDataflowAlert';
export { KubernetesMigrationAlert } from './KubernetesMigrationAlert';
export type { MigrationAlert } from './KubernetesMigrationAlert';
+30 -1
View File
@@ -31,7 +31,8 @@ import {
import {
ProjectGrowthAlert,
UnlabeledDataflowAlert,
} from '../src/utils/alerts';
KubernetesMigrationAlert,
} from '../src/alerts';
import {
trendlineOf,
changeOf,
@@ -174,6 +175,34 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
const alerts: Alert[] = await this.request({ group }, [
new ProjectGrowthAlert(projectGrowthData),
new UnlabeledDataflowAlert(unlabeledDataflowData),
new KubernetesMigrationAlert(this, {
startDate: '2021-01-24',
endDate: '2020-02-24',
change: {
ratio: 0,
amount: 0,
},
services: [
{
id: 'service-a',
aggregation: [20_000, 10_000],
change: {
ratio: -1,
amount: -10_000,
},
entities: {},
},
{
id: 'service-b',
aggregation: [30_000, 15_000],
change: {
ratio: -1,
amount: 15_000,
},
entities: {},
},
],
}),
]);
return alerts;
@@ -16,9 +16,9 @@
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { AlertActionCard } from './AlertActionCard';
import { ActionItemCard } from './ActionItemCard';
import { MockScrollProvider } from '../../utils/tests';
import { ProjectGrowthAlert } from '../../utils/alerts';
import { ProjectGrowthAlert } from '../../alerts';
import { ProjectGrowthData } from '../../types';
const data: ProjectGrowthData = {
@@ -31,11 +31,11 @@ const data: ProjectGrowthData = {
};
const alert = new ProjectGrowthAlert(data);
describe('<AlertActionCard/>', () => {
describe('<ActionItemCard/>', () => {
it('Renders an alert', async () => {
const rendered = await renderInTestApp(
<MockScrollProvider>
<AlertActionCard alert={alert} number={1} />,
<ActionItemCard alert={alert} avatar={<div>1</div>} />
</MockScrollProvider>,
);
@@ -0,0 +1,62 @@
/*
* 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, { MouseEventHandler } from 'react';
import classnames from 'classnames';
import { Card, CardHeader } from '@material-ui/core';
import { useScroll } from '../../hooks';
import { Alert } from '../../types';
import { useActionItemCardStyles as useStyles } from '../../utils/styles';
type ActionItemCardProps = {
alert: Alert;
number?: number;
avatar?: JSX.Element;
disableScroll?: boolean;
};
export const ActionItemCard = ({
alert,
avatar,
number,
disableScroll = false,
}: ActionItemCardProps) => {
const classes = useStyles();
const rootClasses = classnames(classes.root, {
[classes.activeRoot]: !disableScroll,
});
const [, setScroll] = useScroll();
const onActionItemClick: MouseEventHandler = () => {
if (!disableScroll && number) {
setScroll(`alert-${number}`);
}
};
return (
<Card className={classes.card} raised={false} onClick={onActionItemClick}>
<CardHeader
classes={{
root: rootClasses,
action: classes.action,
title: classes.title,
}}
title={alert.title}
subheader={alert.subtitle}
avatar={avatar}
/>
</Card>
);
};
@@ -0,0 +1,86 @@
/*
* 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 from 'react';
import { render } from '@testing-library/react';
import { ActionItems } from './ActionItems';
import { MockScrollProvider } from '../../utils/tests';
function renderInContext(children: JSX.Element) {
return render(<MockScrollProvider>{children}</MockScrollProvider>);
}
describe('<ActionItems/>', () => {
it('should not display status buttons if there no active alerts', () => {
const { queryByRole } = renderInContext(
<ActionItems active={[]} snoozed={[]} accepted={[]} dismissed={[]} />,
);
expect(queryByRole('button', { name: 'snoozed' })).not.toBeInTheDocument();
expect(queryByRole('button', { name: 'accepted' })).not.toBeInTheDocument();
expect(
queryByRole('button', { name: 'dismissed' }),
).not.toBeInTheDocument();
});
it('should display status buttons with correct badge number', () => {
const { getByText, getByRole, getAllByText } = renderInContext(
<ActionItems
active={[
{
title: 'active-alert-title',
subtitle: 'active-alert-subtitle',
},
]}
snoozed={[
{
title: 'test-title',
subtitle: 'test-subtitle',
},
]}
accepted={[
{
title: 'test-title',
subtitle: 'test-subtitle',
},
{
title: 'test-title',
subtitle: 'test-subtitle',
},
]}
dismissed={[
{
title: 'test-title',
subtitle: 'test-subtitle',
},
{
title: 'test-title',
subtitle: 'test-subtitle',
},
{
title: 'test-title',
subtitle: 'test-subtitle',
},
]}
/>,
);
expect(getAllByText('1')).toHaveLength(2); // should be a badge of 1 and action item number of 1
expect(getByText('2')).toBeInTheDocument();
expect(getByText('3')).toBeInTheDocument();
expect(getByRole('button', { name: 'snoozed' })).toBeInTheDocument();
expect(getByRole('button', { name: 'accepted' })).toBeInTheDocument();
expect(getByRole('button', { name: 'dismissed' })).toBeInTheDocument();
});
});
@@ -0,0 +1,132 @@
/*
* 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, { Fragment, MouseEventHandler } from 'react';
import {
Avatar,
Badge,
Box,
IconButtonProps,
IconButton,
Paper,
Divider,
Tooltip,
} from '@material-ui/core';
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 { ActionItemCard } from './ActionItemCard';
import { Alert, AlertStatus } from '../../types';
import { useScroll, ScrollType } from '../../hooks';
import { useActionItemCardStyles as useStyles } from '../../utils/styles';
type ActionItemsProps = {
active: Alert[];
snoozed: Alert[];
accepted: Alert[];
dismissed: Alert[];
};
export const ActionItems = ({
active,
snoozed,
accepted,
dismissed,
}: ActionItemsProps) => {
const classes = useStyles();
const [, setScroll] = useScroll();
const isSnoozedButtonDisplayed = !!snoozed.length;
const isAcceptedButtonDisplayed = !!accepted.length;
const isDismissedButtonDisplayed = !!dismissed.length;
const isStatusButtonGroupDisplayed = !!active.length;
const onStatusButtonClick: MouseEventHandler = () =>
setScroll(ScrollType.AlertSummary);
return (
<>
<Paper>
{active.map((alert, index) => (
<Fragment key={`alert-${index}`}>
<ActionItemCard
alert={alert}
number={index + 1}
avatar={<Avatar className={classes.avatar}>{index + 1}</Avatar>}
/>
{index < active.length - 1 && <Divider />}
</Fragment>
))}
</Paper>
{isStatusButtonGroupDisplayed && (
<Box display="flex" justifyContent="flex-end" mt={2}>
{isAcceptedButtonDisplayed && (
<AlertStatusButton
title="Accepted"
aria-label={AlertStatus.Accepted}
icon={<AcceptIcon />}
amount={accepted.length}
onClick={onStatusButtonClick}
/>
)}
{isSnoozedButtonDisplayed && (
<AlertStatusButton
title="Snoozed"
aria-label={AlertStatus.Snoozed}
amount={snoozed.length}
icon={<SnoozeIcon />}
onClick={onStatusButtonClick}
/>
)}
{isDismissedButtonDisplayed && (
<AlertStatusButton
title="Dismissed"
aria-label={AlertStatus.Dismissed}
icon={<DismissIcon />}
amount={dismissed.length}
onClick={onStatusButtonClick}
/>
)}
</Box>
)}
</>
);
};
type AlertStatusButtonProps = {
title: string;
amount: number;
icon: JSX.Element;
onClick: MouseEventHandler;
} & IconButtonProps;
const AlertStatusButton = ({
title,
amount,
icon,
onClick,
...buttonProps
}: AlertStatusButtonProps) => (
<Tooltip title={title}>
<IconButton
onClick={onClick}
role="button"
aria-hidden={false}
{...buttonProps}
>
<Badge badgeContent={amount}>{icon}</Badge>
</IconButton>
</Tooltip>
);
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { ActionItems } from './ActionItems';
export { ActionItemCard } from './ActionItemCard';
@@ -1,45 +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 from 'react';
import { Avatar, Card, CardHeader } from '@material-ui/core';
import { useScroll } from '../../hooks';
import { Alert } from '../../types';
import {
useAlertActionCardHeader as useHeaderStyles,
useAlertActionCardStyles as useStyles,
} from '../../utils/styles';
type AlertActionCardProps = {
alert: Alert;
number: number;
};
export const AlertActionCard = ({ alert, number }: AlertActionCardProps) => {
const { scrollIntoView } = useScroll(`alert-${number}`);
const headerClasses = useHeaderStyles();
const classes = useStyles();
return (
<Card className={classes.card} raised={false} onClick={scrollIntoView}>
<CardHeader
classes={headerClasses}
avatar={<Avatar className={classes.avatar}>{number}</Avatar>}
title={alert.title}
subheader={alert.subtitle}
/>
</Card>
);
};
@@ -0,0 +1,169 @@
/*
* 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 from 'react';
import { AlertDialog } from './AlertDialog';
import { render } from '@testing-library/react';
import {
Alert,
AlertFormProps,
AlertSnoozeOptions,
AlertDismissOptions,
} from '../../types';
type MockFormDataProps = AlertFormProps<Alert>;
const MockForm = React.forwardRef<HTMLFormElement, MockFormDataProps>(
(props, ref) => (
<form ref={ref} onSubmit={props.onSubmit}>
You. Complete. Me.
</form>
),
);
describe('<AlertDialog />', () => {
const snoozableAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onSnoozed: jest.fn(),
};
const dimissableAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
};
const customSnoozeAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onSnoozed: jest.fn(),
SnoozeForm: MockForm,
};
const customDismissAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
DismissForm: MockForm,
};
const customAcceptAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onAccepted: jest.fn(),
AcceptForm: MockForm,
};
it('Displays a default snooze form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={snoozableAlert}
accepted={null}
dismissed={null}
onClose={jest.fn}
onSubmit={jest.fn}
/>,
);
expect(getByText('For how long?')).toBeInTheDocument();
expect(getByText('Snooze this action item?')).toBeInTheDocument();
expect(
getByText('This action item will be snoozed for all of Ramones.'),
).toBeInTheDocument();
AlertSnoozeOptions.forEach(a =>
expect(getByText(a.label)).toBeInTheDocument(),
);
});
it('Displays a custom snooze form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={customSnoozeAlert}
accepted={null}
dismissed={null}
onClose={jest.fn}
onSubmit={jest.fn}
/>,
);
expect(getByText('You. Complete. Me.')).toBeInTheDocument();
expect(getByText('Snooze this action item?')).toBeInTheDocument();
expect(
getByText('This action item will be snoozed for all of Ramones.'),
).toBeInTheDocument();
});
it('Displays a default dismiss form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={null}
accepted={null}
dismissed={dimissableAlert}
onClose={jest.fn}
onSubmit={jest.fn}
/>,
);
expect(getByText('Dismiss this action item?')).toBeInTheDocument();
expect(
getByText('This action item will be dismissed for all of Ramones.'),
).toBeInTheDocument();
AlertDismissOptions.forEach(a =>
expect(getByText(a.label)).toBeInTheDocument(),
);
});
it('Displays a custom dismiss form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={null}
accepted={null}
dismissed={customDismissAlert}
onClose={jest.fn}
onSubmit={jest.fn}
/>,
);
expect(getByText('Dismiss this action item?')).toBeInTheDocument();
expect(getByText('You. Complete. Me.')).toBeInTheDocument();
expect(
getByText('This action item will be dismissed for all of Ramones.'),
).toBeInTheDocument();
});
it('Displays a custom accept form', () => {
const { getByText } = render(
<AlertDialog
open
group="Ramones"
snoozed={null}
accepted={customAcceptAlert}
dismissed={null}
onClose={jest.fn}
onSubmit={jest.fn}
/>,
);
expect(getByText('Accept this action item?')).toBeInTheDocument();
expect(getByText('You. Complete. Me.')).toBeInTheDocument();
expect(
getByText('This action item will be accepted for all of Ramones.'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,210 @@
/*
* 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, { useEffect, useRef, useState } from 'react';
import { default as CloseIcon } from '@material-ui/icons/Close';
import {
Box,
Button,
Divider,
Dialog,
DialogActions,
IconButton,
DialogContent,
Typography,
} from '@material-ui/core';
import {
AlertAcceptForm,
AlertDismissForm,
AlertSnoozeForm,
} from '../../forms';
import { useAlertDialogStyles as useStyles } from '../../utils/styles';
import { choose } from '../../utils/alerts';
import { Alert, Maybe } from '../../types';
const DEFAULT_FORM_ID = 'alert-form';
type AlertDialogProps = {
open: boolean;
group: string;
snoozed: Maybe<Alert>;
accepted: Maybe<Alert>;
dismissed: Maybe<Alert>;
onClose: () => void;
onSubmit: (data: any) => void;
};
export const AlertDialog = ({
open,
group,
snoozed,
accepted,
dismissed,
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);
useEffect(() => {
if (open) {
setDisabled(true);
} else {
setDisabled(false);
}
}, [open]);
function disableSubmit(isDisabled: boolean) {
setDisabled(isDisabled);
}
function onDialogClose() {
onClose();
setDisabled(true);
}
const SnoozeForm = snoozed?.SnoozeForm ?? AlertSnoozeForm;
const AcceptForm = accepted?.AcceptForm ?? AlertAcceptForm;
const DismissForm = dismissed?.DismissForm ?? AlertDismissForm;
const isSnoozeFormDisplayed = !!snoozed?.onSnoozed;
const isAcceptFormDisplayed = !!accepted?.onAccepted;
const isDismissFormDisplayed = !!dismissed?.onDismissed;
const status = [
isAcceptFormDisplayed,
isSnoozeFormDisplayed,
isDismissFormDisplayed,
] as const;
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;
}
},
};
return (
<Dialog
open={open}
onClose={onDialogClose}
scroll="body"
maxWidth="lg"
TransitionProps={TransitionProps}
>
<Box display="flex" justifyContent="flex-end">
<IconButton
className={classes.icon}
disableRipple
onClick={onDialogClose}
>
<CloseIcon aria-label="close dialog" />
</IconButton>
</Box>
<DialogContent className={classes.content}>
<Box mb={1.5}>
<Typography variant="h5">
<b>
{choose(status, ['Accept', 'Snooze', 'Dismiss'])} this action
item?
</b>
</Typography>
<Typography variant="h6" color="textSecondary">
<b>
This action item will be{' '}
{choose(status, ['accepted', 'snoozed', 'dismissed'])} for all of{' '}
{group}.
</b>
</Typography>
</Box>
<Box
display="flex"
flexDirection="column"
bgcolor="alertBackground"
p={2}
mb={1.5}
borderRadius={4}
>
<Typography>
<b>
{choose(status, [
accepted?.title,
snoozed?.title,
dismissed?.title,
])}
</b>
</Typography>
<Typography color="textSecondary">
{choose(status, [
accepted?.subtitle,
snoozed?.subtitle,
dismissed?.subtitle,
])}
</Typography>
</Box>
{isSnoozeFormDisplayed && (
<SnoozeForm
ref={snoozeRef}
alert={snoozed!}
onSubmit={onSubmit}
disableSubmit={disableSubmit}
/>
)}
{isDismissFormDisplayed && (
<DismissForm
ref={dismissRef}
alert={dismissed!}
onSubmit={onSubmit}
disableSubmit={disableSubmit}
/>
)}
{isAcceptFormDisplayed && (
<AcceptForm
ref={acceptRef}
alert={accepted!}
onSubmit={onSubmit}
disableSubmit={disableSubmit}
/>
)}
</DialogContent>
<Divider />
<DialogActions className={classes.actions} disableSpacing>
<Button
disabled={isButtonDisabled}
type="submit"
form={DEFAULT_FORM_ID}
variant="contained"
color="primary"
>
{choose(status, ['Accept', 'Snooze', 'Dismiss'])}
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,89 @@
/*
* 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 from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { AlertInsights } from './AlertInsights';
import {
MockScrollProvider,
MockAlertsProvider,
MockLoadingProvider,
} from '../../utils/tests';
function renderInContext(children: JSX.Element) {
return render(
<MockLoadingProvider>
<MockScrollProvider>
<MockAlertsProvider>{children}</MockAlertsProvider>
</MockScrollProvider>
</MockLoadingProvider>,
);
}
describe('<AlertInsights />', () => {
it('should display the correct header if there are active action items', () => {
const { getByText, queryByText } = renderInContext(
<AlertInsights
group="black-sabbath"
active={[
{
title: 'Master of Reality',
subtitle: 'Paranoid',
},
]}
snoozed={[]}
accepted={[]}
dismissed={[]}
/>,
);
expect(
getByText(
'This section outlines suggested action items your team can address to improve cloud costs.',
),
).toBeInTheDocument();
expect(queryByText('Hidden Action Item')).not.toBeInTheDocument();
});
it('should display alert summary if there are hidden action items', async () => {
const { getByText, getByRole } = renderInContext(
<AlertInsights
group="black-sabbath"
active={[]}
snoozed={[
{
title: 'Vol. 4',
subtitle: 'Sabotage',
},
]}
accepted={[]}
dismissed={[]}
/>,
);
expect(
getByText(
"All of your team's action items are hidden. Maybe it's time to give them another look?",
),
).toBeInTheDocument();
expect(getByText('Hidden Action Item')).toBeInTheDocument();
fireEvent.click(getByRole('button', { name: 'expand' }));
await waitFor(() => getByRole('img', { name: 'snoozed' }));
expect(getByText('Vol. 4')).toBeInTheDocument();
expect(getByText('Sabotage')).toBeInTheDocument();
});
});
@@ -14,31 +14,203 @@
* limitations under the License.
*/
import React from 'react';
import { Grid } from '@material-ui/core';
import { AlertInsightsSection } from './AlertInsightsSection';
import React, { useCallback, 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';
import { AlertDialog } from './AlertDialog';
import { AlertStatusSummary } from './AlertStatusSummary';
import { AlertStatusSummaryButton } from './AlertStatusSummaryButton';
import { AlertInsightsHeader } from './AlertInsightsHeader';
import { Alert } from '../../types';
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';
const title = "Your team's action items";
const subtitle =
'This section outlines suggested action items your team can address to improve cloud costs.';
type MapLoadingtoAlerts = (isLoading: boolean) => void;
const mapLoadingToAlerts: MapLoadingToProps<MapLoadingtoAlerts> = ({
dispatch,
}) => (isLoading: boolean) =>
dispatch({ [DefaultLoadingAction.CostInsightsAlerts]: isLoading });
type AlertInsightsProps = {
alerts: Array<Alert>;
group: string;
active: Alert[];
snoozed: Alert[];
accepted: Alert[];
dismissed: Alert[];
};
export const AlertInsights = ({ alerts }: AlertInsightsProps) => (
<Grid container direction="column" spacing={2}>
<Grid item>
<AlertInsightsHeader title={title} subtitle={subtitle} />
</Grid>
<Grid item container direction="column" spacing={4}>
{alerts.map((alert, index) => (
<Grid item key={`alert-card-${index}`}>
<AlertInsightsSection alert={alert} number={index + 1} />
export const AlertInsights = ({
group,
active,
snoozed,
accepted,
dismissed,
}: AlertInsightsProps) => {
const [alerts, setAlerts] = useAlerts();
const [scroll, , ScrollAnchor] = useScroll();
const dispatchLoadingAlerts = useLoading(mapLoadingToAlerts);
// Allow users to pass null values for data.
const [data, setData] = useState<Maybe<any>>(undefined);
const [error, setError] = useState<Maybe<Error>>(null);
const [isDialogOpen, setDialogOpen] = useState(false);
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(
options: AlertOptions,
callback: (options: AlertOptions) => Promise<Alert[]>,
) {
closeDialog();
dispatchLoadingAlerts(true);
try {
const a: Alert[] = await callback(options);
setAlerts({ alerts: a });
} catch (e) {
setError(e);
} finally {
dispatchLoadingAlerts(false);
}
}
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;
if (data !== undefined) {
if (onSnoozed) {
callHandler(options, onSnoozed);
} else if (onAccepted) {
callHandler(options, onAccepted);
} else if (onDismissed) {
callHandler(options, onDismissed);
}
}
}, [group, data, alerts, setAlerts, closeDialog, dispatchLoadingAlerts]);
useEffect(() => {
if (scroll === ScrollType.AlertSummary) {
setSummaryOpen(true);
}
}, [scroll]);
useEffect(() => {
if (error) {
setSnackbarOpen(true);
} else {
setSnackbarOpen(false);
}
}, [error]);
useEffect(() => {
function toggleDialogOnStatusChange() {
const isAlertSnoozed = !!alerts.snoozed;
const isAlertAccepted = !!alerts.accepted;
const isAlertDismissed = !!alerts.dismissed;
if (isAlertSnoozed || isAlertDismissed || isAlertAccepted) {
setDialogOpen(true);
} else {
setDialogOpen(false);
}
}
toggleDialogOnStatusChange();
}, [alerts.snoozed, alerts.dismissed, alerts.accepted]);
function onSnackbarClose() {
setError(null);
}
function onDialogSubmit(data: any) {
setData(data);
}
function onSummaryButtonClick() {
setSummaryOpen(prevOpen => !prevOpen);
}
const total = [accepted, snoozed, dismissed].reduce(sumOfAllAlerts, 0);
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}>
<Grid item>
<AlertInsightsHeader
title="Your team's action items"
subtitle={
isAlertInsightSectionDisplayed
? 'This section outlines suggested action items your team can address to improve cloud costs.'
: "All of your team's action items are hidden. Maybe it's time to give them another look?"
}
/>
</Grid>
{isAlertInsightSectionDisplayed && (
<Grid item container direction="column" spacing={4}>
{active.map((alert, index) => (
<Grid item key={`alert-insights-section-${index}`}>
<AlertInsightsSection alert={alert} number={index + 1} />
</Grid>
))}
</Grid>
))}
)}
{isAlertStatusSummaryDisplayed && (
<Grid item>
<Box position="relative" display="flex" justifyContent="flex-end">
<ScrollAnchor id={ScrollType.AlertSummary} />
<AlertStatusSummaryButton onClick={onSummaryButtonClick}>
{pluralize('Hidden Action Item', total)}
</AlertStatusSummaryButton>
</Box>
<AlertStatusSummary
open={isSummaryOpen}
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
/>
</Grid>
)}
<AlertDialog
group={group}
open={isDialogOpen}
snoozed={alerts.snoozed}
accepted={alerts.accepted}
dismissed={alerts.dismissed}
onClose={closeDialog}
onSubmit={onDialogSubmit}
/>
<Snackbar
open={isSnackbarOpen}
autoHideDuration={6_000}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
onClose={onSnackbarClose}
>
<MuiAlert onClose={onSnackbarClose} severity="error">
{error?.message}
</MuiAlert>
</Snackbar>
</Grid>
</Grid>
);
);
};
@@ -30,10 +30,11 @@ export const AlertInsightsHeader = ({
subtitle,
}: AlertInsightsHeaderProps) => {
const classes = useStyles();
const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader);
const [, , ScrollAnchor] = useScroll();
return (
<Box mb={6} position="relative">
<ScrollAnchor top={-20} behavior="smooth" />
<ScrollAnchor id={DefaultNavigation.AlertInsightsHeader} />
<Typography variant="h4" align="center">
{title}{' '}
<span role="img" aria-label="direct-hit">
@@ -17,10 +17,10 @@ import React from 'react';
import { AlertInsightsSection } from './AlertInsightsSection';
import { render } from '@testing-library/react';
import { Alert } from '../../types';
import { MockScrollProvider } from '../..';
import { AlertState } from '../../hooks';
import { MockScrollProvider, MockAlertsProvider } from '../../utils/tests';
const mockAlert: Alert = {
element: <div />,
subtitle:
'Wherefore was I to this keen mockery born? When at your hands did I deserve this scorn?',
title: 'Mock alert',
@@ -29,26 +29,110 @@ const mockAlert: Alert = {
describe('<AlertInsightsSection/>', () => {
it('Renders alert without exploding', () => {
const { getByText } = render(
<MockScrollProvider>
<AlertInsightsSection alert={mockAlert} number={1} />
</MockScrollProvider>,
const { getByText, queryByText } = render(
<MockAlertsProvider>
<MockScrollProvider>
<AlertInsightsSection alert={mockAlert} number={1} />
</MockScrollProvider>
</MockAlertsProvider>,
);
expect(getByText(mockAlert.title)).toBeInTheDocument();
expect(getByText(mockAlert.subtitle)).toBeInTheDocument();
expect(getByText('View Instructions')).toBeInTheDocument();
expect(queryByText('Snooze')).not.toBeInTheDocument();
expect(queryByText('Accept')).not.toBeInTheDocument();
expect(queryByText('Dismiss')).not.toBeInTheDocument();
});
it('Hides instructions button if url is not provided', () => {
const alert = {
const alert: Alert = {
...mockAlert,
url: undefined,
};
const { queryByText } = render(
<MockScrollProvider>
<AlertInsightsSection alert={alert} number={1} />
</MockScrollProvider>,
<MockAlertsProvider>
<MockScrollProvider>
<AlertInsightsSection alert={alert} number={1} />
</MockScrollProvider>
</MockAlertsProvider>,
);
expect(queryByText('View Instructions')).not.toBeInTheDocument();
});
it('Displays a snooze button if a hook is provided', () => {
const alert: Alert = {
...mockAlert,
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>,
);
expect(getByText('Snooze')).toBeInTheDocument();
expect(queryByText('Accept')).not.toBeInTheDocument();
expect(queryByText('Dismiss')).not.toBeInTheDocument();
});
it('Displays a dismiss button if a hook is provided', () => {
const alert: Alert = {
...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>,
);
expect(getByText('Dismiss')).toBeInTheDocument();
expect(queryByText('Accept')).not.toBeInTheDocument();
expect(queryByText('Snooze')).not.toBeInTheDocument();
});
it('Displays an accept button if a hook is provided', () => {
const alert: Alert = {
...mockAlert,
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>,
);
expect(getByText('Accept')).toBeInTheDocument();
expect(queryByText('Snooze')).not.toBeInTheDocument();
expect(queryByText('Dismiss')).not.toBeInTheDocument();
});
});
@@ -15,7 +15,11 @@
*/
import React from 'react';
import { Box, Button } from '@material-ui/core';
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';
type AlertInsightsSectionProps = {
@@ -27,18 +31,60 @@ export const AlertInsightsSection = ({
alert,
number,
}: AlertInsightsSectionProps) => {
const [, setAlerts] = useAlerts();
const isSnoozeButtonDisplayed = !!alert.onSnoozed;
const isAcceptButtonDisplayed = !!alert.onAccepted;
const isDismissButtonDisplayed = !!alert.onDismissed;
const isButtonGroupDisplayed =
isSnoozeButtonDisplayed ||
isAcceptButtonDisplayed ||
isDismissButtonDisplayed;
return (
<Box display="flex" flexDirection="column">
<AlertInsightsSectionHeader
title={alert.title}
subtitle={alert.subtitle}
number={number}
/>
{alert.url && (
<Box textAlign="left" mt={0} mb={4}>
<Button variant="contained" color="primary" href={alert.url}>
{alert.buttonText || 'View Instructions'}
</Button>
<Box display="flex" flexDirection="column" mb={6}>
<AlertInsightsSectionHeader alert={alert} number={number} />
{isButtonGroupDisplayed && (
<Box display="flex" alignItems="center" mb={4}>
{isAcceptButtonDisplayed && (
<Box mr={1}>
<Button
color="primary"
variant="contained"
aria-label="accept"
onClick={() => setAlerts({ accepted: alert })}
startIcon={<AcceptIcon />}
>
Accept
</Button>
</Box>
)}
{isSnoozeButtonDisplayed && (
<Box mr={1}>
<Button
color="default"
variant="outlined"
aria-label="snooze"
disableElevation
onClick={() => setAlerts({ snoozed: alert })}
startIcon={<SnoozeIcon />}
>
Snooze
</Button>
</Box>
)}
{isDismissButtonDisplayed && (
<Button
color="secondary"
variant="outlined"
aria-label="dismiss"
disableElevation
onClick={() => setAlerts({ dismissed: alert })}
startIcon={<DismissIcon />}
>
Dismiss
</Button>
)}
</Box>
)}
{alert.element}
@@ -15,34 +15,47 @@
*/
import React from 'react';
import { Avatar, Box, Grid, Typography } from '@material-ui/core';
import { Avatar, Box, Button, Grid, Typography } from '@material-ui/core';
import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles';
import { useScroll } from '../../hooks';
import { Alert } from '../../types';
type AlertInsightsSectionHeaderProps = {
alert: Alert;
number: number;
title: string;
subtitle: string;
};
export const AlertInsightsSectionHeader = ({
alert,
number,
title,
subtitle,
}: AlertInsightsSectionHeaderProps) => {
const { ScrollAnchor } = useScroll(`alert-${number}`);
const [, , ScrollAnchor] = useScroll();
const classes = useStyles();
const isViewInstructionsButtonDisplayed = !!alert.url;
return (
<Box position="relative" mb={3} textAlign="left">
<ScrollAnchor top={-20} behavior="smooth" />
<Grid container spacing={2}>
<ScrollAnchor id={`alert-${number}`} />
<Grid container spacing={2} justify="space-between" alignItems="center">
<Grid item>
<Avatar className={classes.button}>{number}</Avatar>
</Grid>
<Grid item>
<Typography variant="h5">{title}</Typography>
<Typography gutterBottom>{subtitle}</Typography>
<Box display="flex" alignItems="center">
<Box mr={2}>
<Avatar className={classes.button}>{number}</Avatar>
</Box>
<Box>
<Typography variant="h5">{alert.title}</Typography>
<Typography gutterBottom>{alert.subtitle}</Typography>
</Box>
</Box>
</Grid>
{isViewInstructionsButtonDisplayed && (
<Grid item>
<Button variant="text" color="primary" href={alert.url}>
{alert.buttonText || 'View Instructions'}
</Button>
</Grid>
)}
</Grid>
</Box>
);
@@ -0,0 +1,58 @@
/*
* 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 from 'react';
import { AlertStatusSummary } from './AlertStatusSummary';
import { render } from '@testing-library/react';
import { Alert, AlertStatus } from '../../types';
import { MockScrollProvider } from '../../utils/tests';
const mockSnoozed: Alert = {
title: 'snoozed-title',
subtitle: 'snoozed-subtitle',
status: AlertStatus.Snoozed,
};
const mockAccepted: Alert = {
title: 'accepted-title',
subtitle: 'accepted-subtitle',
status: AlertStatus.Accepted,
};
const mockDismissed: Alert = {
title: 'dismissed-title',
subtitle: 'dismissed-subtitle',
status: AlertStatus.Dismissed,
};
describe('<AlertStatusSummary />', () => {
it('should display alerts', () => {
const { getByText, getByRole } = render(
<MockScrollProvider>
<AlertStatusSummary
open
snoozed={[mockSnoozed]}
accepted={[mockAccepted]}
dismissed={[mockDismissed]}
/>
</MockScrollProvider>,
);
[mockSnoozed, mockAccepted, mockDismissed].forEach(a => {
expect(getByText(a.title)).toBeInTheDocument();
expect(getByText(a.subtitle)).toBeInTheDocument();
expect(getByRole('img', { name: a.status })).toBeInTheDocument();
});
});
});
@@ -0,0 +1,116 @@
/*
* 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, { Fragment } from 'react';
import { Avatar, Box, Collapse, Divider } 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';
import { ActionItemCard } from '../ActionItems';
import { Alert, AlertStatus } from '../../types';
import { useActionItemCardStyles as useStyles } from '../../utils/styles';
type AlertStatusSummaryProps = {
open: boolean;
snoozed: Alert[];
accepted: Alert[];
dismissed: Alert[];
};
export const AlertStatusSummary = ({
open,
snoozed,
accepted,
dismissed,
}: AlertStatusSummaryProps) => {
const classes = useStyles();
const isSnoozedListDisplayed = !!snoozed.length;
const isAcceptedListDisplayed = !!accepted.length;
const isDismissedListDisplayed = !!dismissed.length;
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>
)}
{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>
)}
{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>
)}
</Collapse>
);
};
@@ -0,0 +1,54 @@
/*
* 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, { useState, MouseEventHandler, PropsWithChildren } from 'react';
import classnames from 'classnames';
import { Button } from '@material-ui/core';
import { default as ExpandMoreIcon } from '@material-ui/icons/ExpandMore';
import { useAlertStatusSummaryButtonStyles as useStyles } from '../../utils/styles';
type AlertStatusSummaryButtonProps = {
onClick: MouseEventHandler;
};
export const AlertStatusSummaryButton = ({
children,
onClick,
}: PropsWithChildren<AlertStatusSummaryButtonProps>) => {
const classes = useStyles();
const [clicked, setClicked] = useState(false);
const iconClassName = classnames(classes.icon, {
[classes.clicked]: clicked,
});
const handleOnClick: MouseEventHandler = e => {
setClicked(prevClicked => !prevClicked);
onClick(e);
};
return (
<Button
variant="text"
color="primary"
disableElevation
aria-label="expand"
endIcon={<ExpandMoreIcon className={iconClassName} />}
onClick={handleOnClick}
>
{children}
</Button>
);
};
@@ -105,13 +105,14 @@ export const CostInsightsNavigation = React.memo(
const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => {
const classes = useStyles();
const { scrollIntoView } = useScroll(navigation);
const [, setScroll] = useScroll();
return (
<MenuItem
button
data-testid={`menu-item-${navigation}`}
className={classes.menuItem}
onClick={scrollIntoView}
onClick={() => setScroll(navigation)}
>
<ListItemIcon className={classes.listItemIcon}>{icon}</ListItemIcon>
<ListItemText
@@ -14,12 +14,19 @@
* limitations under the License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Container, Divider, Grid, Typography } from '@material-ui/core';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Box,
Collapse,
Container,
Divider,
Grid,
Typography,
} from '@material-ui/core';
import { Progress, useApi } from '@backstage/core';
import { default as MaterialAlert } from '@material-ui/lab/Alert';
import { costInsightsApiRef } from '../../api';
import { AlertActionCardList } from '../AlertActionCardList';
import { ActionItems } from '../ActionItems';
import { AlertInsights } from '../AlertInsights';
import { CostInsightsLayout } from '../CostInsightsLayout';
import { CopyUrlToClipboard } from '../CopyUrlToClipboard';
@@ -37,16 +44,23 @@ import { ProductInsights } from '../ProductInsights';
import {
useConfig,
useCurrency,
useAlerts,
useFilters,
useGroups,
useLastCompleteBillingDate,
useLoading,
} from '../../hooks';
import { Alert, Cost, Maybe, MetricData, Product, Project } from '../../types';
import { 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,
} from '../../utils/alerts';
export const CostInsightsPage = () => {
const classes = useSubtleTypographyStyles();
@@ -54,16 +68,24 @@ export const CostInsightsPage = () => {
const config = useConfig();
const groups = useGroups();
const lastCompleteBillingDate = useLastCompleteBillingDate();
const [alerts, setAlerts] = useAlerts();
const [currency, setCurrency] = useCurrency();
const [projects, setProjects] = useState<Maybe<Project[]>>(null);
const [products, setProducts] = useState<Maybe<Product[]>>(null);
const [dailyCost, setDailyCost] = useState<Maybe<Cost>>(null);
const [metricData, setMetricData] = useState<Maybe<MetricData>>(null);
const [alerts, setAlerts] = useState<Maybe<Alert[]>>(null);
const [error, setError] = useState<Maybe<Error>>(null);
const { pageFilters, setPageFilters } = useFilters(p => p);
const snoozed = useMemo(() => alerts.alerts.filter(isSnoozed), [alerts]);
const accepted = useMemo(() => alerts.alerts.filter(isAccepted), [alerts]);
const dismissed = useMemo(() => alerts.alerts.filter(isDismissed), [alerts]);
const activeAlerts = useMemo(() => alerts.alerts.filter(isActive), [alerts]);
const isActionItemsDisplayed = !!activeAlerts.length;
const isAlertInsightsDisplayed = !!alerts.alerts.length;
const {
loadingActions,
loadingGroups,
@@ -120,7 +142,7 @@ export const CostInsightsPage = () => {
: client.getGroupDailyCost(pageFilters.group, intervals),
]);
setProjects(fetchedProjects);
setAlerts(fetchedAlerts);
setAlerts({ alerts: fetchedAlerts });
setMetricData(fetchedMetricData);
setDailyCost(fetchedDailyCost);
} else {
@@ -145,6 +167,7 @@ export const CostInsightsPage = () => {
loadingActions,
loadingGroups,
loadingBillingDate,
setAlerts,
dispatchLoadingInsights,
dispatchLoadingInitial,
dispatchLoadingNone,
@@ -177,8 +200,8 @@ export const CostInsightsPage = () => {
</CostInsightsLayout>
);
}
// These should be defined, alerts can be an empty array but that's truthy
if (!dailyCost || !alerts) {
if (!dailyCost) {
return (
<MaterialAlert severity="error">{`Error: Could not fetch cost insights data for team ${pageFilters.group}`}</MaterialAlert>
);
@@ -228,7 +251,7 @@ export const CostInsightsPage = () => {
<Box position="sticky" top={20}>
<CostInsightsNavigation
products={products}
alerts={alerts.length}
alerts={activeAlerts.length}
/>
</Box>
</Grid>
@@ -249,19 +272,22 @@ export const CostInsightsPage = () => {
owner={pageFilters.group}
groups={groups}
hasCostData={!!dailyCost.aggregation.length}
alerts={alerts.length}
alerts={activeAlerts.length}
/>
</Grid>
{!!alerts.length && (
<>
<Grid item xs>
<Box px={3} py={6}>
<AlertActionCardList alerts={alerts} />
</Box>
</Grid>
<Divider />
</>
)}
<Collapse in={isActionItemsDisplayed} enter={false}>
<Grid item xs>
<Box px={3} py={6}>
<ActionItems
active={activeAlerts}
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
/>
</Box>
</Grid>
<Divider />
</Collapse>
<Grid item xs>
<CostOverviewBanner />
</Grid>
@@ -276,14 +302,20 @@ export const CostInsightsPage = () => {
<WhyCostsMatter />
</Box>
</Grid>
<Grid item xs>
{!!alerts?.length && (
<Collapse in={isAlertInsightsDisplayed} enter={false}>
<Grid item xs>
<Box px={6} py={6} mx={-3} bgcolor="alertBackground">
<AlertInsights alerts={alerts} />
<AlertInsights
group={pageFilters.group}
active={activeAlerts}
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
/>
</Box>
)}
</Grid>
{!alerts.length && <Divider />}
</Grid>
</Collapse>
{!isAlertInsightsDisplayed && <Divider />}
<Grid item xs>
<Box px={3} py={6}>
<ProductInsights
@@ -20,6 +20,7 @@ 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';
@@ -34,7 +35,9 @@ export const CostInsightsPageRoot = () => (
<FilterProvider>
<ScrollProvider>
<CurrencyProvider>
<CostInsightsPage />
<AlertsProvider>
<CostInsightsPage />
</AlertsProvider>
</CurrencyProvider>
</ScrollProvider>
</FilterProvider>
@@ -49,7 +49,7 @@ export const CostOverviewCard = ({
const config = useConfig();
const [tabIndex, setTabIndex] = useState(0);
const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard);
const [, , ScrollAnchor] = useScroll();
const { setDuration, setProject, setMetric, ...filters } = useFilters(
mapFiltersToProps,
);
@@ -95,7 +95,7 @@ export const CostOverviewCard = ({
return (
<Card style={{ position: 'relative' }}>
<ScrollAnchor behavior="smooth" top={-20} />
<ScrollAnchor id={DefaultNavigation.CostOverviewCard} />
<CardContent>
{dailyCostData.groupedCosts && <OverviewTabs />}
<CostOverviewHeader title={tabs[tabIndex].title}>
@@ -0,0 +1,60 @@
/*
* 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 from 'react';
import { Box } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
import { MigrationBarChartLegend } from './MigrationBarChartLegend';
import { MigrationBarChart } from './MigrationBarChart';
import { MigrationData } from '../../types';
type MigrationAlertProps = {
data: MigrationData;
title: string;
subheader: string;
currentProduct: string;
comparedProduct: string;
};
export const MigrationAlertCard = ({
data,
title,
subheader,
currentProduct,
comparedProduct,
}: MigrationAlertProps) => {
return (
<InfoCard title={title} subheader={subheader}>
<Box display="flex" flexDirection="column">
<Box paddingY={1}>
<MigrationBarChartLegend
startDate={data.startDate}
change={data.change}
currentProduct={currentProduct}
comparedProduct={comparedProduct}
/>
</Box>
<Box paddingY={1}>
<MigrationBarChart
services={data.services}
currentProduct={currentProduct}
comparedProduct={comparedProduct}
/>
</Box>
</Box>
</InfoCard>
);
};
@@ -0,0 +1,54 @@
/*
* 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 from 'react';
import { BarChart } from '../BarChart';
import {
BarChartOptions,
CostInsightsTheme,
Entity,
ResourceData,
} from '../../types';
import { useTheme } from '@material-ui/core';
type MigrationBarChartProps = {
currentProduct: string;
comparedProduct: string;
services: Array<Entity>;
};
export const MigrationBarChart = ({
currentProduct,
comparedProduct,
services,
}: MigrationBarChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const options: BarChartOptions = {
previousFill: theme.palette.magenta,
currentFill: theme.palette.yellow,
previousName: comparedProduct,
currentName: currentProduct,
};
const resources: ResourceData[] = services.map(service => ({
name: service.id,
previous: service.aggregation[0],
current: service.aggregation[1],
}));
return <BarChart resources={resources} options={options} />;
};
@@ -0,0 +1,58 @@
/*
* 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 from 'react';
import { Box, useTheme } from '@material-ui/core';
import { CostGrowth } from '../CostGrowth';
import { LegendItem } from '../LegendItem';
import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types';
import { monthOf } from '../../utils/formatters';
export type MigrationBarChartLegendProps = {
change: ChangeStatistic;
startDate: string;
currentProduct: string;
comparedProduct: string;
};
export const MigrationBarChartLegend = ({
currentProduct,
comparedProduct,
change,
startDate,
}: MigrationBarChartLegendProps) => {
const theme = useTheme<CostInsightsTheme>();
return (
<Box display="flex" flexDirection="row">
<Box marginRight={2}>
<LegendItem
title={monthOf(startDate)}
markerColor={theme.palette.magenta}
>
{currentProduct}
</LegendItem>
</Box>
<Box marginRight={2}>
<LegendItem title="Estimated Cost" markerColor={theme.palette.yellow}>
{comparedProduct}
</LegendItem>
</Box>
<LegendItem title="Total Savings">
<CostGrowth change={change} duration={Duration.P30D} />
</LegendItem>
</Box>
);
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { AlertActionCardList } from './AlertActionCardList';
export { MigrationAlertCard } from './MigrationAlertCard';
@@ -77,17 +77,6 @@ const renderProductInsightsCardInTestApp = async (
);
describe('<ProductInsightsCard/>', () => {
it('Renders the scroll anchors', async () => {
const rendered = await renderProductInsightsCardInTestApp(
mockProductCost,
MockComputeEngine,
Duration.P30D,
);
expect(
rendered.queryByTestId(`scroll-test-compute-engine`),
).toBeInTheDocument();
});
it('Should render the right subheader for products with cost data', async () => {
const entity = {
...mockProductCost,
@@ -60,7 +60,7 @@ export const ProductInsightsCard = ({
}: PropsWithChildren<ProductInsightsCardProps>) => {
const classes = useStyles();
const mountedRef = useRef(false);
const { ScrollAnchor } = useScroll(product.kind);
const [, , ScrollAnchor] = useScroll();
const [error, setError] = useState<Maybe<Error>>(null);
const dispatchLoading = useLoading(mapLoadingToProps);
const lastCompleteBillingDate = useLastCompleteBillingDate();
@@ -107,7 +107,7 @@ export const ProductInsightsCard = ({
if (error || !entity) {
return (
<InfoCard title={product.name} headerProps={headerProps}>
<ScrollAnchor behavior="smooth" top={-12} />
<ScrollAnchor id={product.kind} />
<Alert severity="error">
{error
? error.message
@@ -123,7 +123,7 @@ export const ProductInsightsCard = ({
subheader={subheader}
headerProps={headerProps}
>
<ScrollAnchor behavior="smooth" top={-12} />
<ScrollAnchor id={product.kind} />
{entities.length ? (
<ProductInsightsChart
entity={entity}
@@ -28,7 +28,7 @@ import {
Product,
ProjectGrowthData,
} from '../../types';
import { ProjectGrowthAlert } from '../../utils/alerts';
import { ProjectGrowthAlert } from '../../alerts';
const today = moment().format(DEFAULT_DATE_FORMAT);
@@ -0,0 +1,57 @@
/*
* 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, {
useState,
forwardRef,
ChangeEvent,
FormEventHandler,
} from 'react';
import { Checkbox, FormControl, FormControlLabel } from '@material-ui/core';
import { Alert, AlertFormProps } from '../types';
export type AlertAcceptFormProps = AlertFormProps<Alert, null>;
export const AlertAcceptForm = forwardRef<
HTMLFormElement,
AlertAcceptFormProps
>(({ onSubmit, disableSubmit }, ref) => {
const [checked, setChecked] = useState(false);
const onFormSubmit: FormEventHandler = e => {
e.preventDefault();
onSubmit(null);
};
const onChecked = (_: ChangeEvent<HTMLInputElement>, isChecked: boolean) => {
setChecked(isChecked);
disableSubmit(!isChecked);
};
return (
<form ref={ref} onSubmit={onFormSubmit}>
<FormControl component="fieldset" fullWidth>
<FormControlLabel
label="My team can commit to making this change soon, or has already."
value={checked}
control={
<Checkbox color="primary" checked={checked} onChange={onChecked} />
}
/>
</FormControl>
</form>
);
});
@@ -0,0 +1,156 @@
/*
* 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, {
ChangeEvent,
useEffect,
useState,
forwardRef,
FormEventHandler,
} from 'react';
import {
Box,
Collapse,
FormControl,
FormControlLabel,
TextField,
Typography,
Radio,
RadioGroup,
} from '@material-ui/core';
import {
Alert,
AlertFormProps,
AlertDismissReason,
AlertDismissOptions,
AlertDismissFormData,
Maybe,
} from '../types';
import { useAlertDialogStyles as useStyles } from '../utils/styles';
export type AlertDismissFormProps = AlertFormProps<Alert, AlertDismissFormData>;
export const AlertDismissForm = forwardRef<
HTMLFormElement,
AlertDismissFormProps
>(({ onSubmit, disableSubmit }, ref) => {
const classes = useStyles();
const [other, setOther] = useState<Maybe<string>>(null);
const [feedback, setFeedback] = useState<Maybe<string>>(null);
const [reason, setReason] = useState<AlertDismissReason>(
AlertDismissReason.Resolved,
);
const onFormSubmit: FormEventHandler = e => {
function submit() {
onSubmit({
other: other,
reason: reason,
feedback: feedback,
});
}
e.preventDefault();
if (reason) {
submit();
}
};
const onReasonChange = (_: ChangeEvent<HTMLInputElement>, value: string) => {
setReason(value as AlertDismissReason);
};
const onOtherChange = (e: ChangeEvent<HTMLInputElement>) => {
return e.target.value
? setOther(e.target.value as AlertDismissReason)
: setOther(null);
};
const onFeedbackChange = (e: ChangeEvent<HTMLInputElement>) => {
return e.target.value
? setFeedback(e.target.value as AlertDismissReason)
: setFeedback(null);
};
useEffect(() => {
function validateDismissForm() {
if (reason === AlertDismissReason.Other) {
if (other) {
disableSubmit(false);
} else {
disableSubmit(true);
}
} else if (reason) {
disableSubmit(false);
} else {
disableSubmit(true);
}
}
validateDismissForm();
}, [reason, other, disableSubmit]);
return (
<form ref={ref} onSubmit={onFormSubmit}>
<FormControl component="fieldset" fullWidth>
<Typography color="textPrimary">
<b>Reason for dismissing?</b>
</Typography>
<Box mb={1}>
<RadioGroup
name="dismiss-alert-reasons"
value={reason}
onChange={onReasonChange}
>
{AlertDismissOptions.map(option => (
<FormControlLabel
key={`dismiss-alert-option-${option.reason}`}
label={option.label}
value={option.reason}
control={<Radio className={classes.radio} />}
/>
))}
</RadioGroup>
<Collapse in={reason === AlertDismissReason.Other}>
<Box ml={4}>
<TextField
id="dismiss-alert-option-other"
variant="outlined"
multiline
fullWidth
rows={4}
value={other ?? ''}
onChange={onOtherChange}
/>
</Box>
</Collapse>
</Box>
<Typography gutterBottom>
<b>Any other feedback you can provide?</b>
</Typography>
<TextField
id="dismiss-alert-feedback"
variant="outlined"
multiline
rows={4}
fullWidth
value={feedback ?? ''}
onChange={onFeedbackChange}
/>
</FormControl>
</form>
);
});
@@ -0,0 +1,115 @@
/*
* 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, {
ChangeEvent,
useEffect,
useState,
forwardRef,
FormEventHandler,
} from 'react';
import dayjs from 'dayjs';
import {
Box,
Collapse,
FormControl,
FormControlLabel,
RadioGroup,
Radio,
Typography,
} from '@material-ui/core';
import {
Alert,
AlertFormProps,
Duration,
DEFAULT_DATE_FORMAT,
Maybe,
AlertSnoozeFormData,
AlertSnoozeOptions,
} from '../types';
import { useAlertDialogStyles as useStyles } from '../utils/styles';
import { intervalsOf } from '../utils/duration';
export type AlertSnoozeFormProps = AlertFormProps<Alert, AlertSnoozeFormData>;
export const AlertSnoozeForm = forwardRef<
HTMLFormElement,
AlertSnoozeFormProps
>(({ onSubmit, disableSubmit }, ref) => {
const classes = useStyles();
const [error, setError] = useState<Maybe<Error>>(null);
const [duration, setDuration] = useState<Maybe<Duration>>(Duration.P7D);
const onFormSubmit: FormEventHandler = e => {
e.preventDefault();
if (duration) {
const repeatInterval = 1;
const inclusiveEndDate = dayjs().format(DEFAULT_DATE_FORMAT);
onSubmit({
intervals: intervalsOf(duration, inclusiveEndDate, repeatInterval),
});
} else {
setError(new Error('Please select an option.'));
}
};
const onSnoozeDurationChange = (
_: ChangeEvent<HTMLInputElement>,
value: string,
) => {
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>
<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"
value={duration}
onChange={onSnoozeDurationChange}
>
{AlertSnoozeOptions.map(option => (
<FormControlLabel
key={`snooze-alert-option-${option.duration}`}
label={option.label}
value={option.duration}
control={<Radio className={classes.radio} />}
/>
))}
</RadioGroup>
</Box>
</FormControl>
</form>
);
});
@@ -0,0 +1,108 @@
/*
* 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, {
useEffect,
useState,
forwardRef,
FormEventHandler,
ChangeEvent,
} from 'react';
import {
Checkbox,
FormControl,
FormControlLabel,
FormGroup,
Typography,
} from '@material-ui/core';
import { AlertFormProps, Entity } from '../types';
import { MigrationAlert } from '../alerts';
import { findAlways } from '../utils/assert';
export type MigrationDismissFormData = {
services: Entity[];
};
export type MigrationDismissFormProps = AlertFormProps<
MigrationAlert,
MigrationDismissFormData
>;
export const MigrationDismissForm = forwardRef<
HTMLFormElement,
MigrationDismissFormProps
>(({ onSubmit, disableSubmit, alert }, ref) => {
const [services, setServices] = useState<Entity[]>(alert.data.services);
const onFormSubmit: FormEventHandler = e => {
/* Remember to prevent default form behavior */
e.preventDefault();
onSubmit({ services: services });
};
const onCheckboxChange = (
e: ChangeEvent<HTMLInputElement>,
checked: boolean,
) => {
if (checked) {
const service = findAlways(
alert.data.services,
s => s.id === e.target.value,
);
setServices(prevServices => prevServices.concat(service));
} else {
setServices(prevServices =>
prevServices.filter(p => p.id !== e.target.value),
);
}
};
/* Submit button is disabled by default. Use props.disableSubmit to toggle disabled state. */
useEffect(() => {
if (services.length) {
disableSubmit(false);
} else {
disableSubmit(true);
}
}, [services, disableSubmit]);
return (
/* All custom forms must accept a ref and implement an onSubmit handler. */
<form ref={ref} onSubmit={onFormSubmit}>
<FormControl component="fieldset" fullWidth>
<Typography color="textPrimary">
<b>Or choose which services to dismiss this alert for.</b>
</Typography>
<FormGroup>
{alert.data.services.map((service, index) => (
<FormControlLabel
key={`example-option-${index}`}
label={service.id}
value={service.id}
control={
<Checkbox
color="primary"
checked={services.some(p => p.id === service.id)}
onChange={onCheckboxChange}
/>
}
/>
))}
</FormGroup>
</FormControl>
</form>
);
});
+21
View File
@@ -0,0 +1,21 @@
/*
* 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.
*/
export { AlertAcceptForm } from './AlertAcceptForm';
export { AlertSnoozeForm } from './AlertSnoozeForm';
export { AlertDismissForm } from './AlertDismissForm';
export { MigrationDismissForm } from './MigrationDismissForm';
export type { MigrationDismissFormData } from './MigrationDismissForm';
+1
View File
@@ -16,6 +16,7 @@
export * from './useConfig';
export * from './useCurrency';
export * from './useAlerts';
export * from './useFilters';
export * from './useCurrency';
export * from './useGroups';
@@ -0,0 +1,76 @@
/*
* 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');
}
+26 -37
View File
@@ -15,6 +15,7 @@
*/
import React, {
Dispatch,
ElementType,
SetStateAction,
useState,
useContext,
@@ -22,19 +23,14 @@ import React, {
useRef,
PropsWithChildren,
} from 'react';
import { CSSProperties } from '@material-ui/styles';
import { Maybe } from '../types';
export type ScrollTo = Maybe<string>;
export type ScrollContextProps = {
scrollTo: ScrollTo;
setScrollTo: Dispatch<SetStateAction<ScrollTo>>;
};
export type ScrollUtils = {
ScrollAnchor: (props: Omit<ScrollAnchorProps, 'id'>) => JSX.Element;
scrollIntoView: () => void;
scroll: ScrollTo;
setScroll: Dispatch<SetStateAction<ScrollTo>>;
ScrollAnchor: ElementType<ScrollAnchorProps>;
};
export interface ScrollAnchorProps extends ScrollIntoViewOptions {
@@ -49,28 +45,14 @@ export const ScrollContext = React.createContext<
export const ScrollAnchor = ({
id,
top,
left,
behavior,
block,
inline,
left = 0,
top = -20,
behavior = 'smooth',
}: ScrollAnchorProps) => {
const divRef = useRef<HTMLDivElement>(null);
const context = useContext(ScrollContext);
if (!context) {
assertNever();
}
const { scrollTo, setScrollTo } = context;
const styles: CSSProperties = {
position: 'absolute',
height: 0,
width: 0,
top: top || 0,
left: left || 0,
};
const [scroll, setScroll] = useScroll();
useEffect(() => {
function scrollIntoView() {
@@ -80,39 +62,46 @@ export const ScrollAnchor = ({
inline: inline || 'nearest',
};
if (divRef.current && scrollTo === id) {
if (divRef.current && scroll === id) {
divRef.current.scrollIntoView(options);
setScrollTo(null);
setScroll(null);
}
}
scrollIntoView();
}, [scrollTo, setScrollTo, id, behavior, block, inline]);
}, [scroll, setScroll, id, behavior, block, inline]);
return <div ref={divRef} style={styles} data-testid={`scroll-test-${id}`} />;
return (
<div
ref={divRef}
style={{ position: 'absolute', height: 0, width: 0, top, left }}
data-testid={`scroll-test-${id}`}
/>
);
};
export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => {
const [scrollTo, setScrollTo] = useState<ScrollTo>(null);
const [scroll, setScroll] = useState<ScrollTo>(null);
return (
<ScrollContext.Provider value={{ scrollTo, setScrollTo }}>
<ScrollContext.Provider value={{ scroll, setScroll, ScrollAnchor }}>
{children}
</ScrollContext.Provider>
);
};
export function useScroll(id: ScrollTo): ScrollUtils {
export enum ScrollType {
AlertSummary = 'alert-status-summary',
}
export function useScroll() {
const context = useContext(ScrollContext);
if (!context) {
assertNever();
}
return {
ScrollAnchor: props => <ScrollAnchor id={id} {...props} />,
scrollIntoView: () => context.setScrollTo(id),
};
return [context.scroll, context.setScroll, context.ScrollAnchor] as const;
}
function assertNever(): never {
+1 -1
View File
@@ -17,8 +17,8 @@
export { plugin } from './plugin';
export * from './client';
export * from './api';
export { ProjectGrowthAlert, UnlabeledDataflowAlert } from './alerts';
export * from './components';
export { useCurrency } from './hooks';
export * from './types';
export * from './utils/tests';
export * from './utils/alerts';
+126 -2
View File
@@ -13,23 +13,147 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ForwardRefExoticComponent, RefAttributes } from 'react';
import { ChangeStatistic } from './ChangeStatistic';
import { Duration } from './Duration';
import { Maybe } from './Maybe';
/**
* Generic alert type with required fields for display. The `element` field will be rendered in
* the Cost Insights "Action Items" section. This should use data fetched in the CostInsightsApi
* implementation to render an InfoCard or other visualization.
*
* The alert type exposes hooks which can be used to enable and access various events,
* such as when a user dismisses or snoozes an alert. Default forms and buttons
* will be rendered if a hook is defined.
*
* Each default form can be overridden with a custom component. It must be implemented using
* React.forwardRef. See https://reactjs.org/docs/forwarding-refs
*
* Errors thrown within hooks will generate a snackbar error notification.
*/
export type Alert = {
title: string;
subtitle: string;
element?: JSX.Element;
status?: AlertStatus;
url?: string;
buttonText?: string; // Default: View Instructions
element: JSX.Element;
SnoozeForm?: AlertForm;
AcceptForm?: AlertForm;
DismissForm?: AlertForm;
onSnoozed?(options: AlertOptions): Promise<Alert[]>;
onAccepted?(options: AlertOptions): Promise<Alert[]>;
onDismissed?(options: AlertOptions): Promise<Alert[]>;
};
export type AlertForm<
A extends Alert = any,
Data = any
> = ForwardRefExoticComponent<
AlertFormProps<A, Data> & RefAttributes<HTMLFormElement>
>;
export interface AlertOptions<T = any> {
data: T;
group: string;
}
/**
* Default snooze form intervals are expressed using an ISO 8601 repeating interval string.
* For example, R1/P7D/2020-09-02 for 1 week or R1/P30D/2020-09-02 for 1 month.
*
* For example, if a user dismisses an alert on Monday January 01 for 1 week,
* it can be re-served on Monday, January 08. 7 calendar days from January 02,
* inclusive of the last day.
*
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
*/
export interface AlertSnoozeFormData {
intervals: string;
}
export interface AlertDismissFormData {
other: Maybe<string>;
reason: AlertDismissReason;
feedback: Maybe<string>;
}
// TODO: Convert enum to literal
export enum AlertStatus {
Snoozed = 'snoozed',
Accepted = 'accepted',
Dismissed = 'dismissed',
}
export type AlertFormProps<A extends Alert, FormData = {}> = {
alert: A;
onSubmit: (data: FormData) => void;
disableSubmit: (isDisabled: boolean) => void;
};
export interface AlertDismissOption {
label: string;
reason: string;
}
export enum AlertDismissReason {
Other = 'other',
Resolved = 'resolved',
Expected = 'expected',
Seasonal = 'seasonal',
Migration = 'migration',
NotApplicable = 'not-applicable',
}
export const AlertDismissOptions: AlertDismissOption[] = [
{
reason: AlertDismissReason.Resolved,
label: 'This action item is now resolved.',
},
{
reason: AlertDismissReason.Seasonal,
label: 'This is an expected increase at this time of year.',
},
{
reason: AlertDismissReason.Migration,
label: 'This increase is from a migration in process.',
},
{
reason: AlertDismissReason.Expected,
label: 'This is an expected increase due to our teams priorities.',
},
{
reason: AlertDismissReason.NotApplicable,
label: 'This action item doesnt make sense for my team.',
},
{
reason: AlertDismissReason.Other,
label: 'Other (please specify)',
},
];
export type AlertSnoozeOption = {
label: string;
duration: Duration;
};
export const AlertSnoozeOptions: AlertSnoozeOption[] = [
{
duration: Duration.P7D,
label: '1 Week',
},
{
duration: Duration.P30D,
label: '1 Month',
},
{
duration: Duration.P3M,
label: '1 Quarter',
},
];
export interface AlertCost {
id: string;
aggregation: [number, number];
@@ -21,6 +21,7 @@
* September 15.
*/
export enum Duration {
P7D = 'P7D',
P30D = 'P30D',
P90D = 'P90D',
P3M = 'P3M',
+15 -43
View File
@@ -14,50 +14,22 @@
* limitations under the License.
*/
import React from 'react';
import { Alert, UnlabeledDataflowData, ProjectGrowthData } from '../types';
import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard';
import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard';
import { Alert, AlertStatus } from '../types';
/**
* The alerts below are examples of Alert implementation; the CostInsightsApi permits returning
* any implementation of the Alert type, so adopters can create their own. The CostInsightsApi
* fetches alert data from the backend, then creates Alert classes with the data.
*/
const createStatusHandler = (status?: string) => (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 class UnlabeledDataflowAlert implements Alert {
data: UnlabeledDataflowData;
export const sumOfAllAlerts = (sum: number, alerts: Alert[]) =>
sum + alerts.length;
constructor(data: UnlabeledDataflowData) {
this.data = data;
}
title = 'Add labels to workflows';
subtitle =
'Labels show in billing data, enabling cost insights for each workflow.';
url = '/cost-insights/labeling-jobs';
get element() {
return <UnlabeledDataflowAlertCard alert={this.data} />;
}
}
export class ProjectGrowthAlert implements Alert {
data: ProjectGrowthData;
constructor(data: ProjectGrowthData) {
this.data = data;
}
get title() {
return `Investigate cost growth in project ${this.data.project}`;
}
subtitle =
'Cost growth outpacing business growth is unsustainable long-term.';
url = '/cost-insights/investigating-growth';
get element() {
return <ProjectGrowthAlertCard alert={this.data} />;
}
export function choose<T>(
status: readonly [boolean, boolean, boolean],
values: [T, T, T],
): T | null {
const i = status.indexOf(true);
return i < 0 ? null : values[i];
}
+11 -2
View File
@@ -31,6 +31,7 @@ export function inclusiveStartDateOf(
exclusiveEndDate: string,
): string {
switch (duration) {
case Duration.P7D:
case Duration.P30D:
case Duration.P90D:
return moment(exclusiveEndDate)
@@ -53,6 +54,7 @@ export function exclusiveEndDateOf(
inclusiveEndDate: string,
): string {
switch (duration) {
case Duration.P7D:
case Duration.P30D:
case Duration.P90D:
return moment(inclusiveEndDate)
@@ -80,8 +82,15 @@ export function inclusiveEndDateOf(
}
// https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
export function intervalsOf(duration: Duration, inclusiveEndDate: string) {
return `R2/${duration}/${exclusiveEndDateOf(duration, inclusiveEndDate)}`;
export function intervalsOf(
duration: Duration,
inclusiveEndDate: string,
repeating: number = 2,
) {
return `R${repeating}/${duration}/${exclusiveEndDateOf(
duration,
inclusiveEndDate,
)}`;
}
export function quarterEndDate(inclusiveEndDate: string): string {
@@ -29,6 +29,7 @@ export enum DefaultLoadingAction {
CostInsightsInitial = 'cost-insights-initial',
CostInsightsPage = 'cost-insights-page',
CostInsightsProducts = 'cost-insights-products',
CostInsightsAlerts = 'cost-insights-alerts',
}
export const INITIAL_LOADING_ACTIONS = [
+33 -8
View File
@@ -479,8 +479,8 @@ export const useSelectStyles = makeStyles<BackstageTheme>(
}),
);
export const useAlertActionCardStyles = makeStyles<BackstageTheme>(
(theme: BackstageTheme) =>
export const useActionItemCardStyles = makeStyles<CostInsightsTheme>(
(theme: CostInsightsTheme) =>
createStyles({
card: {
boxShadow: 'none',
@@ -489,15 +489,12 @@ export const useAlertActionCardStyles = makeStyles<BackstageTheme>(
backgroundColor: theme.palette.textVerySubtle,
color: theme.palette.text.primary,
},
}),
);
export const useAlertActionCardHeader = makeStyles<CostInsightsTheme>(
(theme: CostInsightsTheme) =>
createStyles({
root: {
minHeight: 80,
paddingBottom: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
},
activeRoot: {
cursor: 'pointer',
transition: theme.transitions.create('background', {
duration: theme.transitions.duration.short,
@@ -599,3 +596,31 @@ export const useEntityDialogStyles = makeStyles<BackstageTheme>(theme =>
},
}),
);
export const useAlertDialogStyles = makeStyles((theme: BackstageTheme) =>
createStyles({
content: {
padding: theme.spacing(0, 5, 2, 5),
},
actions: {
padding: theme.spacing(2, 5),
},
radio: {
margin: theme.spacing(-0.5, 0, -0.5, 0),
},
icon: {
color: theme.palette.primary.dark,
margin: theme.spacing(2.5, 2.5, 0, 0),
padding: 0,
},
}),
);
export const useAlertStatusSummaryButtonStyles = makeStyles(() => ({
icon: {
transform: 'transform 5s',
},
clicked: {
transform: 'rotate(180deg)',
},
}));
+29 -2
View File
@@ -22,6 +22,7 @@ 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';
@@ -163,8 +164,9 @@ export type MockScrollProviderProps = PropsWithChildren<{}>;
export const MockScrollProvider = ({ children }: MockScrollProviderProps) => {
const defaultContext: ScrollContextProps = {
scrollTo: null,
setScrollTo: jest.fn(),
scroll: null,
setScroll: jest.fn(),
ScrollAnchor: jest.fn(() => <div />),
};
return (
<ScrollContext.Provider value={defaultContext}>
@@ -231,3 +233,28 @@ 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>
);
};