Add getLastCompleteBillingDate to CostInsightsApi

Add useLastCompleteBillingDate hook
More usage of PropsWithChildren
This commit is contained in:
Tim Hansen
2020-10-22 19:22:29 -06:00
parent 628f782948
commit 169ed042ae
36 changed files with 542 additions and 266 deletions
@@ -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;
@@ -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}
@@ -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>
@@ -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();
@@ -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>
@@ -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>
@@ -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();
@@ -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 => {
@@ -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();
});
},
);
});
@@ -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>
+1
View File
@@ -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',
);
}
+6 -10
View File
@@ -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);
});
});
+30 -13
View File
@@ -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);
});
});
+37 -18
View File
@@ -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);
}
}
+2 -1
View File
@@ -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 }));
+30 -9
View File
@@ -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>