Merge pull request #4254 from backstage/cost-insights-alert-hooks
Cost Insights Alert Hooks
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
}
|
||||
+5
-18
@@ -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,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;
|
||||
|
||||
+4
-4
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 team’s priorities.',
|
||||
},
|
||||
{
|
||||
reason: AlertDismissReason.NotApplicable,
|
||||
label: 'This action item doesn’t 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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)',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user