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