Merge pull request #4254 from backstage/cost-insights-alert-hooks

Cost Insights Alert Hooks
This commit is contained in:
Ryan Vazquez
2021-01-29 11:20:45 -05:00
committed by GitHub
50 changed files with 2780 additions and 304 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': minor
---
add alert hooks
@@ -0,0 +1,159 @@
/*
* 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;
}
/**
* This is an example of an Alert implementation using optional event hooks.
*
* Event hooks can be used to enable users to dismiss, snooze, or accept an action item
* - or any combination thereof. Defining a hook will generate default UI - button, dialog and
* form. Cost Insights does not preserve client side alert state - each hook is expected to return a new set of alerts.
*
* Snoozed, accepted, etc. alerts should define a corresponding status property. Alerts will be aggregated
* by status in a collapsed view below Alert Insights section and a badge will appear in Action Items
* showing the total alerts of that status.
*
* Customizing Alerts
* Default forms can be overridden in two ways - by setting a form property to null or defining a custom component.
*
* If a form property is set to null, the Dialog will not render a form. This can be useful in scenarios
* where data isn't needed from the user such as when a user accepts an action item's recommendation.
*
* If a form property is set to a React component, the Dialog will render the form component in place of the default form.
* Form components must return valid form elements, and accept a ref and onSubmit event handler.
* Custom forms must implement the corresponding event hook. See /forms for example implementations.
*/
export class KubernetesMigrationAlert implements MigrationAlert {
api: CostInsightsApi;
data: MigrationData;
subtitle =
'Services running on Kubernetes are estimated to save 50% or more compared to Compute Engine.';
// Dialog will not render a form if form property set to null.
AcceptForm = null;
// Overrides default Dismiss form with a custom form component.
DismissForm: AlertForm<
MigrationAlert,
MigrationDismissFormData
> = 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(
'Service',
this.data.services.length,
true,
)}, sorted by cost`;
return (
<MigrationAlertCard
data={this.data}
title="Migrate to Kubernetes"
subheader={subheader}
currentProduct="Compute Engine"
comparedProduct="Kubernetes"
/>
);
}
/* Fires when the onSubmit event is raised on a Dismiss form. Displays 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.filter(a => a.title !== this.title),
{
title: this.title,
subtitle: this.subtitle,
status: AlertStatus.Dismissed,
},
]),
);
}
/* Fires when the onSubmit event is raised on a Snooze form. Displays default snooze form. */
async onSnoozed(
options: AlertOptions<AlertSnoozeFormData>,
): Promise<Alert[]> {
const alerts = await this.api.getAlerts(options.group);
return new Promise(resolve =>
setTimeout(resolve, 750, [
...alerts.filter(a => a.title !== this.title),
{
title: this.title,
subtitle: this.subtitle,
status: AlertStatus.Snoozed,
},
]),
);
}
/* Fires when the Accept button is clicked. Dialog does not render a form. See KubernetesMigrationAlert.AcceptForm */
async onAccepted(options: AlertOptions<null>): Promise<Alert[]> {
const alerts = await this.api.getAlerts(options.group);
return new Promise(resolve =>
setTimeout(resolve, 750, [
...alerts.filter(a => a.title !== this.title),
{
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,9 @@
* 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';
export type { MigrationData } from './KubernetesMigrationAlert';
+31 -1
View File
@@ -31,7 +31,8 @@ import {
import {
ProjectGrowthAlert,
UnlabeledDataflowAlert,
} from '../src/utils/alerts';
KubernetesMigrationAlert,
} from '../src/alerts';
import {
aggregationFor,
changeOf,
@@ -177,9 +178,38 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
],
};
const today = dayjs();
const alerts: Alert[] = await this.request({ group }, [
new ProjectGrowthAlert(projectGrowthData),
new UnlabeledDataflowAlert(unlabeledDataflowData),
new KubernetesMigrationAlert(this, {
startDate: today.subtract(30, 'day').format(DEFAULT_DATE_FORMAT),
endDate: today.format(DEFAULT_DATE_FORMAT),
change: {
ratio: 0,
amount: 0,
},
services: [
{
id: 'service-a',
aggregation: [20_000, 10_000],
change: {
ratio: -0.5,
amount: -10_000,
},
entities: {},
},
{
id: 'service-b',
aggregation: [30_000, 15_000],
change: {
ratio: -0.5,
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 are 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 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>
);
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>
)}
</>
);
};
@@ -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,174 @@
/*
* 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 { capitalize } from '@material-ui/core';
import { AlertDialog } from './AlertDialog';
import { render } from '@testing-library/react';
import { Alert, AlertFormProps, AlertStatus } from '../../types';
type MockFormDataProps = AlertFormProps<Alert>;
function createForm(title: string) {
return React.forwardRef<HTMLFormElement, MockFormDataProps>((props, ref) => (
<form ref={ref} onSubmit={props.onSubmit}>
You. {title}. Me.
</form>
));
}
const snoozableAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onSnoozed: jest.fn(),
};
const dimissableAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
};
const acceptableAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onAccepted: jest.fn(),
};
const customSnoozeAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onSnoozed: jest.fn(),
SnoozeForm: createForm('Snooze'),
};
const customDismissAlert: Alert = {
title: 'title',
subtitle: 'subtitle',
onDismissed: jest.fn(),
DismissForm: createForm('Dismiss'),
};
const customAcceptAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onAccepted: jest.fn(),
AcceptForm: createForm('Accept'),
};
const nullAcceptAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onAccepted: jest.fn(),
AcceptForm: null,
};
const nullDismissAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onDismissed: jest.fn(),
DismissForm: null,
};
const nullSnoozeAlert: Alert = {
title: 'title',
subtitle: 'test-subtitle',
onSnoozed: jest.fn(),
SnoozeForm: null,
};
describe('<AlertDialog />', () => {
describe.each`
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`
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`
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();
});
});
});
@@ -0,0 +1,170 @@
/*
* 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 {
capitalize,
Box,
Button,
Divider,
Dialog,
DialogActions,
IconButton,
DialogContent,
Typography,
} from '@material-ui/core';
import { default as CloseIcon } from '@material-ui/icons/Close';
import { useAlertDialogStyles as useStyles } from '../../utils/styles';
import { Alert, AlertStatus, Maybe } from '../../types';
import { choose, formOf } from '../../utils/alerts';
const DEFAULT_FORM_ID = 'alert-form';
type AlertDialogProps = {
open: boolean;
group: string;
alert: Maybe<Alert>;
status: Maybe<AlertStatus>;
onClose: () => void;
onSubmit: (data: any) => void;
};
export const AlertDialog = ({
open,
group,
alert,
status,
onClose,
onSubmit,
}: AlertDialogProps) => {
const classes = useStyles();
const [isSubmitDisabled, setSubmitDisabled] = useState(true);
const formRef = useRef<Maybe<HTMLFormElement>>(null);
useEffect(() => {
setSubmitDisabled(open);
}, [open]);
function disableSubmit(isDisabled: boolean) {
setSubmitDisabled(isDisabled);
}
function onDialogClose() {
onClose();
setSubmitDisabled(true);
}
const [action, actioned] = choose(
status,
[
['snooze', 'snoozed'],
['accept', 'accepted'],
['dismiss', 'dismissed'],
],
['', ''],
);
const TransitionProps = {
mountOnEnter: true,
unmountOnExit: true,
onEntered() {
if (formRef.current) {
formRef.current.id = DEFAULT_FORM_ID;
}
},
};
const Form = formOf(alert, status);
return (
<Dialog
open={open}
onClose={onDialogClose}
scroll="body"
maxWidth="lg"
TransitionProps={TransitionProps}
>
<Box display="flex" justifyContent="flex-end">
<IconButton
className={classes.icon}
disableRipple
aria-label="Close"
onClick={onDialogClose}
>
<CloseIcon />
</IconButton>
</Box>
<DialogContent className={classes.content}>
<Box mb={1.5}>
<Typography variant="h5">
<b>{capitalize(action)} this action item?</b>
</Typography>
<Typography variant="h6" color="textSecondary">
<b>
This action item will be {actioned} for all of {group}.
</b>
</Typography>
</Box>
<Box
display="flex"
flexDirection="column"
bgcolor="alertBackground"
p={2}
mb={1.5}
borderRadius={4}
>
<Typography>
<b>{alert?.title}</b>
</Typography>
<Typography color="textSecondary">{alert?.subtitle}</Typography>
</Box>
{Form && (
<Form
ref={formRef}
alert={alert}
onSubmit={onSubmit}
disableSubmit={disableSubmit}
/>
)}
</DialogContent>
<Divider />
<DialogActions className={classes.actions} disableSpacing>
{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"
variant="contained"
aria-label={action}
onClick={() => onSubmit(null)}
>
{capitalize(action)}
</Button>
)}
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,85 @@
/*
* 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, MockLoadingProvider } from '../../utils/tests';
function renderInContext(children: JSX.Element) {
return render(
<MockLoadingProvider>
<MockScrollProvider>{children}</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={[]}
onChange={jest.fn()}
/>,
);
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={[]}
onChange={jest.fn()}
/>,
);
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,214 @@
* limitations under the License.
*/
import React from 'react';
import { Grid } from '@material-ui/core';
import { AlertInsightsSection } from './AlertInsightsSection';
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';
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 {
useScroll,
useLoading,
ScrollType,
MapLoadingToProps,
} from '../../hooks';
import { DefaultLoadingAction } from '../../utils/loading';
import { Alert, AlertOptions, AlertStatus, Maybe } from '../../types';
import {
isStatusSnoozed,
isStatusAccepted,
isStatusDismissed,
sumOfAllAlerts,
} from '../../utils/alerts';
import { ScrollAnchor } from '../../utils/scroll';
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[];
onChange: (alerts: Alert[]) => void;
};
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,
onChange,
}: AlertInsightsProps) => {
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);
const [isDialogOpen, setDialogOpen] = useState(false);
const [isSummaryOpen, setSummaryOpen] = useState(false);
const [isSnackbarOpen, setSnackbarOpen] = useState(false);
useEffect(() => {
async function callAlertHook(
options: AlertOptions,
callback: (options: AlertOptions) => Promise<Alert[]>,
) {
setAlert(null);
setStatus(null);
setData(undefined);
setDialogOpen(false);
dispatchLoadingAlerts(true);
try {
const alerts: Alert[] = await callback(options);
onChange(alerts);
} catch (e) {
setError(e);
} finally {
dispatchLoadingAlerts(false);
}
}
const options: AlertOptions = { data, group };
const onSnoozed = alert?.onSnoozed?.bind(alert);
const onAccepted = alert?.onAccepted?.bind(alert);
const onDismissed = alert?.onDismissed?.bind(alert);
if (data !== undefined) {
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, alert, status, onChange, dispatchLoadingAlerts]);
useEffect(() => {
if (scroll === ScrollType.AlertSummary) {
setSummaryOpen(true);
}
}, [scroll]);
useEffect(() => {
setDialogOpen(!!status);
}, [status]);
useEffect(() => {
setSnackbarOpen(!!error);
}, [error]);
function onSnooze(alert: Alert) {
setAlert(alert);
setStatus(AlertStatus.Snoozed);
}
function onAccept(alert: Alert) {
setAlert(alert);
setStatus(AlertStatus.Accepted);
}
function onDismiss(alert: Alert) {
setAlert(alert);
setStatus(AlertStatus.Dismissed);
}
function onSnackbarClose() {
setError(null);
}
function onDialogClose() {
setAlert(null);
setStatus(null);
}
function onDialogFormSubmit(data: any) {
setData(data);
}
function onSummaryButtonClick() {
setSummaryOpen(prevOpen => !prevOpen);
}
const total = [accepted, snoozed, dismissed].reduce(sumOfAllAlerts, 0);
const isAlertStatusSummaryDisplayed = !!total;
const isAlertInsightSectionDisplayed = !!active.length;
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}
onSnooze={onSnooze}
onAccept={onAccept}
onDismiss={onDismiss}
/>
</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}
alert={alert}
status={status}
onClose={onDialogClose}
onSubmit={onDialogFormSubmit}
/>
<Snackbar
open={isSnackbarOpen}
autoHideDuration={6_000}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
onClose={onSnackbarClose}
>
<MuiAlert onClose={onSnackbarClose} severity="error">
{error?.message}
</MuiAlert>
</Snackbar>
</Grid>
</Grid>
);
);
};
@@ -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,10 +30,10 @@ export const AlertInsightsHeader = ({
subtitle,
}: AlertInsightsHeaderProps) => {
const classes = useStyles();
const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader);
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,38 +17,115 @@ import React from 'react';
import { AlertInsightsSection } from './AlertInsightsSection';
import { render } from '@testing-library/react';
import { Alert } from '../../types';
import { MockScrollProvider } from '../..';
import { MockScrollProvider } 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',
url: '/cost-insights/test',
};
function renderInContext(children: JSX.Element) {
return render(<MockScrollProvider>{children}</MockScrollProvider>);
}
describe('<AlertInsightsSection/>', () => {
it('Renders alert without exploding', () => {
const { getByText } = render(
<MockScrollProvider>
<AlertInsightsSection alert={mockAlert} number={1} />
</MockScrollProvider>,
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();
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>,
const { queryByText } = renderInContext(
<AlertInsightsSection
alert={alert}
number={1}
onSnooze={jest.fn()}
onDismiss={jest.fn()}
onAccept={jest.fn()}
/>,
);
expect(queryByText('View Instructions')).not.toBeInTheDocument();
});
it('Displays a snooze button if a hook is provided', () => {
const alert: Alert = {
...mockAlert,
onSnoozed: jest.fn(),
};
const { queryByText, getByText } = renderInContext(
<AlertInsightsSection
alert={alert}
number={1}
onSnooze={jest.fn()}
onDismiss={jest.fn()}
onAccept={jest.fn()}
/>,
);
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 { queryByText, getByText } = renderInContext(
<AlertInsightsSection
alert={alert}
number={1}
onSnooze={jest.fn()}
onDismiss={jest.fn()}
onAccept={jest.fn()}
/>,
);
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 { queryByText, getByText } = renderInContext(
<AlertInsightsSection
alert={alert}
number={1}
onSnooze={jest.fn()}
onDismiss={jest.fn()}
onAccept={jest.fn()}
/>,
);
expect(getByText('Accept')).toBeInTheDocument();
expect(queryByText('Snooze')).not.toBeInTheDocument();
expect(queryByText('Dismiss')).not.toBeInTheDocument();
});
});
@@ -15,30 +15,84 @@
*/
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 { 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 isSnoozeButtonDisplayed = isSnoozeEnabled(alert);
const isAcceptButtonDisplayed = isAcceptEnabled(alert);
const isDismissButtonDisplayed = isDismissEnabled(alert);
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={() => onAccept(alert)}
startIcon={<AcceptIcon />}
>
Accept
</Button>
</Box>
)}
{isSnoozeButtonDisplayed && (
<Box mr={1}>
<Button
color="default"
variant="outlined"
aria-label="snooze"
disableElevation
onClick={() => onSnooze(alert)}
startIcon={<SnoozeIcon />}
>
Snooze
</Button>
</Box>
)}
{isDismissButtonDisplayed && (
<Button
color="secondary"
variant="outlined"
aria-label="dismiss"
disableElevation
onClick={() => onDismiss(alert)}
startIcon={<DismissIcon />}
>
Dismiss
</Button>
)}
</Box>
)}
{alert.element}
@@ -15,34 +15,46 @@
*/
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 { ScrollAnchor } from '../../utils/scroll';
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 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,118 @@
/*
* 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, 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';
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[];
accepted: Alert[];
dismissed: Alert[];
};
export const AlertStatusSummary = ({
open,
snoozed,
accepted,
dismissed,
}: AlertStatusSummaryProps) => {
const isSnoozedListDisplayed = !!snoozed.length;
const isAcceptedListDisplayed = !!accepted.length;
const isDismissedListDisplayed = !!dismissed.length;
return (
<Collapse in={open}>
{isAcceptedListDisplayed && (
<AlertGroup
title="Accepted"
alerts={accepted}
status={AlertStatus.Accepted}
icon={
<AcceptIcon
role="img"
aria-hidden={false}
aria-label={AlertStatus.Accepted}
/>
}
/>
)}
{isSnoozedListDisplayed && (
<AlertGroup
title="Snoozed"
alerts={snoozed}
status={AlertStatus.Snoozed}
icon={
<SnoozeIcon
role="img"
aria-hidden={false}
aria-label={AlertStatus.Snoozed}
/>
}
/>
)}
{isDismissedListDisplayed && (
<AlertGroup
title="Dismissed"
alerts={dismissed}
status={AlertStatus.Dismissed}
icon={
<DismissIcon
role="img"
aria-hidden={false}
aria-label={AlertStatus.Dismissed}
/>
}
/>
)}
</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>
);
};
@@ -41,13 +41,13 @@ type CostInsightsNavigationProps = {
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';
@@ -47,6 +54,12 @@ import { mapLoadingToProps } from './selector';
import { ProjectSelect } from '../ProjectSelect';
import { intervalsOf } from '../../utils/duration';
import { useSubtleTypographyStyles } from '../../utils/styles';
import {
isAlertActive,
isAlertAccepted,
isAlertDismissed,
isAlertSnoozed,
} from '../../utils/alerts';
export const CostInsightsPage = () => {
const classes = useSubtleTypographyStyles();
@@ -54,16 +67,24 @@ export const CostInsightsPage = () => {
const config = useConfig();
const groups = useGroups();
const lastCompleteBillingDate = useLastCompleteBillingDate();
const [alerts, setAlerts] = useState<Alert[]>([]);
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 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 = !!active.length;
const isAlertInsightsDisplayed = !!alerts.length;
const {
loadingActions,
loadingGroups,
@@ -177,8 +198,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 +249,7 @@ export const CostInsightsPage = () => {
<Box position="sticky" top={20}>
<CostInsightsNavigation
products={products}
alerts={alerts.length}
alerts={active.length}
/>
</Box>
</Grid>
@@ -249,19 +270,22 @@ export const CostInsightsPage = () => {
owner={pageFilters.group}
groups={groups}
hasCostData={!!dailyCost.aggregation.length}
alerts={alerts.length}
alerts={active.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={active}
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
/>
</Box>
</Grid>
<Divider />
</Collapse>
<Grid item xs>
<CostOverviewBanner />
</Grid>
@@ -276,14 +300,21 @@ 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={active}
snoozed={snoozed}
accepted={accepted}
dismissed={dismissed}
onChange={setAlerts}
/>
</Box>
)}
</Grid>
{!alerts.length && <Divider />}
</Grid>
</Collapse>
{!isAlertInsightsDisplayed && <Divider />}
<Grid item xs>
<Box px={3} py={6}>
<ProductInsights
@@ -30,16 +30,17 @@ 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, MetricData } from '../../types';
import { Cost, CostInsightsTheme, Maybe, MetricData } from '../../types';
import { useOverviewTabsStyles } from '../../utils/styles';
import { ScrollAnchor } from '../../utils/scroll';
export type CostOverviewCardProps = {
dailyCostData: Cost;
metricData: MetricData | null;
metricData: Maybe<MetricData>;
};
export const CostOverviewCard = ({
@@ -47,8 +48,12 @@ export const CostOverviewCard = ({
metricData,
}: CostOverviewCardProps) => {
const theme = useTheme<CostInsightsTheme>();
const styles = useOverviewTabsStyles(theme);
const config = useConfig();
const [tabIndex, setTabIndex] = useState(0);
const { setDuration, setProject, setMetric, ...filters } = useFilters(
mapFiltersToProps,
);
// Reset tabIndex if breakdowns available change
useEffect(() => {
@@ -59,15 +64,9 @@ export const CostOverviewCard = ({
}
}, [dailyCostData, tabIndex, setTabIndex]);
const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard);
const { setDuration, setProject, setMetric, ...filters } = useFilters(
mapFiltersToProps,
);
const metric = filters.metric
? findAlways(config.metrics, m => m.kind === filters.metric)
: null;
const styles = useOverviewTabsStyles(theme);
const breakdownTabs = Object.keys(dailyCostData.groupedCosts ?? {}).map(
key => ({
@@ -109,7 +108,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[safeTabIndex].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 '../../alerts';
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,
@@ -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(product.kind);
const [error, setError] = useState<Maybe<Error>>(null);
const dispatchLoading = useLoading(mapLoadingToProps);
const lastCompleteBillingDate = useLastCompleteBillingDate();
@@ -108,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
@@ -124,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,152 @@
/*
* 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 => {
e.preventDefault();
if (reason) {
onSubmit({
other: other,
reason: reason,
feedback: feedback,
});
}
};
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,99 @@
/*
* 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,
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 [duration, setDuration] = useState<Maybe<Duration>>(Duration.P7D);
useEffect(() => disableSubmit(false), [disableSubmit]);
const onFormSubmit: FormEventHandler = e => {
e.preventDefault();
if (duration) {
const repeatInterval = 1;
const today = dayjs().format(DEFAULT_DATE_FORMAT);
onSubmit({
intervals: intervalsOf(duration, today, repeatInterval),
});
}
};
const onSnoozeDurationChange = (
_: ChangeEvent<HTMLInputElement>,
value: string,
) => {
setDuration(value as Duration);
};
return (
<form ref={ref} onSubmit={onFormSubmit}>
<FormControl component="fieldset" fullWidth>
<Typography color="textPrimary">
<b>For how long?</b>
</Typography>
<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';
+11 -72
View File
@@ -18,105 +18,44 @@ import React, {
SetStateAction,
useState,
useContext,
useEffect,
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>>;
scroll: ScrollTo;
setScroll: Dispatch<SetStateAction<ScrollTo>>;
};
export type ScrollUtils = {
ScrollAnchor: (props: Omit<ScrollAnchorProps, 'id'>) => JSX.Element;
scrollIntoView: () => void;
};
export interface ScrollAnchorProps extends ScrollIntoViewOptions {
id: ScrollTo;
top?: number;
left?: number;
}
export const ScrollContext = React.createContext<
ScrollContextProps | undefined
>(undefined);
export const ScrollAnchor = ({
id,
top,
left,
behavior,
block,
inline,
}: 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,
};
useEffect(() => {
function scrollIntoView() {
const options = {
behavior: behavior || 'auto',
block: block || 'start',
inline: inline || 'nearest',
};
if (divRef.current && scrollTo === id) {
divRef.current.scrollIntoView(options);
setScrollTo(null);
}
}
scrollIntoView();
}, [scrollTo, setScrollTo, id, behavior, block, inline]);
return <div ref={divRef} style={styles} 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 }}>
{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] as const;
}
function assertNever(): never {
throw new Error(
`Cannot use useScroll or ScrollAnchor outside ScrollProvider`,
);
throw new Error(`Cannot use useScroll outside ScrollProvider`);
}
+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?: Maybe<AlertForm>;
AcceptForm?: Maybe<AlertForm>;
DismissForm?: Maybe<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',
@@ -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);
});
});
});
+105 -37
View File
@@ -14,50 +14,118 @@
* 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, AlertForm, AlertStatus, Maybe } from '../types';
import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms';
const createAlertHandler = (status?: AlertStatus) => (alert: Alert) =>
alert.status === status;
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');
/**
* 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.
* 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 class UnlabeledDataflowAlert implements Alert {
data: UnlabeledDataflowData;
constructor(data: UnlabeledDataflowData) {
this.data = data;
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;
}
};
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 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;
}
}
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} />;
/**
* 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;
@@ -20,6 +20,7 @@ export const rateOf = (cost: number, duration: Duration) => {
switch (duration) {
case Duration.P30D:
return cost / 12;
case Duration.P7D:
case Duration.P90D:
case Duration.P3M:
return cost / 4;
+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 = [
@@ -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}`}
/>
);
};
+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)',
},
}));
+2 -2
View File
@@ -163,8 +163,8 @@ export type MockScrollProviderProps = PropsWithChildren<{}>;
export const MockScrollProvider = ({ children }: MockScrollProviderProps) => {
const defaultContext: ScrollContextProps = {
scrollTo: null,
setScrollTo: jest.fn(),
scroll: null,
setScroll: jest.fn(),
};
return (
<ScrollContext.Provider value={defaultContext}>