diff --git a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx new file mode 100644 index 0000000000..8940322223 --- /dev/null +++ b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx @@ -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; +} + +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 = MigrationSnoozeForm; + // AcceptForm: AlertForm = 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 ( + + ); + } + + /* Displays a custom dismiss form. */ + async onDismissed( + options: AlertOptions, + ): Promise { + 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, + ): Promise { + 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 { + 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, + }, + ]), + ); + } +} diff --git a/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx new file mode 100644 index 0000000000..67eb1b5071 --- /dev/null +++ b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx @@ -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 ; + } +} diff --git a/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx new file mode 100644 index 0000000000..e889e0e3d4 --- /dev/null +++ b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx @@ -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 ; + } +} diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/plugins/cost-insights/src/alerts/index.ts similarity index 50% rename from plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx rename to plugins/cost-insights/src/alerts/index.ts index aaadbed09d..eb4a079e90 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx +++ b/plugins/cost-insights/src/alerts/index.ts @@ -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; -}; - -export const AlertActionCardList = ({ alerts }: AlertActionCardList) => ( - - {alerts.map((alert, index) => ( - - - {index < alerts.length - 1 && } - - ))} - -); +export { ProjectGrowthAlert } from './ProjectGrowthAlert'; +export { UnlabeledDataflowAlert } from './UnlabeledDataflowAlert'; +export { KubernetesMigrationAlert } from './KubernetesMigrationAlert'; +export type { MigrationAlert } from './KubernetesMigrationAlert'; diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 0ba57f76fa..837cd7a560 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -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; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx similarity index 87% rename from plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx rename to plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx index d896bf0d47..b72f050e5b 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx @@ -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('', () => { +describe('', () => { it('Renders an alert', async () => { const rendered = await renderInTestApp( - , + 1} /> , ); diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx new file mode 100644 index 0000000000..3c95fdaa2f --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx @@ -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 ( + + + + ); +}; diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx new file mode 100644 index 0000000000..8c96e47f1a --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx @@ -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({children}); +} + +describe('', () => { + it('should not display status buttons if there no active alerts', () => { + const { queryByRole } = renderInContext( + , + ); + 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( + , + ); + 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(); + }); +}); diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx new file mode 100644 index 0000000000..320ebb38aa --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx @@ -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 ( + <> + + {active.map((alert, index) => ( + + {index + 1}} + /> + {index < active.length - 1 && } + + ))} + + {isStatusButtonGroupDisplayed && ( + + {isAcceptedButtonDisplayed && ( + } + amount={accepted.length} + onClick={onStatusButtonClick} + /> + )} + {isSnoozedButtonDisplayed && ( + } + onClick={onStatusButtonClick} + /> + )} + {isDismissedButtonDisplayed && ( + } + amount={dismissed.length} + onClick={onStatusButtonClick} + /> + )} + + )} + + ); +}; + +type AlertStatusButtonProps = { + title: string; + amount: number; + icon: JSX.Element; + onClick: MouseEventHandler; +} & IconButtonProps; + +const AlertStatusButton = ({ + title, + amount, + icon, + onClick, + ...buttonProps +}: AlertStatusButtonProps) => ( + + + {icon} + + +); diff --git a/plugins/cost-insights/src/components/ActionItems/index.ts b/plugins/cost-insights/src/components/ActionItems/index.ts new file mode 100644 index 0000000000..b63e34b0af --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/index.ts @@ -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'; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx deleted file mode 100644 index 78feade952..0000000000 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx +++ /dev/null @@ -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 ( - - {number}} - title={alert.title} - subheader={alert.subtitle} - /> - - ); -}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx new file mode 100644 index 0000000000..5785592aeb --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx @@ -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; + +const MockForm = React.forwardRef( + (props, ref) => ( +
+ You. Complete. Me. +
+ ), +); + +describe('', () => { + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx new file mode 100644 index 0000000000..aa5b8c59fb --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx @@ -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; + accepted: Maybe; + dismissed: Maybe; + 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>(null); + const snoozeRef = useRef>(null); + const dismissRef = useRef>(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 ( + + + + + + + + + + + {choose(status, ['Accept', 'Snooze', 'Dismiss'])} this action + item? + + + + + This action item will be{' '} + {choose(status, ['accepted', 'snoozed', 'dismissed'])} for all of{' '} + {group}. + + + + + + + {choose(status, [ + accepted?.title, + snoozed?.title, + dismissed?.title, + ])} + + + + {choose(status, [ + accepted?.subtitle, + snoozed?.subtitle, + dismissed?.subtitle, + ])} + + + {isSnoozeFormDisplayed && ( + + )} + {isDismissFormDisplayed && ( + + )} + {isAcceptFormDisplayed && ( + + )} + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx new file mode 100644 index 0000000000..cd8444cb1f --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx @@ -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( + + + {children} + + , + ); +} + +describe('', () => { + it('should display the correct header if there are active action items', () => { + const { getByText, queryByText } = renderInContext( + , + ); + 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( + , + ); + + 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(); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx index 4606906f04..1bcb684052 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -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 = ({ + dispatch, +}) => (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsAlerts]: isLoading }); type AlertInsightsProps = { - alerts: Array; + group: string; + active: Alert[]; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; }; -export const AlertInsights = ({ alerts }: AlertInsightsProps) => ( - - - - - - {alerts.map((alert, index) => ( - - +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>(undefined); + const [error, setError] = useState>(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, + ) { + 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 ( + + + + + {isAlertInsightSectionDisplayed && ( + + {active.map((alert, index) => ( + + + + ))} - ))} + )} + {isAlertStatusSummaryDisplayed && ( + + + + + {pluralize('Hidden Action Item', total)} + + + + + )} + + + + {error?.message} + + - -); + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx index b58f6973ae..a8006b81d0 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx @@ -30,10 +30,11 @@ export const AlertInsightsHeader = ({ subtitle, }: AlertInsightsHeaderProps) => { const classes = useStyles(); - const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader); + const [, , ScrollAnchor] = useScroll(); + return ( - + {title}{' '} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx index 54d350298c..f04e1d8b01 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -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:
, 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('', () => { it('Renders alert without exploding', () => { - const { getByText } = render( - - - , + const { getByText, queryByText } = render( + + + + + , ); 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( - - - , + + + + + , ); 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( + + + + + , + ); + + 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( + + + + + , + ); + + 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( + + + + + , + ); + + expect(getByText('Accept')).toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); + }); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx index 7e371c8001..dda2d66f3f 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -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 ( - - - {alert.url && ( - - + + + {isButtonGroupDisplayed && ( + + {isAcceptButtonDisplayed && ( + + + + )} + {isSnoozeButtonDisplayed && ( + + + + )} + {isDismissButtonDisplayed && ( + + )} )} {alert.element} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx index 3613e3adc8..c463a05609 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx @@ -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 ( - - + + - {number} - - - {title} - {subtitle} + + + {number} + + + {alert.title} + {alert.subtitle} + + + {isViewInstructionsButtonDisplayed && ( + + + + )} ); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx new file mode 100644 index 0000000000..830db19fcd --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx @@ -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('', () => { + it('should display alerts', () => { + const { getByText, getByRole } = render( + + + , + ); + [mockSnoozed, mockAccepted, mockDismissed].forEach(a => { + expect(getByText(a.title)).toBeInTheDocument(); + expect(getByText(a.subtitle)).toBeInTheDocument(); + expect(getByRole('img', { name: a.status })).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx new file mode 100644 index 0000000000..cb0a149511 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx @@ -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 ( + + {isAcceptedListDisplayed && ( + + {accepted.map((alert, index) => ( + + + {/* Icons indicate alert status. Do not hide from accesibility tree */} + + + } + /> + {index < accepted.length - 1 && } + + ))} + + )} + {isSnoozedListDisplayed && ( + + {snoozed.map((alert, index) => ( + + + + + } + /> + {index < snoozed.length - 1 && } + + ))} + + )} + {isDismissedListDisplayed && ( + + {dismissed.map((alert, index) => ( + + + + + } + /> + {index < dismissed.length - 1 && } + + ))} + + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx new file mode 100644 index 0000000000..ed8c7fabbb --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx @@ -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) => { + 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 ( + + ); +}; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx index a30b6c5a90..dd1652af46 100644 --- a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx @@ -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 ( setScroll(navigation)} > {icon} { 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>(null); const [products, setProducts] = useState>(null); const [dailyCost, setDailyCost] = useState>(null); const [metricData, setMetricData] = useState>(null); - const [alerts, setAlerts] = useState>(null); const [error, setError] = useState>(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 = () => { ); } - // These should be defined, alerts can be an empty array but that's truthy - if (!dailyCost || !alerts) { + + if (!dailyCost) { return ( {`Error: Could not fetch cost insights data for team ${pageFilters.group}`} ); @@ -228,7 +251,7 @@ export const CostInsightsPage = () => { @@ -249,19 +272,22 @@ export const CostInsightsPage = () => { owner={pageFilters.group} groups={groups} hasCostData={!!dailyCost.aggregation.length} - alerts={alerts.length} + alerts={activeAlerts.length} /> - {!!alerts.length && ( - <> - - - - - - - - )} + + + + + + + + @@ -276,14 +302,20 @@ export const CostInsightsPage = () => { - - {!!alerts?.length && ( + + - + - )} - - {!alerts.length && } + + + {!isAlertInsightsDisplayed && } ( - + + + diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index 39adc4bbf1..1539e431fd 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -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 ( - + {dailyCostData.groupedCosts && } diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx new file mode 100644 index 0000000000..f4d174d521 --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx @@ -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 ( + + + + + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx new file mode 100644 index 0000000000..60aa49b2bc --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx @@ -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; +}; + +export const MigrationBarChart = ({ + currentProduct, + comparedProduct, + services, +}: MigrationBarChartProps) => { + const theme = useTheme(); + + 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 ; +}; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx new file mode 100644 index 0000000000..a84e865e28 --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx @@ -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(); + return ( + + + + {currentProduct} + + + + + {comparedProduct} + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/index.ts b/plugins/cost-insights/src/components/MigrationAlertCard/index.ts similarity index 90% rename from plugins/cost-insights/src/components/AlertActionCardList/index.ts rename to plugins/cost-insights/src/components/MigrationAlertCard/index.ts index a0ce118482..4feb749c27 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/index.ts +++ b/plugins/cost-insights/src/components/MigrationAlertCard/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { AlertActionCardList } from './AlertActionCardList'; +export { MigrationAlertCard } from './MigrationAlertCard'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index fc37bc5dd6..406affcd15 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -77,17 +77,6 @@ const renderProductInsightsCardInTestApp = async ( ); describe('', () => { - 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, diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 534ef490c3..828d229c94 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -60,7 +60,7 @@ export const ProductInsightsCard = ({ }: PropsWithChildren) => { const classes = useStyles(); const mountedRef = useRef(false); - const { ScrollAnchor } = useScroll(product.kind); + const [, , ScrollAnchor] = useScroll(); const [error, setError] = useState>(null); const dispatchLoading = useLoading(mapLoadingToProps); const lastCompleteBillingDate = useLastCompleteBillingDate(); @@ -107,7 +107,7 @@ export const ProductInsightsCard = ({ if (error || !entity) { return ( - + {error ? error.message @@ -123,7 +123,7 @@ export const ProductInsightsCard = ({ subheader={subheader} headerProps={headerProps} > - + {entities.length ? ( ; + +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, isChecked: boolean) => { + setChecked(isChecked); + disableSubmit(!isChecked); + }; + + return ( +
+ + + } + /> + +
+ ); +}); diff --git a/plugins/cost-insights/src/forms/AlertDismissForm.tsx b/plugins/cost-insights/src/forms/AlertDismissForm.tsx new file mode 100644 index 0000000000..1b3cbdb363 --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertDismissForm.tsx @@ -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; + +export const AlertDismissForm = forwardRef< + HTMLFormElement, + AlertDismissFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const classes = useStyles(); + const [other, setOther] = useState>(null); + const [feedback, setFeedback] = useState>(null); + const [reason, setReason] = useState( + AlertDismissReason.Resolved, + ); + + const onFormSubmit: FormEventHandler = e => { + function submit() { + onSubmit({ + other: other, + reason: reason, + feedback: feedback, + }); + } + + e.preventDefault(); + if (reason) { + submit(); + } + }; + + const onReasonChange = (_: ChangeEvent, value: string) => { + setReason(value as AlertDismissReason); + }; + + const onOtherChange = (e: ChangeEvent) => { + return e.target.value + ? setOther(e.target.value as AlertDismissReason) + : setOther(null); + }; + + const onFeedbackChange = (e: ChangeEvent) => { + 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 ( +
+ + + Reason for dismissing? + + + + {AlertDismissOptions.map(option => ( + } + /> + ))} + + + + + + + + + Any other feedback you can provide? + + + +
+ ); +}); diff --git a/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx new file mode 100644 index 0000000000..51218fe844 --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx @@ -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; + +export const AlertSnoozeForm = forwardRef< + HTMLFormElement, + AlertSnoozeFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const classes = useStyles(); + const [error, setError] = useState>(null); + const [duration, setDuration] = useState>(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, + value: string, + ) => { + setDuration(value as Duration); + }; + + useEffect(() => { + function clearErrorOnFormDataChange() { + disableSubmit(false); + setError(prevError => (prevError ? null : prevError)); + } + + clearErrorOnFormDataChange(); + }, [duration, disableSubmit]); + + const isErrorMessageDisplayed = !!error; + + return ( +
+ + + For how long? + + + {error?.message} + + + + {AlertSnoozeOptions.map(option => ( + } + /> + ))} + + + +
+ ); +}); diff --git a/plugins/cost-insights/src/forms/MigrationDismissForm.tsx b/plugins/cost-insights/src/forms/MigrationDismissForm.tsx new file mode 100644 index 0000000000..ce71af3a37 --- /dev/null +++ b/plugins/cost-insights/src/forms/MigrationDismissForm.tsx @@ -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(alert.data.services); + + const onFormSubmit: FormEventHandler = e => { + /* Remember to prevent default form behavior */ + e.preventDefault(); + onSubmit({ services: services }); + }; + + const onCheckboxChange = ( + e: ChangeEvent, + 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. */ +
+ + + Or choose which services to dismiss this alert for. + + + {alert.data.services.map((service, index) => ( + p.id === service.id)} + onChange={onCheckboxChange} + /> + } + /> + ))} + + +
+ ); +}); diff --git a/plugins/cost-insights/src/forms/index.ts b/plugins/cost-insights/src/forms/index.ts new file mode 100644 index 0000000000..5b8384a293 --- /dev/null +++ b/plugins/cost-insights/src/forms/index.ts @@ -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'; diff --git a/plugins/cost-insights/src/hooks/index.ts b/plugins/cost-insights/src/hooks/index.ts index 94c556763c..c1a4b3512c 100644 --- a/plugins/cost-insights/src/hooks/index.ts +++ b/plugins/cost-insights/src/hooks/index.ts @@ -16,6 +16,7 @@ export * from './useConfig'; export * from './useCurrency'; +export * from './useAlerts'; export * from './useFilters'; export * from './useCurrency'; export * from './useGroups'; diff --git a/plugins/cost-insights/src/hooks/useAlerts.tsx b/plugins/cost-insights/src/hooks/useAlerts.tsx new file mode 100644 index 0000000000..6bc311b09b --- /dev/null +++ b/plugins/cost-insights/src/hooks/useAlerts.tsx @@ -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>>; +}; + +export const AlertsContext = createContext( + undefined, +); + +export type AlertState = { + alerts: Alert[]; + snoozed: Maybe; + accepted: Maybe; + dismissed: Maybe; +}; + +const initialState: AlertState = { + alerts: [], + snoozed: null, + accepted: null, + dismissed: null, +}; + +const reducer = ( + prevState: AlertState, + action: SetStateAction>, +): AlertState => ({ + ...prevState, + ...action, +}); + +export const AlertsProvider = ({ children }: PropsWithChildren<{}>) => { + const [alerts, setAlerts] = useReducer(reducer, initialState); + + return ( + + {children} + + ); +}; + +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'); +} diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx index 0a1abbdad3..7f310d59b0 100644 --- a/plugins/cost-insights/src/hooks/useScroll.tsx +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -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; export type ScrollContextProps = { - scrollTo: ScrollTo; - setScrollTo: Dispatch>; -}; - -export type ScrollUtils = { - ScrollAnchor: (props: Omit) => JSX.Element; - scrollIntoView: () => void; + scroll: ScrollTo; + setScroll: Dispatch>; + ScrollAnchor: ElementType; }; 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(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
; + return ( +
+ ); }; export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => { - const [scrollTo, setScrollTo] = useState(null); + const [scroll, setScroll] = useState(null); return ( - + {children} ); }; -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 => , - scrollIntoView: () => context.setScrollTo(id), - }; + return [context.scroll, context.setScroll, context.ScrollAnchor] as const; } function assertNever(): never { diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 7cd0456ad1..13ce929401 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -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'; diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index 18b86c8cb8..21e1a779a8 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -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; + onAccepted?(options: AlertOptions): Promise; + onDismissed?(options: AlertOptions): Promise; }; +export type AlertForm< + A extends Alert = any, + Data = any +> = ForwardRefExoticComponent< + AlertFormProps & RefAttributes +>; + +export interface AlertOptions { + 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; + reason: AlertDismissReason; + feedback: Maybe; +} + +// TODO: Convert enum to literal +export enum AlertStatus { + Snoozed = 'snoozed', + Accepted = 'accepted', + Dismissed = 'dismissed', +} + +export type AlertFormProps = { + 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]; diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index c0f03d5c27..e25f38e63d 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -21,6 +21,7 @@ * September 15. */ export enum Duration { + P7D = 'P7D', P30D = 'P30D', P90D = 'P90D', P3M = 'P3M', diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx index fbb148f38a..b8dfddf79b 100644 --- a/plugins/cost-insights/src/utils/alerts.tsx +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -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 ; - } -} - -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 ; - } +export function choose( + status: readonly [boolean, boolean, boolean], + values: [T, T, T], +): T | null { + const i = status.indexOf(true); + return i < 0 ? null : values[i]; } diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 6eebead1d7..79ea150fc5 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -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 { diff --git a/plugins/cost-insights/src/utils/loading.ts b/plugins/cost-insights/src/utils/loading.ts index 3328f23cfc..d15101075d 100644 --- a/plugins/cost-insights/src/utils/loading.ts +++ b/plugins/cost-insights/src/utils/loading.ts @@ -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 = [ diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts index c04ceba8e3..acb900c490 100644 --- a/plugins/cost-insights/src/utils/styles.ts +++ b/plugins/cost-insights/src/utils/styles.ts @@ -479,8 +479,8 @@ export const useSelectStyles = makeStyles( }), ); -export const useAlertActionCardStyles = makeStyles( - (theme: BackstageTheme) => +export const useActionItemCardStyles = makeStyles( + (theme: CostInsightsTheme) => createStyles({ card: { boxShadow: 'none', @@ -489,15 +489,12 @@ export const useAlertActionCardStyles = makeStyles( backgroundColor: theme.palette.textVerySubtle, color: theme.palette.text.primary, }, - }), -); - -export const useAlertActionCardHeader = makeStyles( - (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(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)', + }, +})); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 852330acc3..13b38a49f2 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -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(() =>
), }; return ( @@ -231,3 +233,28 @@ export const MockCostInsightsApiProvider = ({ return {children}; }; + +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 ( + + {children} + + ); +};