Add getLastCompleteBillingDate to CostInsightsApi
Add useLastCompleteBillingDate hook More usage of PropsWithChildren
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
Cost,
|
||||
CostInsightsApi,
|
||||
DateAggregation,
|
||||
DEFAULT_DATE_FORMAT,
|
||||
Duration,
|
||||
exclusiveEndDateOf,
|
||||
Group,
|
||||
@@ -38,18 +39,32 @@ import {
|
||||
UnlabeledDataflowData,
|
||||
} from '@backstage/plugin-cost-insights';
|
||||
|
||||
function durationOf(intervals: string): Duration {
|
||||
const match = intervals.match(/\/(?<duration>P\d+[DM])\//);
|
||||
const { duration } = match!.groups!;
|
||||
return duration as Duration;
|
||||
type IntervalFields = {
|
||||
duration: Duration;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
function parseIntervals(intervals: string): IntervalFields {
|
||||
const match = intervals.match(
|
||||
/\/(?<duration>P\d+[DM])\/(?<date>\d{4}-\d{2}-\d{2})/,
|
||||
);
|
||||
if (Object.keys(match?.groups || {}).length !== 2) {
|
||||
throw new Error(`Invalid intervals: ${intervals}`);
|
||||
}
|
||||
const { duration, date } = match!.groups!;
|
||||
return {
|
||||
duration: duration as Duration,
|
||||
endDate: date,
|
||||
};
|
||||
}
|
||||
|
||||
function aggregationFor(
|
||||
duration: Duration,
|
||||
intervals: string,
|
||||
baseline: number,
|
||||
): DateAggregation[] {
|
||||
const days = dayjs(exclusiveEndDateOf(duration)).diff(
|
||||
inclusiveStartDateOf(duration),
|
||||
const { duration, endDate } = parseIntervals(intervals);
|
||||
const days = dayjs(exclusiveEndDateOf(duration, endDate)).diff(
|
||||
inclusiveStartDateOf(duration, endDate),
|
||||
'day',
|
||||
);
|
||||
|
||||
@@ -57,10 +72,10 @@ function aggregationFor(
|
||||
(values: DateAggregation[], i: number): DateAggregation[] => {
|
||||
const last = values.length ? values[values.length - 1].amount : baseline;
|
||||
values.push({
|
||||
date: dayjs(inclusiveStartDateOf(duration))
|
||||
date: dayjs(inclusiveStartDateOf(duration, endDate))
|
||||
.add(i, 'day')
|
||||
.format('YYYY-MM-DD'),
|
||||
amount: last + (baseline / 20) * (Math.random() * 2 - 1),
|
||||
.format(DEFAULT_DATE_FORMAT),
|
||||
amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)),
|
||||
});
|
||||
return values;
|
||||
},
|
||||
@@ -99,6 +114,12 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
return new Promise(resolve => setTimeout(resolve, 0, res));
|
||||
}
|
||||
|
||||
getLastCompleteBillingDate(): Promise<string> {
|
||||
return Promise.resolve(
|
||||
dayjs().subtract(1, 'day').format(DEFAULT_DATE_FORMAT),
|
||||
);
|
||||
}
|
||||
|
||||
async getUserGroups(userId: string): Promise<Group[]> {
|
||||
const groups: Group[] = await this.request({ userId }, [
|
||||
{ id: 'pied-piper' },
|
||||
@@ -121,10 +142,10 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
metric: string,
|
||||
intervals: string,
|
||||
): Promise<MetricData> {
|
||||
const aggregation = aggregationFor(
|
||||
durationOf(intervals),
|
||||
100_000,
|
||||
).map(entry => ({ ...entry, amount: Math.round(entry.amount) }));
|
||||
const aggregation = aggregationFor(intervals, 100_000).map(entry => ({
|
||||
...entry,
|
||||
amount: Math.round(entry.amount),
|
||||
}));
|
||||
|
||||
const cost: MetricData = await this.request(
|
||||
{ metric, intervals },
|
||||
@@ -140,7 +161,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
}
|
||||
|
||||
async getGroupDailyCost(group: string, intervals: string): Promise<Cost> {
|
||||
const aggregation = aggregationFor(durationOf(intervals), 8_000);
|
||||
const aggregation = aggregationFor(intervals, 8_000);
|
||||
const groupDailyCost: Cost = await this.request(
|
||||
{ group, intervals },
|
||||
{
|
||||
@@ -154,7 +175,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
}
|
||||
|
||||
async getProjectDailyCost(project: string, intervals: string): Promise<Cost> {
|
||||
const aggregation = aggregationFor(durationOf(intervals), 1_500);
|
||||
const aggregation = aggregationFor(intervals, 1_500);
|
||||
const projectDailyCost: Cost = await this.request(
|
||||
{ project, intervals },
|
||||
{
|
||||
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
} from '../types';
|
||||
|
||||
export type CostInsightsApi = {
|
||||
/**
|
||||
* Get the most current date for which billing data is complete, in YYYY-MM-DD format. This helps
|
||||
* define the intervals used in other API methods to avoid showing incomplete cost. The costs for
|
||||
* today, for example, will not be complete. This ideally comes from the cloud provider.
|
||||
*/
|
||||
getLastCompleteBillingDate(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Get a list of groups the given user belongs to. These may be LDAP groups or similar
|
||||
* organizational groups. Cost Insights is designed to show costs based on group membership;
|
||||
|
||||
+2
-3
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { Box, Button, Container, makeStyles } from '@material-ui/core';
|
||||
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
|
||||
import { Header, Page } from '@backstage/core';
|
||||
@@ -30,13 +30,12 @@ const useStyles = makeStyles(theme => ({
|
||||
|
||||
type AlertInstructionsLayoutProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const AlertInstructionsLayout = ({
|
||||
title,
|
||||
children,
|
||||
}: AlertInstructionsLayoutProps) => {
|
||||
}: PropsWithChildren<AlertInstructionsLayoutProps>) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<CostInsightsThemeProvider>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { useBarChartLabelStyles } from '../../utils/styles';
|
||||
|
||||
@@ -23,10 +23,15 @@ type BarChartLabel = {
|
||||
y: number;
|
||||
height: number;
|
||||
width: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const BarChartLabel = ({ x, y, height, width, children }: BarChartLabel) => {
|
||||
const BarChartLabel = ({
|
||||
x,
|
||||
y,
|
||||
height,
|
||||
width,
|
||||
children,
|
||||
}: PropsWithChildren<BarChartLabel>) => {
|
||||
const classes = useBarChartLabelStyles();
|
||||
const translateX = width * -0.5;
|
||||
const childArray = React.Children.toArray(children);
|
||||
|
||||
@@ -14,18 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, Ref } from 'react';
|
||||
import React, { forwardRef, PropsWithChildren, Ref } from 'react';
|
||||
import { ButtonBase, ButtonBaseProps } from '@material-ui/core';
|
||||
import { useBarChartStepperButtonStyles as useStyles } from '../../utils/styles';
|
||||
|
||||
interface BarChartStepperButtonProps extends ButtonBaseProps {
|
||||
name: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const BarChartStepperButton = forwardRef(
|
||||
(
|
||||
{ name, children, ...buttonBaseProps }: BarChartStepperButtonProps,
|
||||
{
|
||||
name,
|
||||
children,
|
||||
...buttonBaseProps
|
||||
}: PropsWithChildren<BarChartStepperButtonProps>,
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) => {
|
||||
const classes = useStyles();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { Header, Page } from '@backstage/core';
|
||||
import { Group } from '../../types';
|
||||
@@ -34,10 +34,12 @@ const useStyles = makeStyles(theme => ({
|
||||
type CostInsightsLayoutProps = {
|
||||
title?: string;
|
||||
groups: Group[];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const CostInsightsLayout = ({ groups, children }: CostInsightsLayoutProps) => {
|
||||
const CostInsightsLayout = ({
|
||||
groups,
|
||||
children,
|
||||
}: PropsWithChildren<CostInsightsLayoutProps>) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
|
||||
@@ -15,8 +15,17 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Container, Divider, Grid, Typography } from '@material-ui/core';
|
||||
import { Progress, useApi, featureFlagsApiRef } from '@backstage/core';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
createStyles,
|
||||
Divider,
|
||||
Grid,
|
||||
makeStyles,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { featureFlagsApiRef, Progress, useApi } from '@backstage/core';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { default as MaterialAlert } from '@material-ui/lab/Alert';
|
||||
import { costInsightsApiRef } from '../../api';
|
||||
import AlertActionCardList from '../AlertActionCardList';
|
||||
@@ -33,11 +42,12 @@ import CostOverviewCard from '../CostOverviewCard';
|
||||
import ProductInsights from '../ProductInsights';
|
||||
import CostInsightsSupportButton from '../CostInsightsSupportButton';
|
||||
import {
|
||||
useLoading,
|
||||
useConfig,
|
||||
useCurrency,
|
||||
useFilters,
|
||||
useGroups,
|
||||
useCurrency,
|
||||
useConfig,
|
||||
useLastCompleteBillingDate,
|
||||
useLoading,
|
||||
} from '../../hooks';
|
||||
import {
|
||||
Alert,
|
||||
@@ -50,13 +60,23 @@ import {
|
||||
import { mapLoadingToProps } from './selector';
|
||||
import ProjectSelect from '../ProjectSelect';
|
||||
|
||||
const useTextStyles = makeStyles<BackstageTheme>((theme: BackstageTheme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
color: theme.palette.textSubtle,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const CostInsightsPage = () => {
|
||||
const classes = useTextStyles();
|
||||
const flags = useApi(featureFlagsApiRef).getFlags();
|
||||
// There is not currently a UI to set feature flags
|
||||
// flags.set('cost-insights-currencies', FeatureFlagState.On);
|
||||
const client = useApi(costInsightsApiRef);
|
||||
const config = useConfig();
|
||||
const groups = useGroups();
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const [currency, setCurrency] = useCurrency();
|
||||
const [projects, setProjects] = useState<Maybe<Project[]>>(null);
|
||||
const [dailyCost, setDailyCost] = useState<Maybe<Cost>>(null);
|
||||
@@ -101,7 +121,10 @@ const CostInsightsPage = () => {
|
||||
try {
|
||||
if (pageFilters.group) {
|
||||
dispatchLoadingInsights(true);
|
||||
const intervals = intervalsOf(pageFilters.duration);
|
||||
const intervals = intervalsOf(
|
||||
pageFilters.duration,
|
||||
lastCompleteBillingDate,
|
||||
);
|
||||
const [
|
||||
fetchedProjects,
|
||||
fetchedAlerts,
|
||||
@@ -145,6 +168,7 @@ const CostInsightsPage = () => {
|
||||
dispatchLoadingInsights,
|
||||
dispatchLoadingInitial,
|
||||
dispatchLoadingNone,
|
||||
lastCompleteBillingDate,
|
||||
]);
|
||||
|
||||
if (loadingInitial) {
|
||||
@@ -195,6 +219,9 @@ const CostInsightsPage = () => {
|
||||
>
|
||||
<Box minHeight={40} width="75%" pt={2}>
|
||||
<Typography variant="h4">Cost Overview</Typography>
|
||||
<Typography classes={classes}>
|
||||
Billing data as of {lastCompleteBillingDate}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box minHeight={40} maxHeight={60} display="flex">
|
||||
{!!flags.get('cost-insights-currencies') && (
|
||||
|
||||
@@ -22,6 +22,7 @@ import { GroupsProvider } from '../../hooks/useGroups';
|
||||
import { CurrencyProvider } from '../../hooks/useCurrency';
|
||||
import { ScrollProvider } from '../../hooks/useScroll';
|
||||
import { ConfigProvider } from '../../hooks/useConfig';
|
||||
import { BillingDateProvider } from '../../hooks/useLastCompleteBillingDate';
|
||||
import { CostInsightsThemeProvider } from './CostInsightsThemeProvider';
|
||||
|
||||
const CostInsightsPageRoot = () => (
|
||||
@@ -29,13 +30,15 @@ const CostInsightsPageRoot = () => (
|
||||
<ConfigProvider>
|
||||
<LoadingProvider>
|
||||
<GroupsProvider>
|
||||
<FilterProvider>
|
||||
<ScrollProvider>
|
||||
<CurrencyProvider>
|
||||
<CostInsightsPage />
|
||||
</CurrencyProvider>
|
||||
</ScrollProvider>
|
||||
</FilterProvider>
|
||||
<BillingDateProvider>
|
||||
<FilterProvider>
|
||||
<ScrollProvider>
|
||||
<CurrencyProvider>
|
||||
<CostInsightsPage />
|
||||
</CurrencyProvider>
|
||||
</ScrollProvider>
|
||||
</FilterProvider>
|
||||
</BillingDateProvider>
|
||||
</GroupsProvider>
|
||||
</LoadingProvider>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { createMuiTheme, ThemeProvider } from '@material-ui/core';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import {
|
||||
@@ -23,13 +23,9 @@ import {
|
||||
} from '../../utils/styles';
|
||||
import { CostInsightsTheme } from '../../types';
|
||||
|
||||
interface CostInsightsThemeProviderProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CostInsightsThemeProvider = ({
|
||||
children,
|
||||
}: CostInsightsThemeProviderProps) => {
|
||||
}: PropsWithChildren<{}>) => {
|
||||
return (
|
||||
<ThemeProvider
|
||||
theme={(theme: BackstageTheme) =>
|
||||
|
||||
@@ -13,20 +13,19 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
|
||||
type CostOverviewHeaderProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const CostOverviewHeader = ({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: CostOverviewHeaderProps) => (
|
||||
}: PropsWithChildren<CostOverviewHeaderProps>) => (
|
||||
<Box
|
||||
marginY={1}
|
||||
display="flex"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { Box, Typography, Tooltip } from '@material-ui/core';
|
||||
import LensIcon from '@material-ui/icons/Lens';
|
||||
import HelpOutlineOutlinedIcon from '@material-ui/icons/HelpOutlineOutlined';
|
||||
@@ -24,7 +24,6 @@ type LegendItemProps = {
|
||||
title: string;
|
||||
tooltipText?: string;
|
||||
markerColor?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const LegendItem = ({
|
||||
@@ -32,7 +31,7 @@ const LegendItem = ({
|
||||
tooltipText,
|
||||
markerColor,
|
||||
children,
|
||||
}: LegendItemProps) => {
|
||||
}: PropsWithChildren<LegendItemProps>) => {
|
||||
const classes = useCostGrowthLegendStyles();
|
||||
return (
|
||||
<Box display="flex" flexDirection="column">
|
||||
|
||||
@@ -17,32 +17,41 @@
|
||||
import React from 'react';
|
||||
import { getByRole, waitFor } from '@testing-library/react';
|
||||
import UserEvent from '@testing-library/user-event';
|
||||
import PeriodSelect, { DEFAULT_OPTIONS as options } from './PeriodSelect';
|
||||
import PeriodSelect, { getDefaultOptions } from './PeriodSelect';
|
||||
import { Duration, getDefaultPageFilters, Group } from '../../types';
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { MockBillingDateProvider } from '../../utils/tests';
|
||||
|
||||
const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]);
|
||||
|
||||
Date.now = jest.fn(() => new Date(Date.parse('2020-05-01')).valueOf());
|
||||
const lastCompleteBillingDate = '2020-05-01';
|
||||
const options = getDefaultOptions(lastCompleteBillingDate);
|
||||
|
||||
describe('<PeriodSelect />', () => {
|
||||
it('Renders without exploding', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<PeriodSelect
|
||||
duration={DefaultPageFilters.duration}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
<MockBillingDateProvider
|
||||
lastCompleteBillingDate={lastCompleteBillingDate}
|
||||
>
|
||||
<PeriodSelect
|
||||
duration={DefaultPageFilters.duration}
|
||||
onSelect={jest.fn()}
|
||||
/>
|
||||
</MockBillingDateProvider>,
|
||||
);
|
||||
expect(rendered.getByTestId('period-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should display all costGrowth period options', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<PeriodSelect
|
||||
duration={DefaultPageFilters.duration}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
<MockBillingDateProvider
|
||||
lastCompleteBillingDate={lastCompleteBillingDate}
|
||||
>
|
||||
<PeriodSelect
|
||||
duration={DefaultPageFilters.duration}
|
||||
onSelect={jest.fn()}
|
||||
/>
|
||||
</MockBillingDateProvider>,
|
||||
);
|
||||
const periodSelectContainer = rendered.getByTestId('period-select');
|
||||
const button = getByRole(periodSelectContainer, 'button');
|
||||
@@ -70,7 +79,11 @@ describe('<PeriodSelect />', () => {
|
||||
: DefaultPageFilters.duration;
|
||||
|
||||
const rendered = await renderInTestApp(
|
||||
<PeriodSelect duration={mockAggregation} onSelect={mockOnSelect} />,
|
||||
<MockBillingDateProvider
|
||||
lastCompleteBillingDate={lastCompleteBillingDate}
|
||||
>
|
||||
<PeriodSelect duration={mockAggregation} onSelect={mockOnSelect} />,
|
||||
</MockBillingDateProvider>,
|
||||
);
|
||||
const periodSelect = rendered.getByTestId('period-select');
|
||||
const button = getByRole(periodSelect, 'button');
|
||||
|
||||
@@ -22,35 +22,35 @@ import {
|
||||
} from '../../utils/formatters';
|
||||
import { Duration, findAlways } from '../../types';
|
||||
import { useSelectStyles as useStyles } from '../../utils/styles';
|
||||
import { useLastCompleteBillingDate } from '../../hooks';
|
||||
|
||||
export type PeriodOption = {
|
||||
value: Duration;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const LAST_6_MONTHS = 'Past 6 Months';
|
||||
const LAST_60_DAYS = 'Past 60 Days';
|
||||
const LAST_2_COMPLETED_MONTHS = formatLastTwoMonths();
|
||||
const LAST_2_LOOKAHEAD_QUARTERS = formatLastTwoLookaheadQuarters();
|
||||
|
||||
export const DEFAULT_OPTIONS: PeriodOption[] = [
|
||||
{
|
||||
value: Duration.P90D,
|
||||
label: LAST_6_MONTHS,
|
||||
},
|
||||
{
|
||||
value: Duration.P30D,
|
||||
label: LAST_60_DAYS,
|
||||
},
|
||||
{
|
||||
value: Duration.P1M,
|
||||
label: LAST_2_COMPLETED_MONTHS,
|
||||
},
|
||||
{
|
||||
value: Duration.P3M,
|
||||
label: LAST_2_LOOKAHEAD_QUARTERS,
|
||||
},
|
||||
];
|
||||
export function getDefaultOptions(
|
||||
lastCompleteBillingDate: string,
|
||||
): PeriodOption[] {
|
||||
return [
|
||||
{
|
||||
value: Duration.P90D,
|
||||
label: 'Past 6 Months',
|
||||
},
|
||||
{
|
||||
value: Duration.P30D,
|
||||
label: 'Past 60 Days',
|
||||
},
|
||||
{
|
||||
value: Duration.P1M,
|
||||
label: formatLastTwoMonths(lastCompleteBillingDate),
|
||||
},
|
||||
{
|
||||
value: Duration.P3M,
|
||||
label: formatLastTwoLookaheadQuarters(lastCompleteBillingDate),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type PeriodSelectProps = {
|
||||
duration: Duration;
|
||||
@@ -58,19 +58,18 @@ type PeriodSelectProps = {
|
||||
options?: PeriodOption[];
|
||||
};
|
||||
|
||||
const PeriodSelect = ({
|
||||
duration,
|
||||
onSelect,
|
||||
options = DEFAULT_OPTIONS,
|
||||
}: PeriodSelectProps) => {
|
||||
const PeriodSelect = ({ duration, onSelect, options }: PeriodSelectProps) => {
|
||||
const classes = useStyles();
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const optionsOrDefault =
|
||||
options ?? getDefaultOptions(lastCompleteBillingDate);
|
||||
|
||||
const handleOnChange: SelectProps['onChange'] = e => {
|
||||
onSelect(e.target.value as Duration);
|
||||
};
|
||||
|
||||
const renderValue: SelectProps['renderValue'] = value => {
|
||||
const option = findAlways(DEFAULT_OPTIONS, o => o.value === value);
|
||||
const option = findAlways(optionsOrDefault, o => o.value === value);
|
||||
return <b>{option.label}</b>;
|
||||
};
|
||||
|
||||
@@ -83,7 +82,7 @@ const PeriodSelect = ({
|
||||
renderValue={renderValue}
|
||||
data-testid="period-select"
|
||||
>
|
||||
{options.map(option => (
|
||||
{optionsOrDefault.map(option => (
|
||||
<MenuItem
|
||||
className={classes.menuItem}
|
||||
key={option.value}
|
||||
|
||||
+54
-22
@@ -17,31 +17,33 @@
|
||||
import React from 'react';
|
||||
import ProductInsightsCard from './ProductInsightsCard';
|
||||
import {
|
||||
MockComputeEngine,
|
||||
createMockEntity,
|
||||
mockDefaultState,
|
||||
createMockProductCost,
|
||||
MockComputeEngine,
|
||||
mockDefaultState,
|
||||
} from '../../utils/mockData';
|
||||
import {
|
||||
IdentityApi,
|
||||
ApiRegistry,
|
||||
identityApiRef,
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
} from '@backstage/core';
|
||||
import { costInsightsApiRef, CostInsightsApi } from '../../api';
|
||||
import { CostInsightsApi, costInsightsApiRef } from '../../api';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { GroupsContext } from '../../hooks/useGroups';
|
||||
import { LoadingContext } from '../../hooks/useLoading';
|
||||
import {
|
||||
defaultCurrencies,
|
||||
Duration,
|
||||
findAlways,
|
||||
Product,
|
||||
ProductCost,
|
||||
defaultCurrencies,
|
||||
findAlways,
|
||||
} from '../../types';
|
||||
import {
|
||||
MockBillingDateProvider,
|
||||
MockConfigProvider,
|
||||
MockFilterProvider,
|
||||
MockCurrencyProvider,
|
||||
MockFilterProvider,
|
||||
MockScrollProvider,
|
||||
} from '../../utils/tests';
|
||||
|
||||
@@ -84,6 +86,7 @@ const mockProductCost = createMockProductCost(() => ({
|
||||
const renderProductInsightsCardInTestApp = async (
|
||||
productCost: ProductCost,
|
||||
product: Product,
|
||||
duration: Duration,
|
||||
) =>
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={getApis(productCost)}>
|
||||
@@ -102,19 +105,22 @@ const renderProductInsightsCardInTestApp = async (
|
||||
}}
|
||||
>
|
||||
<GroupsContext.Provider value={{ groups: [{ id: 'test-group' }] }}>
|
||||
<MockFilterProvider
|
||||
setPageFilters={mockSetPageFilters}
|
||||
setProductFilters={mockSetProductFilters}
|
||||
>
|
||||
<MockScrollProvider>
|
||||
<MockCurrencyProvider
|
||||
currency={engineers}
|
||||
setCurrency={mockSetCurrency}
|
||||
>
|
||||
<ProductInsightsCard product={product} />
|
||||
</MockCurrencyProvider>
|
||||
</MockScrollProvider>
|
||||
</MockFilterProvider>
|
||||
<MockBillingDateProvider lastCompleteBillingDate="2020-10-01">
|
||||
<MockFilterProvider
|
||||
setPageFilters={mockSetPageFilters}
|
||||
setProductFilters={mockSetProductFilters}
|
||||
duration={duration}
|
||||
>
|
||||
<MockScrollProvider>
|
||||
<MockCurrencyProvider
|
||||
currency={engineers}
|
||||
setCurrency={mockSetCurrency}
|
||||
>
|
||||
<ProductInsightsCard product={product} />
|
||||
</MockCurrencyProvider>
|
||||
</MockScrollProvider>
|
||||
</MockFilterProvider>
|
||||
</MockBillingDateProvider>
|
||||
</GroupsContext.Provider>
|
||||
</LoadingContext.Provider>
|
||||
</MockConfigProvider>
|
||||
@@ -126,6 +132,7 @@ describe('<ProductInsightsCard/>', () => {
|
||||
const rendered = await renderProductInsightsCardInTestApp(
|
||||
mockProductCost,
|
||||
MockComputeEngine,
|
||||
Duration.P1M,
|
||||
);
|
||||
expect(
|
||||
rendered.queryByTestId(`scroll-test-compute-engine`),
|
||||
@@ -140,6 +147,7 @@ describe('<ProductInsightsCard/>', () => {
|
||||
const rendered = await renderProductInsightsCardInTestApp(
|
||||
productCost,
|
||||
MockComputeEngine,
|
||||
Duration.P1M,
|
||||
);
|
||||
const subheader = 'entities, sorted by cost';
|
||||
const subheaderRgx = new RegExp(
|
||||
@@ -154,6 +162,7 @@ describe('<ProductInsightsCard/>', () => {
|
||||
const rendered = await renderProductInsightsCardInTestApp(
|
||||
productCost,
|
||||
MockComputeEngine,
|
||||
Duration.P1M,
|
||||
);
|
||||
const subheaderRgx = new RegExp(subheader);
|
||||
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
|
||||
@@ -164,4 +173,27 @@ describe('<ProductInsightsCard/>', () => {
|
||||
rendered.queryByTestId('.insights-bar-chart'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe.each`
|
||||
duration | periodStartText | periodEndText
|
||||
${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'}
|
||||
${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'}
|
||||
`(
|
||||
'Should display the correct relative time',
|
||||
({ duration, periodStartText, periodEndText }) => {
|
||||
it(`Should display the correct relative time for ${duration}`, async () => {
|
||||
const productCost = {
|
||||
...mockProductCost,
|
||||
entities: [...Array(3)].map(createMockEntity),
|
||||
};
|
||||
const rendered = await renderProductInsightsCardInTestApp(
|
||||
productCost,
|
||||
MockComputeEngine,
|
||||
duration,
|
||||
);
|
||||
expect(rendered.getByText(periodStartText)).toBeInTheDocument();
|
||||
expect(rendered.getByText(periodEndText)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,11 +22,17 @@ import { costInsightsApiRef } from '../../api';
|
||||
import PeriodSelect from '../PeriodSelect';
|
||||
import ResourceGrowthBarChart from '../ResourceGrowthBarChart';
|
||||
import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend';
|
||||
import { useFilters, useLoading, useScroll } from '../../hooks';
|
||||
import {
|
||||
useFilters,
|
||||
useLastCompleteBillingDate,
|
||||
useLoading,
|
||||
useScroll,
|
||||
} from '../../hooks';
|
||||
import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
|
||||
import { mapFiltersToProps, mapLoadingToProps } from './selector';
|
||||
import { Duration, Maybe, Product, ProductCost } from '../../types';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
import { formatPeriod } from '../../utils/formatters';
|
||||
|
||||
type ProductInsightsCardProps = {
|
||||
product: Product;
|
||||
@@ -36,6 +42,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
|
||||
const client = useApi(costInsightsApiRef);
|
||||
const classes = useStyles();
|
||||
const { ScrollAnchor } = useScroll(product.kind);
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const [resource, setResource] = useState<Maybe<ProductCost>>(null);
|
||||
const [error, setError] = useState<Maybe<Error>>(null);
|
||||
|
||||
@@ -53,6 +60,17 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
|
||||
const amount = resource?.entities?.length || 0;
|
||||
const hasCostsWithinTimeframe = resource?.change && !!amount;
|
||||
|
||||
const previousName = formatPeriod(
|
||||
productFilter.duration,
|
||||
lastCompleteBillingDate,
|
||||
false,
|
||||
);
|
||||
const currentName = formatPeriod(
|
||||
productFilter.duration,
|
||||
lastCompleteBillingDate,
|
||||
true,
|
||||
);
|
||||
|
||||
const subheader = amount
|
||||
? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost`
|
||||
: `There are no ${product.name} costs within this timeframe for your team's projects.`;
|
||||
@@ -131,12 +149,15 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
|
||||
<ResourceGrowthBarChartLegend
|
||||
duration={productFilter.duration}
|
||||
change={resource.change!}
|
||||
previousName={previousName}
|
||||
currentName={currentName}
|
||||
costStart={costStart}
|
||||
costEnd={costEnd}
|
||||
/>
|
||||
</Box>
|
||||
<ResourceGrowthBarChart
|
||||
duration={productFilter.duration}
|
||||
previousName={previousName}
|
||||
currentName={currentName}
|
||||
resources={resource.entities || []}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
+20
-12
@@ -18,7 +18,11 @@ import React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import ProjectGrowthAlertCard from './ProjectGrowthAlertCard';
|
||||
import { createMockProjectGrowthData } from '../../utils/mockData';
|
||||
import { MockCurrencyProvider, MockConfigProvider } from '../../utils/tests';
|
||||
import {
|
||||
MockCurrencyProvider,
|
||||
MockConfigProvider,
|
||||
MockBillingDateProvider,
|
||||
} from '../../utils/tests';
|
||||
import { AlertCost, defaultCurrencies, findAlways } from '../../types';
|
||||
|
||||
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
|
||||
@@ -49,9 +53,11 @@ describe('<ProjectGrowthAlertCard />', () => {
|
||||
engineerCost={200_000}
|
||||
currencies={[]}
|
||||
>
|
||||
<MockCurrencyProvider currency={engineers} setCurrency={jest.fn()}>
|
||||
<ProjectGrowthAlertCard alert={MockProjectGrowthAlert} />,
|
||||
</MockCurrencyProvider>
|
||||
<MockBillingDateProvider lastCompleteBillingDate="2020-10-01">
|
||||
<MockCurrencyProvider currency={engineers} setCurrency={jest.fn()}>
|
||||
<ProjectGrowthAlertCard alert={MockProjectGrowthAlert} />,
|
||||
</MockCurrencyProvider>
|
||||
</MockBillingDateProvider>
|
||||
</MockConfigProvider>,
|
||||
);
|
||||
expect(rendered.getByText(title)).toBeInTheDocument();
|
||||
@@ -69,14 +75,16 @@ describe('<ProjectGrowthAlertCard />', () => {
|
||||
engineerCost={200_000}
|
||||
currencies={[]}
|
||||
>
|
||||
<MockCurrencyProvider currency={engineers} setCurrency={jest.fn()}>
|
||||
<ProjectGrowthAlertCard
|
||||
alert={{
|
||||
...MockProjectGrowthAlert,
|
||||
products: [{ id: 'test-alert-id', aggregation: [0, 100] }],
|
||||
}}
|
||||
/>
|
||||
</MockCurrencyProvider>
|
||||
<MockBillingDateProvider lastCompleteBillingDate="2020-10-01">
|
||||
<MockCurrencyProvider currency={engineers} setCurrency={jest.fn()}>
|
||||
<ProjectGrowthAlertCard
|
||||
alert={{
|
||||
...MockProjectGrowthAlert,
|
||||
products: [{ id: 'test-alert-id', aggregation: [0, 100] }],
|
||||
}}
|
||||
/>
|
||||
</MockCurrencyProvider>
|
||||
</MockBillingDateProvider>
|
||||
</MockConfigProvider>,
|
||||
);
|
||||
expect(rendered.getByText(title)).toBeInTheDocument();
|
||||
|
||||
+7
-1
@@ -21,6 +21,7 @@ import ResourceGrowthBarChart from '../ResourceGrowthBarChart';
|
||||
import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend';
|
||||
import { Duration, ProjectGrowthData } from '../../types';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
import { formatPeriod } from '../../utils/formatters';
|
||||
|
||||
type ProjectGrowthAlertProps = {
|
||||
alert: ProjectGrowthData;
|
||||
@@ -33,6 +34,8 @@ const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
|
||||
${alert.products.length} ${pluralOf(alert.products.length, 'product')}${
|
||||
alert.products.length > 1 ? ', sorted by cost' : ''
|
||||
}`;
|
||||
const previousName = formatPeriod(Duration.P3M, alert.periodStart, false);
|
||||
const currentName = formatPeriod(Duration.P3M, alert.periodEnd, true);
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
@@ -44,13 +47,16 @@ const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
|
||||
<ResourceGrowthBarChartLegend
|
||||
change={alert.change}
|
||||
duration={Duration.P3M}
|
||||
previousName={previousName}
|
||||
currentName={currentName}
|
||||
costStart={costStart}
|
||||
costEnd={costEnd}
|
||||
/>
|
||||
</Box>
|
||||
<ResourceGrowthBarChart
|
||||
resources={alert.products}
|
||||
duration={Duration.P3M}
|
||||
previousName={previousName}
|
||||
currentName={currentName}
|
||||
/>
|
||||
</Box>
|
||||
</InfoCard>
|
||||
|
||||
+4
-1
@@ -160,14 +160,17 @@ const ProjectGrowthInstructionsPage = () => {
|
||||
<ResourceGrowthBarChartLegend
|
||||
duration={Duration.P3M}
|
||||
change={{ ratio: 3, amount: 40000 }}
|
||||
previousName="Q2 2020"
|
||||
currentName="Q3 2020"
|
||||
costStart={20000}
|
||||
costEnd={60000}
|
||||
/>
|
||||
</Box>
|
||||
<Box paddingY={1}>
|
||||
<ResourceGrowthBarChart
|
||||
duration={Duration.P3M}
|
||||
resources={entities}
|
||||
previousName="Q2 2020"
|
||||
currentName="Q3 2020"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
+2
-2
@@ -16,7 +16,6 @@
|
||||
|
||||
import React from 'react';
|
||||
import ResourceGrowthBarChart from './ResourceGrowthBarChart';
|
||||
import { Duration } from '../../types';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { createMockEntity } from '../../utils/mockData';
|
||||
|
||||
@@ -32,8 +31,9 @@ describe('<ResourceGrowthBarChart/>', () => {
|
||||
it('Pre-renders without exploding', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<ResourceGrowthBarChart
|
||||
duration={Duration.P1M}
|
||||
resources={MockResources}
|
||||
previousName="Q2 2020"
|
||||
currentName="Q3 2020"
|
||||
/>,
|
||||
);
|
||||
expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument();
|
||||
|
||||
+9
-17
@@ -16,41 +16,33 @@
|
||||
|
||||
import React from 'react';
|
||||
import { TooltipPayload } from 'recharts';
|
||||
import { currencyFormatter } from '../../utils/formatters';
|
||||
import {
|
||||
currencyFormatter,
|
||||
dateRegex,
|
||||
formatDuration,
|
||||
} from '../../utils/formatters';
|
||||
import {
|
||||
AlertCost,
|
||||
BarChartData,
|
||||
CostInsightsTheme,
|
||||
DataKey,
|
||||
Duration,
|
||||
Entity,
|
||||
inclusiveEndDateOf,
|
||||
inclusiveStartDateOf,
|
||||
Maybe,
|
||||
ResourceData,
|
||||
AlertCost,
|
||||
} from '../../types';
|
||||
import BarChart from '../BarChart';
|
||||
import { TooltipItemProps } from '../Tooltip';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
|
||||
export type ResourceGrowthBarChartProps = {
|
||||
duration: Duration;
|
||||
resources: Array<Entity | AlertCost>;
|
||||
previousName: string;
|
||||
currentName: string;
|
||||
};
|
||||
|
||||
const ResourceGrowthBarChart = ({
|
||||
duration,
|
||||
resources,
|
||||
previousName,
|
||||
currentName,
|
||||
}: ResourceGrowthBarChartProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const getTooltipItem = (payload: TooltipPayload): Maybe<TooltipItemProps> => {
|
||||
const label = dateRegex.test(payload.name)
|
||||
? formatDuration(payload.name, duration)
|
||||
: payload.name;
|
||||
const value =
|
||||
typeof payload.value === 'number'
|
||||
? currencyFormatter.format(payload.value)
|
||||
@@ -61,7 +53,7 @@ const ResourceGrowthBarChart = ({
|
||||
case DataKey.Current:
|
||||
case DataKey.Previous:
|
||||
return {
|
||||
label: label,
|
||||
label: payload.name,
|
||||
value: value,
|
||||
fill: fill,
|
||||
};
|
||||
@@ -73,8 +65,8 @@ const ResourceGrowthBarChart = ({
|
||||
const barChartData: BarChartData = {
|
||||
previousFill: theme.palette.lightBlue,
|
||||
currentFill: theme.palette.darkBlue,
|
||||
previousName: inclusiveStartDateOf(duration),
|
||||
currentName: inclusiveEndDateOf(duration),
|
||||
previousName: previousName,
|
||||
currentName: currentName,
|
||||
};
|
||||
|
||||
const resourceData: ResourceData[] = resources.map(resource => {
|
||||
|
||||
+2
-24
@@ -56,6 +56,8 @@ describe('<ResourceGrowthBarChartLegend />', () => {
|
||||
<ResourceGrowthBarChartLegend
|
||||
duration={Duration.P3M}
|
||||
change={{ ratio, amount }}
|
||||
previousName="Q2 2020"
|
||||
currentName="Q3 2020"
|
||||
costStart={1000}
|
||||
costEnd={5000}
|
||||
/>
|
||||
@@ -66,28 +68,4 @@ describe('<ResourceGrowthBarChartLegend />', () => {
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.each`
|
||||
duration | periodStartText | periodEndText
|
||||
${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'}
|
||||
${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'}
|
||||
`(
|
||||
'Should display the correct relative time',
|
||||
({ duration, periodStartText, periodEndText }) => {
|
||||
it(`Should display the correct relative time for ${duration}`, async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<MockContext currency={engineers}>
|
||||
<ResourceGrowthBarChartLegend
|
||||
change={{ ratio: -2.5, amount: 100_000 }}
|
||||
duration={duration}
|
||||
costStart={1000}
|
||||
costEnd={5000}
|
||||
/>
|
||||
</MockContext>,
|
||||
);
|
||||
expect(rendered.getByText(periodStartText)).toBeInTheDocument();
|
||||
expect(rendered.getByText(periodEndText)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+8
-18
@@ -18,18 +18,14 @@ import React from 'react';
|
||||
import { Box, useTheme } from '@material-ui/core';
|
||||
import LegendItem from '../LegendItem';
|
||||
import CostGrowth from '../CostGrowth';
|
||||
import { currencyFormatter, formatDuration } from '../../utils/formatters';
|
||||
import {
|
||||
ChangeStatistic,
|
||||
CostInsightsTheme,
|
||||
Duration,
|
||||
inclusiveEndDateOf,
|
||||
inclusiveStartDateOf,
|
||||
} from '../../types';
|
||||
import { currencyFormatter } from '../../utils/formatters';
|
||||
import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types';
|
||||
|
||||
export type ResourceGrowthBarChartLegendProps = {
|
||||
change: ChangeStatistic;
|
||||
duration: Duration;
|
||||
previousName: string;
|
||||
currentName: string;
|
||||
costStart: number;
|
||||
costEnd: number;
|
||||
};
|
||||
@@ -37,28 +33,22 @@ export type ResourceGrowthBarChartLegendProps = {
|
||||
const ResourceGrowthBarChartLegend = ({
|
||||
change,
|
||||
duration,
|
||||
previousName,
|
||||
currentName,
|
||||
costStart,
|
||||
costEnd,
|
||||
}: ResourceGrowthBarChartLegendProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
|
||||
const startOf = inclusiveStartDateOf(duration);
|
||||
const endOf = inclusiveEndDateOf(duration);
|
||||
const periodStartTitle = formatDuration(startOf, duration);
|
||||
const periodEndTitle = formatDuration(endOf, duration);
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="row">
|
||||
<Box marginRight={2}>
|
||||
<LegendItem
|
||||
title={periodStartTitle}
|
||||
markerColor={theme.palette.lightBlue}
|
||||
>
|
||||
<LegendItem title={previousName} markerColor={theme.palette.lightBlue}>
|
||||
{currencyFormatter.format(costStart)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
<Box marginRight={2}>
|
||||
<LegendItem title={periodEndTitle} markerColor={theme.palette.darkBlue}>
|
||||
<LegendItem title={currentName} markerColor={theme.palette.darkBlue}>
|
||||
{currencyFormatter.format(costEnd)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
|
||||
@@ -21,3 +21,4 @@ export * from './useCurrency';
|
||||
export * from './useGroups';
|
||||
export * from './useLoading';
|
||||
export * from './useScroll';
|
||||
export * from './useLastCompleteBillingDate';
|
||||
|
||||
@@ -15,15 +15,15 @@
|
||||
*/
|
||||
|
||||
import React, {
|
||||
ReactNode,
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useApi, configApiRef } from '@backstage/core';
|
||||
import { configApiRef, useApi } from '@backstage/core';
|
||||
import { Config as BackstageConfig } from '@backstage/config';
|
||||
import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types';
|
||||
import { Currency, defaultCurrencies, Icon, Metric, Product } from '../types';
|
||||
import { getIcon } from '../utils/navigation';
|
||||
import { validateMetrics } from '../utils/config';
|
||||
|
||||
@@ -67,7 +67,7 @@ const defaultState: ConfigContextProps = {
|
||||
currencies: defaultCurrencies,
|
||||
};
|
||||
|
||||
export const ConfigProvider = ({ children }: { children: ReactNode }) => {
|
||||
export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const c: BackstageConfig = useApi(configApiRef);
|
||||
const [config, setConfig] = useState(defaultState);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
ReactNode,
|
||||
useState,
|
||||
useContext,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
import { Currency, defaultCurrencies, findAlways } from '../types';
|
||||
|
||||
@@ -27,15 +27,11 @@ export type CurrencyContextProps = {
|
||||
setCurrency: Dispatch<SetStateAction<Currency>>;
|
||||
};
|
||||
|
||||
export type CurrencyProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const CurrencyContext = React.createContext<
|
||||
CurrencyContextProps | undefined
|
||||
>(undefined);
|
||||
|
||||
export const CurrencyProvider = ({ children }: CurrencyProviderProps) => {
|
||||
export const CurrencyProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
|
||||
const [currency, setCurrency] = useState<Currency>(engineers);
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,7 @@ type GroupsProviderLoadingProps = {
|
||||
dispatchLoadingGroups: (isLoading: boolean) => void;
|
||||
};
|
||||
|
||||
export const mapLoadingToProps: MapLoadingToProps<GroupsProviderLoadingProps> = ({
|
||||
const mapLoadingToProps: MapLoadingToProps<GroupsProviderLoadingProps> = ({
|
||||
dispatch,
|
||||
}) => ({
|
||||
dispatchLoadingGroups: (isLoading: boolean) =>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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, {
|
||||
PropsWithChildren,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { costInsightsApiRef } from '../api';
|
||||
import { MapLoadingToProps, useLoading } from './useLoading';
|
||||
import { DefaultLoadingAction, Maybe } from '../types';
|
||||
|
||||
type BillingDateProviderLoadingProps = {
|
||||
dispatchLoadingBillingDate: (isLoading: boolean) => void;
|
||||
};
|
||||
|
||||
const mapLoadingToProps: MapLoadingToProps<BillingDateProviderLoadingProps> = ({
|
||||
dispatch,
|
||||
}) => ({
|
||||
dispatchLoadingBillingDate: (isLoading: boolean) =>
|
||||
dispatch({ [DefaultLoadingAction.LastCompleteBillingDate]: isLoading }),
|
||||
});
|
||||
|
||||
export type BillingDateContextProps = {
|
||||
lastCompleteBillingDate: string; // YYYY-MM-DD
|
||||
};
|
||||
|
||||
export const BillingDateContext = React.createContext<
|
||||
BillingDateContextProps | undefined
|
||||
>(undefined);
|
||||
|
||||
export const BillingDateProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const client = useApi(costInsightsApiRef);
|
||||
const [error, setError] = useState<Maybe<Error>>(null);
|
||||
const { dispatchLoadingBillingDate } = useLoading(mapLoadingToProps);
|
||||
|
||||
const [lastCompleteBillingDate, setlastCompleteBillingDate] = useState<
|
||||
Maybe<string>
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatchLoadingBillingDate(true);
|
||||
|
||||
async function getLastCompleteBillingDate() {
|
||||
try {
|
||||
const d = await client.getLastCompleteBillingDate();
|
||||
setlastCompleteBillingDate(d);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
dispatchLoadingBillingDate(false);
|
||||
}
|
||||
}
|
||||
|
||||
getLastCompleteBillingDate();
|
||||
}, [client]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
if (!lastCompleteBillingDate) return null;
|
||||
|
||||
return (
|
||||
<BillingDateContext.Provider
|
||||
value={
|
||||
{
|
||||
lastCompleteBillingDate: lastCompleteBillingDate,
|
||||
} as BillingDateContextProps
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</BillingDateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useLastCompleteBillingDate(): string {
|
||||
const context = useContext(BillingDateContext);
|
||||
return context ? context.lastCompleteBillingDate : assertNever();
|
||||
}
|
||||
|
||||
function assertNever(): never {
|
||||
throw Error(
|
||||
'Cannot use useLastCompleteBillingDate outside of BillingDateProvider',
|
||||
);
|
||||
}
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import React, {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
createContext,
|
||||
Dispatch,
|
||||
PropsWithChildren,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -27,10 +27,10 @@ import React, {
|
||||
} from 'react';
|
||||
import { Backdrop, CircularProgress } from '@material-ui/core';
|
||||
import {
|
||||
Loading,
|
||||
getDefaultState,
|
||||
DefaultLoadingAction,
|
||||
getDefaultState,
|
||||
getLoadingActions,
|
||||
Loading,
|
||||
} from '../types';
|
||||
import { useBackdropStyles as useStyles } from '../utils/styles';
|
||||
import { useConfig } from './useConfig';
|
||||
@@ -41,10 +41,6 @@ export type LoadingContextProps = {
|
||||
actions: Array<string>;
|
||||
};
|
||||
|
||||
export type LoadingProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type MapLoadingToProps<T> = (props: LoadingContextProps) => T;
|
||||
|
||||
export const LoadingContext = createContext<LoadingContextProps | undefined>(
|
||||
@@ -58,7 +54,7 @@ function reducer(prevState: Loading, action: Partial<Loading>): Loading {
|
||||
} as Record<string, boolean>;
|
||||
}
|
||||
|
||||
export const LoadingProvider = ({ children }: LoadingProviderProps) => {
|
||||
export const LoadingProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const classes = useStyles();
|
||||
const { products } = useConfig();
|
||||
const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
*/
|
||||
import React, {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
import { CSSProperties } from '@material-ui/styles';
|
||||
import { Maybe } from '../types';
|
||||
@@ -32,10 +32,6 @@ export type ScrollContextProps = {
|
||||
setScrollTo: Dispatch<SetStateAction<ScrollTo>>;
|
||||
};
|
||||
|
||||
export type ScrollProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type ScrollUtils = {
|
||||
ScrollAnchor: (props: Omit<ScrollAnchorProps, 'id'>) => JSX.Element;
|
||||
scrollIntoView: () => void;
|
||||
@@ -96,7 +92,7 @@ export const ScrollAnchor = ({
|
||||
return <div ref={divRef} style={styles} data-testid={`scroll-test-${id}`} />;
|
||||
};
|
||||
|
||||
export const ScrollProvider = ({ children }: ScrollProviderProps) => {
|
||||
export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const [scrollTo, setScrollTo] = useState<ScrollTo>(null);
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Duration, inclusiveEndDateOf, inclusiveStartDateOf } from './Duration';
|
||||
|
||||
Date.now = jest.fn(() => new Date(Date.parse('2020-06-05')).valueOf());
|
||||
const lastCompleteBillingDate = '2020-06-05';
|
||||
|
||||
describe.each`
|
||||
duration | startDate | endDate
|
||||
@@ -26,7 +26,9 @@ describe.each`
|
||||
${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'}
|
||||
`('Calculates interval dates correctly', ({ duration, startDate, endDate }) => {
|
||||
it(`Calculates dates correctly for ${duration}`, () => {
|
||||
expect(inclusiveStartDateOf(duration)).toBe(startDate);
|
||||
expect(inclusiveEndDateOf(duration)).toBe(endDate);
|
||||
expect(inclusiveStartDateOf(duration, lastCompleteBillingDate)).toBe(
|
||||
startDate,
|
||||
);
|
||||
expect(inclusiveEndDateOf(duration, lastCompleteBillingDate)).toBe(endDate);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,23 +32,31 @@ export enum Duration {
|
||||
|
||||
export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
|
||||
|
||||
// Derive the start date of a given period, assuming two repeating intervals
|
||||
export function inclusiveStartDateOf(duration: Duration): string {
|
||||
/**
|
||||
* Derive the start date of a given period, assuming two repeating intervals.
|
||||
*
|
||||
* @param duration see comment on Duration enum
|
||||
* @param endDate from CostInsightsApi.getLastCompleteBillingDate
|
||||
*/
|
||||
export function inclusiveStartDateOf(
|
||||
duration: Duration,
|
||||
endDate: string,
|
||||
): string {
|
||||
switch (duration) {
|
||||
case Duration.P30D:
|
||||
case Duration.P90D:
|
||||
return moment()
|
||||
return moment(endDate)
|
||||
.utc()
|
||||
.subtract(moment.duration(duration).add(moment.duration(duration)))
|
||||
.format(DEFAULT_DATE_FORMAT);
|
||||
case Duration.P1M:
|
||||
return moment()
|
||||
return moment(endDate)
|
||||
.utc()
|
||||
.startOf('month')
|
||||
.subtract(moment.duration(duration).add(moment.duration(duration)))
|
||||
.format(DEFAULT_DATE_FORMAT);
|
||||
case Duration.P3M:
|
||||
return moment()
|
||||
return moment(endDate)
|
||||
.utc()
|
||||
.startOf('quarter')
|
||||
.subtract(moment.duration(duration).add(moment.duration(duration)))
|
||||
@@ -58,28 +66,37 @@ export function inclusiveStartDateOf(duration: Duration): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function exclusiveEndDateOf(duration: Duration): string {
|
||||
export function exclusiveEndDateOf(
|
||||
duration: Duration,
|
||||
endDate: string,
|
||||
): string {
|
||||
switch (duration) {
|
||||
case Duration.P30D:
|
||||
case Duration.P90D:
|
||||
return moment().utc().add(1, 'day').format(DEFAULT_DATE_FORMAT);
|
||||
return moment(endDate).utc().add(1, 'day').format(DEFAULT_DATE_FORMAT);
|
||||
case Duration.P1M:
|
||||
return moment().utc().startOf('month').format(DEFAULT_DATE_FORMAT);
|
||||
return moment(endDate).utc().startOf('month').format(DEFAULT_DATE_FORMAT);
|
||||
case Duration.P3M:
|
||||
return moment().utc().startOf('quarter').format(DEFAULT_DATE_FORMAT);
|
||||
return moment(endDate)
|
||||
.utc()
|
||||
.startOf('quarter')
|
||||
.format(DEFAULT_DATE_FORMAT);
|
||||
default:
|
||||
return assertNever(duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function inclusiveEndDateOf(duration: Duration): string {
|
||||
return moment(exclusiveEndDateOf(duration))
|
||||
export function inclusiveEndDateOf(
|
||||
duration: Duration,
|
||||
endDate: string,
|
||||
): string {
|
||||
return moment(exclusiveEndDateOf(duration, endDate))
|
||||
.utc()
|
||||
.subtract(1, 'day')
|
||||
.format(DEFAULT_DATE_FORMAT);
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
|
||||
export function intervalsOf(duration: Duration) {
|
||||
return `R2/${duration}/${exclusiveEndDateOf(duration)}`;
|
||||
export function intervalsOf(duration: Duration, endDate: string) {
|
||||
return `R2/${duration}/${exclusiveEndDateOf(duration, endDate)}`;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export type Loading = Record<string, boolean>;
|
||||
|
||||
export enum DefaultLoadingAction {
|
||||
UserGroups = 'user-groups',
|
||||
LastCompleteBillingDate = 'billing-date',
|
||||
CostInsightsInitial = 'cost-insights-initial',
|
||||
CostInsightsPage = 'cost-insights-page',
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { lengthyCurrencyFormatter, quarterOf } from './formatters';
|
||||
import {
|
||||
formatPeriod,
|
||||
lengthyCurrencyFormatter,
|
||||
quarterOf,
|
||||
} from './formatters';
|
||||
import { Duration } from '../types';
|
||||
|
||||
Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf());
|
||||
|
||||
@@ -48,3 +53,19 @@ describe('date formatters', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
duration | date | isEndDate | output
|
||||
${Duration.P1M} | ${'2020-10-11'} | ${true} | ${'September 2020'}
|
||||
${Duration.P1M} | ${'2020-10-11'} | ${false} | ${'August 2020'}
|
||||
${Duration.P3M} | ${'2020-10-11'} | ${true} | ${'Q3 2020'}
|
||||
${Duration.P3M} | ${'2020-10-11'} | ${false} | ${'Q2 2020'}
|
||||
${Duration.P30D} | ${'2020-10-11'} | ${true} | ${'Last 30 Days'}
|
||||
${Duration.P30D} | ${'2020-10-11'} | ${false} | ${'First 30 Days'}
|
||||
${Duration.P90D} | ${'2020-10-11'} | ${true} | ${'Last 90 Days'}
|
||||
${Duration.P90D} | ${'2020-10-11'} | ${false} | ${'First 90 Days'}
|
||||
`('formatPeriod', ({ duration, date, isEndDate, output }) => {
|
||||
it(`Correctly formats ${duration} with date ${date}`, async () => {
|
||||
expect(formatPeriod(duration, date, isEndDate)).toBe(output);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,26 +87,33 @@ export function formatPercent(n: number): string {
|
||||
return `${(n * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
export const formatLastTwoLookaheadQuarters = () => {
|
||||
const start = moment(inclusiveStartDateOf(Duration.P3M)).format('[Q]Q YYYY');
|
||||
const end = moment(inclusiveEndDateOf(Duration.P3M)).format('[Q]Q YYYY');
|
||||
export function formatLastTwoLookaheadQuarters(endDate: string) {
|
||||
const start = moment(inclusiveStartDateOf(Duration.P3M, endDate)).format(
|
||||
'[Q]Q YYYY',
|
||||
);
|
||||
const end = moment(inclusiveEndDateOf(Duration.P3M, endDate)).format(
|
||||
'[Q]Q YYYY',
|
||||
);
|
||||
return `${start} vs ${end}`;
|
||||
};
|
||||
}
|
||||
|
||||
export const formatLastTwoMonths = () => {
|
||||
const start = moment(inclusiveStartDateOf(Duration.P1M)).utc().format('MMMM');
|
||||
const end = moment(inclusiveEndDateOf(Duration.P1M)).utc().format('MMMM');
|
||||
export function formatLastTwoMonths(endDate: string) {
|
||||
const start = moment(inclusiveStartDateOf(Duration.P1M, endDate))
|
||||
.utc()
|
||||
.format('MMMM');
|
||||
const end = moment(inclusiveEndDateOf(Duration.P1M, endDate))
|
||||
.utc()
|
||||
.format('MMMM');
|
||||
return `${start} vs ${end}`;
|
||||
};
|
||||
}
|
||||
|
||||
export const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export const formatRelativeDuration = (
|
||||
date: string,
|
||||
const formatRelativePeriod = (
|
||||
duration: Duration,
|
||||
date: string,
|
||||
isEndDate: boolean,
|
||||
): string => {
|
||||
const periodStart = inclusiveStartDateOf(duration);
|
||||
const periodEnd = inclusiveEndDateOf(duration);
|
||||
const periodStart = isEndDate ? inclusiveStartDateOf(duration, date) : date;
|
||||
const periodEnd = isEndDate ? date : inclusiveEndDateOf(duration, date);
|
||||
const days = moment.duration(duration).asDays();
|
||||
if (![periodStart, periodEnd].includes(date)) {
|
||||
throw new Error(`Invalid relative date ${date} for duration ${duration}`);
|
||||
@@ -114,13 +121,25 @@ export const formatRelativeDuration = (
|
||||
return date === periodStart ? `First ${days} Days` : `Last ${days} Days`;
|
||||
};
|
||||
|
||||
export function formatDuration(date: string, duration: Duration) {
|
||||
export function formatPeriod(
|
||||
duration: Duration,
|
||||
date: string,
|
||||
isEndDate: boolean,
|
||||
) {
|
||||
switch (duration) {
|
||||
case Duration.P1M:
|
||||
return monthOf(date);
|
||||
return monthOf(
|
||||
isEndDate
|
||||
? inclusiveEndDateOf(duration, date)
|
||||
: inclusiveStartDateOf(duration, date),
|
||||
);
|
||||
case Duration.P3M:
|
||||
return quarterOf(date);
|
||||
return quarterOf(
|
||||
isEndDate
|
||||
? inclusiveEndDateOf(duration, date)
|
||||
: inclusiveStartDateOf(duration, date),
|
||||
);
|
||||
default:
|
||||
return formatRelativeDuration(date, duration);
|
||||
return formatRelativePeriod(duration, date, isEndDate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getDefaultState,
|
||||
Product,
|
||||
ProductCost,
|
||||
ProductFilters,
|
||||
ProjectGrowthData,
|
||||
UnlabeledDataflowAlertProject,
|
||||
UnlabeledDataflowData,
|
||||
@@ -135,7 +136,7 @@ export const MockProductTypes: Record<string, string> = {
|
||||
'cloud-pub-sub': 'Cloud Pub/Sub',
|
||||
};
|
||||
|
||||
export const MockProductFilters = Object.keys(
|
||||
export const MockProductFilters: ProductFilters = Object.keys(
|
||||
MockProductTypes,
|
||||
).map(productType => ({ duration: Duration.P1M, productType }));
|
||||
|
||||
|
||||
@@ -13,18 +13,24 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import React, { Dispatch, PropsWithChildren, SetStateAction } from 'react';
|
||||
import {
|
||||
Duration,
|
||||
getDefaultPageFilters,
|
||||
Group,
|
||||
Maybe,
|
||||
PageFilters,
|
||||
ProductFilters,
|
||||
Group,
|
||||
ProductPeriod,
|
||||
} from '../types';
|
||||
import { FilterContext } from '../hooks/useFilters';
|
||||
import { ConfigContext, ConfigContextProps } from '../hooks/useConfig';
|
||||
import { CurrencyContext, CurrencyContextProps } from '../hooks/useCurrency';
|
||||
import { ScrollContext, ScrollProviderProps } from '../hooks/useScroll';
|
||||
import { ScrollContext } from '../hooks/useScroll';
|
||||
import {
|
||||
BillingDateContext,
|
||||
BillingDateContextProps,
|
||||
} from '../hooks/useLastCompleteBillingDate';
|
||||
import { MockProductFilters } from './mockData';
|
||||
|
||||
export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }];
|
||||
@@ -32,20 +38,24 @@ export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }];
|
||||
type MockFilterProviderProps = {
|
||||
setPageFilters: Dispatch<SetStateAction<Maybe<PageFilters>>>;
|
||||
setProductFilters: Dispatch<SetStateAction<Maybe<ProductFilters>>>;
|
||||
children: ReactNode;
|
||||
duration?: Duration;
|
||||
};
|
||||
|
||||
export const MockFilterProvider = ({
|
||||
setPageFilters,
|
||||
setProductFilters,
|
||||
children,
|
||||
}: MockFilterProviderProps) => {
|
||||
duration = Duration.P1M,
|
||||
}: PropsWithChildren<MockFilterProviderProps>) => {
|
||||
const pageFilters = getDefaultPageFilters(MockGroups);
|
||||
return (
|
||||
<FilterContext.Provider
|
||||
value={{
|
||||
pageFilters: pageFilters,
|
||||
productFilters: MockProductFilters,
|
||||
productFilters: MockProductFilters.map((period: ProductPeriod) => ({
|
||||
...period,
|
||||
duration: duration,
|
||||
})),
|
||||
setPageFilters: setPageFilters,
|
||||
setProductFilters: setProductFilters,
|
||||
}}
|
||||
@@ -62,7 +72,7 @@ export const MockConfigProvider = ({
|
||||
engineerCost,
|
||||
currencies,
|
||||
children,
|
||||
}: ConfigContextProps & { children: React.ReactNode }) => (
|
||||
}: PropsWithChildren<ConfigContextProps>) => (
|
||||
<ConfigContext.Provider
|
||||
value={{ metrics, products, icons, engineerCost, currencies }}
|
||||
>
|
||||
@@ -74,13 +84,24 @@ export const MockCurrencyProvider = ({
|
||||
currency,
|
||||
setCurrency,
|
||||
children,
|
||||
}: CurrencyContextProps & { children: React.ReactNode }) => (
|
||||
}: PropsWithChildren<CurrencyContextProps>) => (
|
||||
<CurrencyContext.Provider value={{ currency, setCurrency }}>
|
||||
{children}
|
||||
</CurrencyContext.Provider>
|
||||
);
|
||||
|
||||
export const MockScrollProvider = ({ children }: ScrollProviderProps) => (
|
||||
export const MockBillingDateProvider = ({
|
||||
lastCompleteBillingDate,
|
||||
children,
|
||||
}: PropsWithChildren<BillingDateContextProps>) => (
|
||||
<BillingDateContext.Provider
|
||||
value={{ lastCompleteBillingDate: lastCompleteBillingDate }}
|
||||
>
|
||||
{children}
|
||||
</BillingDateContext.Provider>
|
||||
);
|
||||
|
||||
export const MockScrollProvider = ({ children }: PropsWithChildren<{}>) => (
|
||||
<ScrollContext.Provider value={{ scrollTo: null, setScrollTo: jest.fn() }}>
|
||||
{children}
|
||||
</ScrollContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user