From 70906eed4cda0582aea7c4493494333c3afa841d Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 28 Jan 2021 18:55:36 -0500 Subject: [PATCH] refactor --- .../src/alerts/KubernetesMigrationAlert.tsx | 9 +- plugins/cost-insights/src/client.ts | 4 +- .../AlertInsights/AlertDialog.test.tsx | 170 +++++++++--------- .../components/AlertInsights/AlertDialog.tsx | 152 +++++----------- .../AlertInsights/AlertInsights.test.tsx | 12 +- .../AlertInsights/AlertInsights.tsx | 119 ++++++------ .../AlertInsights/AlertInsightsHeader.tsx | 3 +- .../AlertInsightsSection.test.tsx | 97 +++++----- .../AlertInsights/AlertInsightsSection.tsx | 26 ++- .../AlertInsightsSectionHeader.tsx | 3 +- .../AlertInsights/AlertStatusSummary.tsx | 130 +++++++------- .../CostInsightsPage/CostInsightsPage.tsx | 45 ++--- .../CostInsightsPage/CostInsightsPageRoot.tsx | 5 +- .../CostOverviewCard/CostOverviewCard.tsx | 4 +- .../ProductInsightsCard.tsx | 3 +- .../src/forms/AlertSnoozeForm.tsx | 26 +-- plugins/cost-insights/src/hooks/index.ts | 1 - plugins/cost-insights/src/hooks/useAlerts.tsx | 76 -------- plugins/cost-insights/src/hooks/useScroll.tsx | 56 +----- .../cost-insights/src/utils/alerts.test.tsx | 109 +++++++++++ plugins/cost-insights/src/utils/alerts.tsx | 124 +++++++++++-- plugins/cost-insights/src/utils/scroll.tsx | 65 +++++++ plugins/cost-insights/src/utils/tests.tsx | 27 --- 23 files changed, 646 insertions(+), 620 deletions(-) delete mode 100644 plugins/cost-insights/src/hooks/useAlerts.tsx create mode 100644 plugins/cost-insights/src/utils/alerts.test.tsx create mode 100644 plugins/cost-insights/src/utils/scroll.tsx diff --git a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx index 8c59d0c1a5..31a4d7e0e9 100644 --- a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx +++ b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx @@ -72,7 +72,6 @@ export class KubernetesMigrationAlert implements MigrationAlert { // Dialog will not render a form if form property set to null. AcceptForm = null; - // Overrides default Dismiss form with a custom form component. DismissForm: AlertForm< MigrationAlert, @@ -94,7 +93,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { get element() { const subheader = `${pluralize( - 'Compute Engine role', + 'Service', this.data.services.length, true, )}, sorted by cost`; @@ -116,7 +115,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { const alerts = await this.api.getAlerts(options.group); return new Promise(resolve => setTimeout(resolve, 750, [ - ...alerts.slice(0, 2), + ...alerts.filter(a => a.title !== this.title), { title: this.title, subtitle: this.subtitle, @@ -133,7 +132,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { const alerts = await this.api.getAlerts(options.group); return new Promise(resolve => setTimeout(resolve, 750, [ - ...alerts.slice(0, 2), + ...alerts.filter(a => a.title !== this.title), { title: this.title, subtitle: this.subtitle, @@ -148,7 +147,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { const alerts = await this.api.getAlerts(options.group); return new Promise(resolve => setTimeout(resolve, 750, [ - ...alerts.slice(0, 2), + ...alerts.filter(a => a.title !== this.title), { title: this.title, subtitle: this.subtitle, diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 7849214c03..2e70170fc1 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -183,8 +183,8 @@ export class ExampleCostInsightsClient implements CostInsightsApi { new ProjectGrowthAlert(projectGrowthData), new UnlabeledDataflowAlert(unlabeledDataflowData), new KubernetesMigrationAlert(this, { - startDate: today.format(DEFAULT_DATE_FORMAT), - endDate: today.add(30, 'day').format(DEFAULT_DATE_FORMAT), + startDate: today.subtract(30, 'day').format(DEFAULT_DATE_FORMAT), + endDate: today.format(DEFAULT_DATE_FORMAT), change: { ratio: 0, amount: 0, diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx index 72ff757cee..7061f0736b 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ import React from 'react'; +import { capitalize } from '@material-ui/core'; import { AlertDialog } from './AlertDialog'; import { render } from '@testing-library/react'; -import { Alert, AlertFormProps } from '../../types'; +import { Alert, AlertFormProps, AlertStatus } from '../../types'; type MockFormDataProps = AlertFormProps; @@ -40,7 +41,7 @@ const dimissableAlert: Alert = { onDismissed: jest.fn(), }; -const acceptAlert: Alert = { +const acceptableAlert: Alert = { title: 'title', subtitle: 'subtitle', onAccepted: jest.fn(), @@ -87,98 +88,87 @@ const nullSnoozeAlert: Alert = { onSnoozed: jest.fn(), SnoozeForm: null, }; + describe('', () => { describe.each` - accepted | dismissed | snoozed | action | text - ${acceptAlert} | ${null} | ${null} | ${['Accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} - ${null} | ${dimissableAlert} | ${null} | ${['Dismiss', 'dismissed']} | ${'Reason for dismissing?'} - ${null} | ${null} | ${snoozableAlert} | ${['Snooze', 'snoozed']} | ${'For how long?'} - `( - 'Default forms', - ({ accepted, dismissed, snoozed, action: [action, actioned], text }) => { - it(`Displays a default ${action} form`, () => { - const { getByText } = render( - , - ); - expect(getByText(text)).toBeInTheDocument(); - expect(getByText(`${action} this action item?`)).toBeInTheDocument(); - expect( - getByText(`This action item will be ${actioned} for all of Ramones.`), - ).toBeInTheDocument(); - }); - }, - ); + alert | status | action | text + ${acceptableAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${dimissableAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${snoozableAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Default forms', ({ alert, status, action: [action, actioned], text }) => { + it(`Displays a default ${action} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(text)).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); describe.each` - accepted | dismissed | snoozed | action - ${customAcceptAlert} | ${null} | ${null} | ${['Accept', 'accepted']} - ${null} | ${customDismissAlert} | ${null} | ${['Dismiss', 'dismissed']} - ${null} | ${null} | ${customSnoozeAlert} | ${['Snooze', 'snoozed']} - `( - 'Custom forms', - ({ accepted, dismissed, snoozed, action: [Action, actioned] }) => { - it(`Displays a custom ${Action} form`, () => { - const { getByText } = render( - , - ); - expect(getByText(`You. ${Action}. Me.`)).toBeInTheDocument(); - expect(getByText(`${Action} this action item?`)).toBeInTheDocument(); - expect( - getByText(`This action item will be ${actioned} for all of Ramones.`), - ).toBeInTheDocument(); - }); - }, - ); + alert | status | action | text + ${customAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${customDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${customSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Custom forms', ({ alert, status, action: [action, actioned] }) => { + it(`Displays a custom ${capitalize(action)} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(`You. ${capitalize(action)}. Me.`)).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); describe.each` - accepted | dismissed | snoozed | action | text - ${nullAcceptAlert} | ${null} | ${null} | ${['Accept', 'accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} - ${null} | ${nullDismissAlert} | ${null} | ${['Dismiss', 'dismiss', 'dismissed']} | ${'Reason for dismissing?'} - ${null} | ${null} | ${nullSnoozeAlert} | ${['Snooze', 'snooze', 'snoozed']} | ${'For how long?'} - `( - 'Null forms', - ({ - accepted, - dismissed, - snoozed, - action: [Action, action, actioned], - text, - }) => { - it(`Does NOT display a ${Action} form`, () => { - const { getByText, getByRole, queryByText } = render( - , - ); - expect(queryByText(text)).not.toBeInTheDocument(); - expect(getByRole('button', { name: action })).toBeInTheDocument(); - expect(getByText(`${Action} this action item?`)).toBeInTheDocument(); - expect( - getByText(`This action item will be ${actioned} for all of Ramones.`), - ).toBeInTheDocument(); - }); - }, - ); + alert | status | action | text + ${nullAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${nullDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${nullSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Null forms', ({ alert, status, action: [action, actioned], text }) => { + it(`Does NOT display a ${capitalize(action)} form`, () => { + const { getByText, getByRole, queryByText } = render( + , + ); + expect(queryByText(text)).not.toBeInTheDocument(); + expect(getByRole('button', { name: action })).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx index f7154602b9..ab6f9fb9f0 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx @@ -15,8 +15,8 @@ */ import React, { useEffect, useRef, useState } from 'react'; -import { default as CloseIcon } from '@material-ui/icons/Close'; import { + capitalize, Box, Button, Divider, @@ -26,23 +26,18 @@ import { DialogContent, Typography, } from '@material-ui/core'; -import { - AlertAcceptForm, - AlertDismissForm, - AlertSnoozeForm, -} from '../../forms'; +import { default as CloseIcon } from '@material-ui/icons/Close'; import { useAlertDialogStyles as useStyles } from '../../utils/styles'; -import { choose } from '../../utils/alerts'; -import { Alert, AlertForm, Maybe } from '../../types'; +import { Alert, AlertStatus, Maybe } from '../../types'; +import { choose, formOf } from '../../utils/alerts'; const DEFAULT_FORM_ID = 'alert-form'; type AlertDialogProps = { open: boolean; group: string; - snoozed: Maybe; - accepted: Maybe; - dismissed: Maybe; + alert: Maybe; + status: Maybe; onClose: () => void; onSubmit: (data: any) => void; }; @@ -50,87 +45,50 @@ type AlertDialogProps = { export const AlertDialog = ({ open, group, - snoozed, - accepted, - dismissed, + alert, + status, onClose, onSubmit, }: AlertDialogProps) => { const classes = useStyles(); - const [isButtonDisabled, setDisabled] = useState(true); - const acceptRef = useRef>(null); - const snoozeRef = useRef>(null); - const dismissRef = useRef>(null); + const [isSubmitDisabled, setSubmitDisabled] = useState(true); + const formRef = useRef>(null); useEffect(() => { - if (open) { - setDisabled(true); - } else { - setDisabled(false); - } + setSubmitDisabled(open); }, [open]); function disableSubmit(isDisabled: boolean) { - setDisabled(isDisabled); + setSubmitDisabled(isDisabled); } function onDialogClose() { onClose(); - setDisabled(true); + setSubmitDisabled(true); } - const SnoozeForm: Maybe = snoozed?.SnoozeForm ?? AlertSnoozeForm; - const AcceptForm: Maybe = accepted?.AcceptForm ?? AlertAcceptForm; - const DismissForm: Maybe = - dismissed?.DismissForm ?? AlertDismissForm; - - const isSnoozingEnabled = !!snoozed?.onSnoozed; - const isAcceptingEnabled = !!accepted?.onAccepted; - const isDismissingEnabled = !!dismissed?.onDismissed; - - const isSnoozeFormDisabled = snoozed?.SnoozeForm === null; - const isAcceptFormDisabled = accepted?.AcceptForm === null; - const isDismissFormDisabled = dismissed?.DismissForm === null; - const isFormDisabled = - isSnoozeFormDisabled || isAcceptFormDisabled || isDismissFormDisabled; - - const status = [ - isSnoozingEnabled, - isAcceptingEnabled, - isDismissingEnabled, - ] as const; - - const [Action, action, actioned] = - choose(status, [ - ['Snooze', 'snooze', 'snoozed'], - ['Accept', 'accept', 'accepted'], - ['Dismiss', 'dismiss', 'dismissed'], - ]) ?? []; - - const [title, subtitle] = - choose(status, [ - [snoozed?.title, snoozed?.subtitle], - [accepted?.title, accepted?.subtitle], - [dismissed?.title, dismissed?.subtitle], - ]) ?? []; + const [action, actioned] = choose( + status, + [ + ['snooze', 'snoozed'], + ['accept', 'accepted'], + ['dismiss', 'dismissed'], + ], + ['', ''], + ); 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; + if (formRef.current) { + formRef.current.id = DEFAULT_FORM_ID; } }, }; + const Form = formOf(alert, status); + return ( @@ -152,7 +110,7 @@ export const AlertDialog = ({ - {Action} this action item? + {capitalize(action)} this action item? @@ -169,30 +127,14 @@ export const AlertDialog = ({ borderRadius={4} > - {title} + {alert?.title} - {subtitle} + {alert?.subtitle} - {isSnoozingEnabled && !isSnoozeFormDisabled && ( - - )} - {isDismissingEnabled && !isDismissFormDisabled && ( - - )} - {isAcceptingEnabled && !isAcceptFormDisabled && ( - @@ -200,7 +142,18 @@ export const AlertDialog = ({ - {isFormDisabled ? ( + {Form ? ( + + ) : ( - ) : ( - )} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx index cd8444cb1f..98f75b1d41 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx @@ -17,18 +17,12 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; import { AlertInsights } from './AlertInsights'; -import { - MockScrollProvider, - MockAlertsProvider, - MockLoadingProvider, -} from '../../utils/tests'; +import { MockScrollProvider, MockLoadingProvider } from '../../utils/tests'; function renderInContext(children: JSX.Element) { return render( - - {children} - + {children} , ); } @@ -47,6 +41,7 @@ describe('', () => { snoozed={[]} accepted={[]} dismissed={[]} + onChange={jest.fn()} />, ); expect( @@ -70,6 +65,7 @@ describe('', () => { ]} accepted={[]} dismissed={[]} + onChange={jest.fn()} />, ); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx index 1bcb684052..59b2dc9eec 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import pluralize from 'pluralize'; import { Box, Grid, Snackbar } from '@material-ui/core'; import { default as MuiAlert } from '@material-ui/lab/Alert'; @@ -24,15 +24,20 @@ import { AlertStatusSummaryButton } from './AlertStatusSummaryButton'; import { AlertInsightsHeader } from './AlertInsightsHeader'; 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'; +import { Alert, AlertOptions, AlertStatus, Maybe } from '../../types'; +import { + isStatusSnoozed, + isStatusAccepted, + isStatusDismissed, + sumOfAllAlerts, +} from '../../utils/alerts'; +import { ScrollAnchor } from '../../utils/scroll'; type MapLoadingtoAlerts = (isLoading: boolean) => void; @@ -47,6 +52,7 @@ type AlertInsightsProps = { snoozed: Alert[]; accepted: Alert[]; dismissed: Alert[]; + onChange: (alerts: Alert[]) => void; }; export const AlertInsights = ({ @@ -55,10 +61,12 @@ export const AlertInsights = ({ snoozed, accepted, dismissed, + onChange, }: AlertInsightsProps) => { - const [alerts, setAlerts] = useAlerts(); - const [scroll, , ScrollAnchor] = useScroll(); + const [scroll] = useScroll(); + const [alert, setAlert] = useState>(null); const dispatchLoadingAlerts = useLoading(mapLoadingToAlerts); + const [status, setStatus] = useState>(null); // Allow users to pass null values for data. const [data, setData] = useState>(undefined); const [error, setError] = useState>(null); @@ -66,22 +74,19 @@ export const AlertInsights = ({ 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( + async function callAlertHook( options: AlertOptions, callback: (options: AlertOptions) => Promise, ) { - closeDialog(); + setAlert(null); + setStatus(null); + setData(undefined); + setDialogOpen(false); dispatchLoadingAlerts(true); try { - const a: Alert[] = await callback(options); - setAlerts({ alerts: a }); + const alerts: Alert[] = await callback(options); + onChange(alerts); } catch (e) { setError(e); } finally { @@ -90,22 +95,20 @@ export const AlertInsights = ({ } 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; + const onSnoozed = alert?.onSnoozed?.bind(alert); + const onAccepted = alert?.onAccepted?.bind(alert); + const onDismissed = alert?.onDismissed?.bind(alert); if (data !== undefined) { - if (onSnoozed) { - callHandler(options, onSnoozed); - } else if (onAccepted) { - callHandler(options, onAccepted); - } else if (onDismissed) { - callHandler(options, onDismissed); + if (isStatusSnoozed(status) && onSnoozed) { + callAlertHook(options, onSnoozed); + } else if (isStatusAccepted(status) && onAccepted) { + callAlertHook(options, onAccepted); + } else if (isStatusDismissed(status) && onDismissed) { + callAlertHook(options, onDismissed); } } - }, [group, data, alerts, setAlerts, closeDialog, dispatchLoadingAlerts]); + }, [group, data, alert, status, onChange, dispatchLoadingAlerts]); useEffect(() => { if (scroll === ScrollType.AlertSummary) { @@ -114,34 +117,38 @@ export const AlertInsights = ({ }, [scroll]); useEffect(() => { - if (error) { - setSnackbarOpen(true); - } else { - setSnackbarOpen(false); - } - }, [error]); + setDialogOpen(!!status); + }, [status]); useEffect(() => { - function toggleDialogOnStatusChange() { - const isAlertSnoozed = !!alerts.snoozed; - const isAlertAccepted = !!alerts.accepted; - const isAlertDismissed = !!alerts.dismissed; + setSnackbarOpen(!!error); + }, [error]); - if (isAlertSnoozed || isAlertDismissed || isAlertAccepted) { - setDialogOpen(true); - } else { - setDialogOpen(false); - } - } + function onSnooze(alert: Alert) { + setAlert(alert); + setStatus(AlertStatus.Snoozed); + } - toggleDialogOnStatusChange(); - }, [alerts.snoozed, alerts.dismissed, alerts.accepted]); + function onAccept(alert: Alert) { + setAlert(alert); + setStatus(AlertStatus.Accepted); + } + + function onDismiss(alert: Alert) { + setAlert(alert); + setStatus(AlertStatus.Dismissed); + } function onSnackbarClose() { setError(null); } - function onDialogSubmit(data: any) { + function onDialogClose() { + setAlert(null); + setStatus(null); + } + + function onDialogFormSubmit(data: any) { setData(data); } @@ -153,7 +160,6 @@ export const AlertInsights = ({ const isAlertStatusSummaryDisplayed = !!total; const isAlertInsightSectionDisplayed = !!active.length; - // AlertInsights will not display if there aren't any active or hidden items. return ( @@ -171,7 +177,13 @@ export const AlertInsights = ({ {active.map((alert, index) => ( - + ))} @@ -195,11 +207,10 @@ export const AlertInsights = ({ { const classes = useStyles(); - const [, , ScrollAnchor] = useScroll(); return ( diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx index f04e1d8b01..937e099356 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -17,8 +17,7 @@ import React from 'react'; import { AlertInsightsSection } from './AlertInsightsSection'; import { render } from '@testing-library/react'; import { Alert } from '../../types'; -import { AlertState } from '../../hooks'; -import { MockScrollProvider, MockAlertsProvider } from '../../utils/tests'; +import { MockScrollProvider } from '../../utils/tests'; const mockAlert: Alert = { subtitle: @@ -27,14 +26,20 @@ const mockAlert: Alert = { url: '/cost-insights/test', }; +function renderInContext(children: JSX.Element) { + return render({children}); +} + describe('', () => { it('Renders alert without exploding', () => { - const { getByText, queryByText } = render( - - - - - , + const { getByText, queryByText } = renderInContext( + , ); expect(getByText(mockAlert.title)).toBeInTheDocument(); expect(getByText(mockAlert.subtitle)).toBeInTheDocument(); @@ -49,12 +54,14 @@ describe('', () => { ...mockAlert, url: undefined, }; - const { queryByText } = render( - - - - - , + const { queryByText } = renderInContext( + , ); expect(queryByText('View Instructions')).not.toBeInTheDocument(); }); @@ -65,19 +72,14 @@ describe('', () => { onSnoozed: jest.fn(), }; - const context: AlertState = { - alerts: [], - snoozed: alert, - dismissed: null, - accepted: null, - }; - - const { queryByText, getByText } = render( - - - - - , + const { queryByText, getByText } = renderInContext( + , ); expect(getByText('Snooze')).toBeInTheDocument(); @@ -90,19 +92,15 @@ describe('', () => { ...mockAlert, onDismissed: jest.fn(), }; - const context: AlertState = { - alerts: [], - snoozed: null, - dismissed: alert, - accepted: null, - }; - const { queryByText, getByText } = render( - - - - - , + const { queryByText, getByText } = renderInContext( + , ); expect(getByText('Dismiss')).toBeInTheDocument(); @@ -116,19 +114,14 @@ describe('', () => { onAccepted: jest.fn(), }; - const context: AlertState = { - alerts: [], - snoozed: null, - dismissed: null, - accepted: alert, - }; - - const { queryByText, getByText } = render( - - - - - , + const { queryByText, getByText } = renderInContext( + , ); expect(getByText('Accept')).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx index dda2d66f3f..d52687ae19 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -19,23 +19,31 @@ 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'; +import { + isSnoozeEnabled, + isAcceptEnabled, + isDismissEnabled, +} from '../../utils/alerts'; type AlertInsightsSectionProps = { alert: Alert; number: number; + onSnooze: (alert: Alert) => void; + onAccept: (alert: Alert) => void; + onDismiss: (alert: Alert) => void; }; export const AlertInsightsSection = ({ alert, number, + onSnooze, + onAccept, + onDismiss, }: AlertInsightsSectionProps) => { - const [, setAlerts] = useAlerts(); - - const isSnoozeButtonDisplayed = !!alert.onSnoozed; - const isAcceptButtonDisplayed = !!alert.onAccepted; - const isDismissButtonDisplayed = !!alert.onDismissed; + const isSnoozeButtonDisplayed = isSnoozeEnabled(alert); + const isAcceptButtonDisplayed = isAcceptEnabled(alert); + const isDismissButtonDisplayed = isDismissEnabled(alert); const isButtonGroupDisplayed = isSnoozeButtonDisplayed || isAcceptButtonDisplayed || @@ -52,7 +60,7 @@ export const AlertInsightsSection = ({ color="primary" variant="contained" aria-label="accept" - onClick={() => setAlerts({ accepted: alert })} + onClick={() => onAccept(alert)} startIcon={} > Accept @@ -66,7 +74,7 @@ export const AlertInsightsSection = ({ variant="outlined" aria-label="snooze" disableElevation - onClick={() => setAlerts({ snoozed: alert })} + onClick={() => onSnooze(alert)} startIcon={} > Snooze @@ -79,7 +87,7 @@ export const AlertInsightsSection = ({ variant="outlined" aria-label="dismiss" disableElevation - onClick={() => setAlerts({ dismissed: alert })} + onClick={() => onDismiss(alert)} startIcon={} > Dismiss diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx index c463a05609..30b8985934 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Avatar, Box, Button, Grid, Typography } from '@material-ui/core'; import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles'; -import { useScroll } from '../../hooks'; +import { ScrollAnchor } from '../../utils/scroll'; import { Alert } from '../../types'; type AlertInsightsSectionHeaderProps = { @@ -29,7 +29,6 @@ export const AlertInsightsSectionHeader = ({ alert, number, }: AlertInsightsSectionHeaderProps) => { - const [, , ScrollAnchor] = useScroll(); const classes = useStyles(); const isViewInstructionsButtonDisplayed = !!alert.url; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx index cb0a149511..caee99dc5e 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx @@ -15,7 +15,7 @@ */ import React, { Fragment } from 'react'; -import { Avatar, Box, Collapse, Divider } from '@material-ui/core'; +import { Avatar, Box, Collapse, Divider, Tooltip } from '@material-ui/core'; import { default as AcceptIcon } from '@material-ui/icons/Check'; import { default as DismissIcon } from '@material-ui/icons/Delete'; import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; @@ -23,6 +23,35 @@ import { ActionItemCard } from '../ActionItems'; import { Alert, AlertStatus } from '../../types'; import { useActionItemCardStyles as useStyles } from '../../utils/styles'; +type AlertGroupProps = { + alerts: Alert[]; + status: AlertStatus; + title: string; + icon: JSX.Element; +}; + +const AlertGroup = ({ alerts, status, title, icon }: AlertGroupProps) => { + const classes = useStyles(); + return ( + + {alerts.map((alert, index) => ( + + + {icon} + + } + /> + {index < alerts.length - 1 && } + + ))} + + ); +}; + type AlertStatusSummaryProps = { open: boolean; snoozed: Alert[]; @@ -36,8 +65,6 @@ export const AlertStatusSummary = ({ accepted, dismissed, }: AlertStatusSummaryProps) => { - const classes = useStyles(); - const isSnoozedListDisplayed = !!snoozed.length; const isAcceptedListDisplayed = !!accepted.length; const isDismissedListDisplayed = !!dismissed.length; @@ -45,71 +72,46 @@ export const AlertStatusSummary = ({ 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/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index f974fa0ddd..a8ba454d11 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -44,22 +44,21 @@ import { ProductInsights } from '../ProductInsights'; import { useConfig, useCurrency, - useAlerts, useFilters, useGroups, useLastCompleteBillingDate, useLoading, } from '../../hooks'; -import { Cost, Maybe, MetricData, Product, Project } from '../../types'; +import { Alert, 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, + isAlertActive, + isAlertAccepted, + isAlertDismissed, + isAlertSnoozed, } from '../../utils/alerts'; export const CostInsightsPage = () => { @@ -68,7 +67,7 @@ export const CostInsightsPage = () => { const config = useConfig(); const groups = useGroups(); const lastCompleteBillingDate = useLastCompleteBillingDate(); - const [alerts, setAlerts] = useAlerts(); + const [alerts, setAlerts] = useState([]); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); const [products, setProducts] = useState>(null); @@ -78,21 +77,13 @@ export const CostInsightsPage = () => { const { pageFilters, setPageFilters } = useFilters(p => p); - const snoozed = useMemo(() => alerts.alerts.filter(isSnoozed), [ - alerts.alerts, - ]); - const accepted = useMemo(() => alerts.alerts.filter(isAccepted), [ - alerts.alerts, - ]); - const dismissed = useMemo(() => alerts.alerts.filter(isDismissed), [ - alerts.alerts, - ]); - const activeAlerts = useMemo(() => alerts.alerts.filter(isActive), [ - alerts.alerts, - ]); + const active = useMemo(() => alerts.filter(isAlertActive), [alerts]); + const snoozed = useMemo(() => alerts.filter(isAlertSnoozed), [alerts]); + const accepted = useMemo(() => alerts.filter(isAlertAccepted), [alerts]); + const dismissed = useMemo(() => alerts.filter(isAlertDismissed), [alerts]); - const isActionItemsDisplayed = !!activeAlerts.length; - const isAlertInsightsDisplayed = !!alerts.alerts.length; + const isActionItemsDisplayed = !!active.length; + const isAlertInsightsDisplayed = !!alerts.length; const { loadingActions, @@ -150,7 +141,7 @@ export const CostInsightsPage = () => { : client.getGroupDailyCost(pageFilters.group, intervals), ]); setProjects(fetchedProjects); - setAlerts({ alerts: fetchedAlerts }); + setAlerts(fetchedAlerts); setMetricData(fetchedMetricData); setDailyCost(fetchedDailyCost); } else { @@ -175,7 +166,6 @@ export const CostInsightsPage = () => { loadingActions, loadingGroups, loadingBillingDate, - setAlerts, dispatchLoadingInsights, dispatchLoadingInitial, dispatchLoadingNone, @@ -259,7 +249,7 @@ export const CostInsightsPage = () => { @@ -280,14 +270,14 @@ export const CostInsightsPage = () => { owner={pageFilters.group} groups={groups} hasCostData={!!dailyCost.aggregation.length} - alerts={activeAlerts.length} + alerts={active.length} /> { diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx index 1947ca185e..4f506b4076 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx @@ -20,7 +20,6 @@ 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'; @@ -35,9 +34,7 @@ export const CostInsightsPageRoot = () => ( - - - + diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index aedec33457..85a9a9db74 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -30,12 +30,13 @@ import { CostOverviewBreakdownChart } from './CostOverviewBreakdownChart'; import { CostOverviewHeader } from './CostOverviewHeader'; import { MetricSelect } from '../MetricSelect'; import { PeriodSelect } from '../PeriodSelect'; -import { useConfig, useFilters, useScroll } from '../../hooks'; +import { useConfig, useFilters } from '../../hooks'; import { mapFiltersToProps } from './selector'; import { DefaultNavigation } from '../../utils/navigation'; import { findAlways } from '../../utils/assert'; import { Cost, CostInsightsTheme, Maybe, MetricData } from '../../types'; import { useOverviewTabsStyles } from '../../utils/styles'; +import { ScrollAnchor } from '../../utils/scroll'; export type CostOverviewCardProps = { dailyCostData: Cost; @@ -49,7 +50,6 @@ export const CostOverviewCard = ({ const theme = useTheme(); const styles = useOverviewTabsStyles(theme); const config = useConfig(); - const [, , ScrollAnchor] = useScroll(); const [tabIndex, setTabIndex] = useState(0); const { setDuration, setProject, setMetric, ...filters } = useFilters( mapFiltersToProps, diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 4626954bac..d46737cafb 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -34,9 +34,9 @@ import { MapLoadingToProps, useLastCompleteBillingDate, useLoading, - useScroll, } from '../../hooks'; import { findAnyKey } from '../../utils/assert'; +import { ScrollAnchor } from '../../utils/scroll'; type LoadingProps = (isLoading: boolean) => void; @@ -60,7 +60,6 @@ export const ProductInsightsCard = ({ }: PropsWithChildren) => { const classes = useStyles(); const mountedRef = useRef(false); - const [, , ScrollAnchor] = useScroll(); const [error, setError] = useState>(null); const dispatchLoading = useLoading(mapLoadingToProps); const lastCompleteBillingDate = useLastCompleteBillingDate(); diff --git a/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx index 51218fe844..743b5ddac1 100644 --- a/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx +++ b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx @@ -24,7 +24,6 @@ import React, { import dayjs from 'dayjs'; import { Box, - Collapse, FormControl, FormControlLabel, RadioGroup, @@ -50,19 +49,18 @@ export const AlertSnoozeForm = forwardRef< AlertSnoozeFormProps >(({ onSubmit, disableSubmit }, ref) => { const classes = useStyles(); - const [error, setError] = useState>(null); const [duration, setDuration] = useState>(Duration.P7D); + useEffect(() => disableSubmit(false), [disableSubmit]); + const onFormSubmit: FormEventHandler = e => { e.preventDefault(); if (duration) { const repeatInterval = 1; - const inclusiveEndDate = dayjs().format(DEFAULT_DATE_FORMAT); + const today = dayjs().format(DEFAULT_DATE_FORMAT); onSubmit({ - intervals: intervalsOf(duration, inclusiveEndDate, repeatInterval), + intervals: intervalsOf(duration, today, repeatInterval), }); - } else { - setError(new Error('Please select an option.')); } }; @@ -73,26 +71,12 @@ export const AlertSnoozeForm = forwardRef< 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} - >>; -}; - -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 7f310d59b0..137762cc2d 100644 --- a/plugins/cost-insights/src/hooks/useScroll.tsx +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -15,12 +15,9 @@ */ import React, { Dispatch, - ElementType, SetStateAction, useState, useContext, - useEffect, - useRef, PropsWithChildren, } from 'react'; import { Maybe } from '../types'; @@ -30,61 +27,16 @@ export type ScrollTo = Maybe; export type ScrollContextProps = { scroll: ScrollTo; setScroll: Dispatch>; - ScrollAnchor: ElementType; }; -export interface ScrollAnchorProps extends ScrollIntoViewOptions { - id: ScrollTo; - top?: number; - left?: number; -} - export const ScrollContext = React.createContext< ScrollContextProps | undefined >(undefined); -export const ScrollAnchor = ({ - id, - block, - inline, - left = 0, - top = -20, - behavior = 'smooth', -}: ScrollAnchorProps) => { - const divRef = useRef(null); - const [scroll, setScroll] = useScroll(); - - useEffect(() => { - function scrollIntoView() { - const options = { - behavior: behavior || 'auto', - block: block || 'start', - inline: inline || 'nearest', - }; - - if (divRef.current && scroll === id) { - divRef.current.scrollIntoView(options); - setScroll(null); - } - } - - scrollIntoView(); - }, [scroll, setScroll, id, behavior, block, inline]); - - return ( -
- ); -}; - export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => { const [scroll, setScroll] = useState(null); - return ( - + {children} ); @@ -101,11 +53,9 @@ export function useScroll() { assertNever(); } - return [context.scroll, context.setScroll, context.ScrollAnchor] as const; + return [context.scroll, context.setScroll] as const; } function assertNever(): never { - throw new Error( - `Cannot use useScroll or ScrollAnchor outside ScrollProvider`, - ); + throw new Error(`Cannot use useScroll outside ScrollProvider`); } diff --git a/plugins/cost-insights/src/utils/alerts.test.tsx b/plugins/cost-insights/src/utils/alerts.test.tsx new file mode 100644 index 0000000000..d821fd9f1f --- /dev/null +++ b/plugins/cost-insights/src/utils/alerts.test.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { ReactNode } from 'react'; +import { formOf } from './alerts'; +import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms'; +import { Alert, AlertStatus, AlertFormProps } from '../types'; + +type Props = AlertFormProps; + +const createMockForm = (children: ReactNode) => + React.forwardRef((props, ref) => ( + + {children} + + )); + +const snoozeDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), +}; + +const snoozeCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: createMockForm('Snooze'), +}; + +const snoozeNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: null, +}; + +const acceptDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), +}; + +const acceptCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), + AcceptForm: createMockForm('Accept'), +}; + +const acceptNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), + AcceptForm: null, +}; + +const dismissDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), +}; + +const dismissCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: createMockForm('Dismiss'), +}; + +const dismissNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: null, +}; + +describe('formOf', () => { + describe.each` + msg | alert | status | expected + ${'default snooze form'} | ${snoozeDefault} | ${AlertStatus.Snoozed} | ${AlertSnoozeForm} + ${'custom snooze form'} | ${snoozeCustom} | ${AlertStatus.Snoozed} | ${snoozeCustom.SnoozeForm} + ${'null snooze form'} | ${snoozeNull} | ${AlertStatus.Snoozed} | ${null} + ${'default accept form'} | ${acceptDefault} | ${AlertStatus.Accepted} | ${AlertAcceptForm} + ${'custom accept form'} | ${acceptCustom} | ${AlertStatus.Accepted} | ${acceptCustom.AcceptForm} + ${'null accept form'} | ${acceptNull} | ${AlertStatus.Accepted} | ${null} + ${'default dismiss form'} | ${dismissDefault} | ${AlertStatus.Dismissed} | ${AlertDismissForm} + ${'custom dismiss form'} | ${dismissCustom} | ${AlertStatus.Dismissed} | ${dismissCustom.DismissForm} + ${'null dismiss form'} | ${dismissNull} | ${AlertStatus.Dismissed} | ${null} + ${'no form or status'} | ${null} | ${null} | ${null} + `('Should render the correct form', ({ msg, alert, status, expected }) => { + it(`for ${msg}`, () => { + const result = formOf(alert, status); + expect(result).toBe(expected); + }); + }); +}); diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx index e10740fbfe..56403a664c 100644 --- a/plugins/cost-insights/src/utils/alerts.tsx +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -14,22 +14,118 @@ * limitations under the License. */ -import { Alert, AlertStatus } from '../types'; +import { Alert, AlertForm, AlertStatus, Maybe } from '../types'; +import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms'; -const createStatusHandler = (status?: string) => (alert: Alert) => +const createAlertHandler = (status?: AlertStatus) => (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 const isAlertActive = (alert: Alert) => !hasProperty(alert, 'status'); +export const isAlertSnoozed = createAlertHandler(AlertStatus.Snoozed); +export const isAlertAccepted = createAlertHandler(AlertStatus.Accepted); +export const isAlertDismissed = createAlertHandler(AlertStatus.Dismissed); + +const createStatusHandler = (status: AlertStatus) => (s: Maybe) => + s === status; +export const isStatusSnoozed = createStatusHandler(AlertStatus.Snoozed); +export const isStatusAccepted = createStatusHandler(AlertStatus.Accepted); +export const isStatusDismissed = createStatusHandler(AlertStatus.Dismissed); + +const createAlertEventHandler = ( + onEvent: 'onSnoozed' | 'onAccepted' | 'onDismissed', +) => (alert: Maybe): boolean => hasProperty(alert, onEvent); +export const isSnoozeEnabled = createAlertEventHandler('onSnoozed'); +export const isAcceptEnabled = createAlertEventHandler('onAccepted'); +export const isDismissEnabled = createAlertEventHandler('onDismissed'); + +const createFormEnabledHandler = ( + Form: 'SnoozeForm' | 'AcceptForm' | 'DismissForm', +) => (alert: Maybe): boolean => { + if (!alert) return false; + if (alert[Form] === null) return false; + switch (Form) { + case 'SnoozeForm': + return isSnoozeEnabled(alert); + case 'AcceptForm': + return isAcceptEnabled(alert); + case 'DismissForm': + return isDismissEnabled(alert); + default: + return false; + } +}; +export const isSnoozeFormEnabled = createFormEnabledHandler('SnoozeForm'); +export const isAcceptFormEnabled = createFormEnabledHandler('AcceptForm'); +export const isDismissFormEnabled = createFormEnabledHandler('DismissForm'); + +/** + * Utility for determining if a form is disabled. + * When a form is disabled, the dialog button's type should convert from submit to button. + * @param alert + * @param status + */ +export const isFormDisabled = ( + alert: Maybe, + status: Maybe, +): boolean => { + switch (status) { + case AlertStatus.Snoozed: + return alert?.SnoozeForm === null; + case AlertStatus.Accepted: + return alert?.AcceptForm === null; + case AlertStatus.Dismissed: + return alert?.DismissForm === null; + default: + return false; + } +}; + +export function formOf( + alert: Maybe, + status: Maybe, +): Maybe { + switch (status) { + case AlertStatus.Snoozed: { + const SnoozeForm = alert?.SnoozeForm ?? AlertSnoozeForm; + return isSnoozeFormEnabled(alert) ? SnoozeForm : null; + } + case AlertStatus.Accepted: { + const AcceptForm = alert?.AcceptForm ?? AlertAcceptForm; + return isAcceptFormEnabled(alert) ? AcceptForm : null; + } + case AlertStatus.Dismissed: { + const DismissForm = alert?.DismissForm ?? AlertDismissForm; + return isDismissFormEnabled(alert) ? DismissForm : null; + } + default: + return null; + } +} + +/** + * Utility for choosing from a fixed set of values for a given alert status. + * @param status + * @param values + */ +export function choose( + status: Maybe, + values: [T, T, T], + none: T, +): T { + switch (status) { + case AlertStatus.Snoozed: + return values[0]; + case AlertStatus.Accepted: + return values[1]; + case AlertStatus.Dismissed: + return values[2]; + default: + return none; + } +} + +export function hasProperty(alert: Maybe, prop: keyof Alert): boolean { + return prop in (alert ?? {}); +} export const sumOfAllAlerts = (sum: number, alerts: Alert[]) => sum + alerts.length; - -export function choose( - status: readonly [boolean, boolean, boolean], - values: [T, T, T], -): T | undefined { - const i = status.indexOf(true); - return values[i]; -} diff --git a/plugins/cost-insights/src/utils/scroll.tsx b/plugins/cost-insights/src/utils/scroll.tsx new file mode 100644 index 0000000000..06f01775df --- /dev/null +++ b/plugins/cost-insights/src/utils/scroll.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useEffect, useRef } from 'react'; +import { ScrollTo, useScroll } from '../hooks/useScroll'; + +/* + Utility component use in conjuction with useScroll that allows scrollable components to control behavior and offset. + 1. ScrollAnchor must be a direct child of a scrollable component. + 2. ScrollAnchor's parent position must be relative. + 3. ScrollAnchor's id must be unique. +*/ + +export interface ScrollAnchorProps extends ScrollIntoViewOptions { + id: ScrollTo; + top?: number; + left?: number; +} + +export const ScrollAnchor = ({ + id, + left = 0, + top = -20, + block = 'start', + inline = 'nearest', + behavior = 'smooth', +}: ScrollAnchorProps) => { + const divRef = useRef(null); + const [scroll, setScroll] = useScroll(); + + useEffect(() => { + function scrollIntoView() { + if (divRef.current && scroll === id) { + divRef.current.scrollIntoView({ + block, + inline, + behavior, + }); + setScroll(null); + } + } + + scrollIntoView(); + }, [scroll, setScroll, id, behavior, block, inline]); + + return ( +
+ ); +}; diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 13b38a49f2..aae2800573 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -22,7 +22,6 @@ 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'; @@ -166,7 +165,6 @@ export const MockScrollProvider = ({ children }: MockScrollProviderProps) => { const defaultContext: ScrollContextProps = { scroll: null, setScroll: jest.fn(), - ScrollAnchor: jest.fn(() =>
), }; return ( @@ -233,28 +231,3 @@ 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} - - ); -};