Cost Insights Configurable Default Currency

Signed-off-by: bnechyporenko <bnechyporenko@bol.com>
This commit is contained in:
bnechyporenko
2022-11-15 13:35:12 +01:00
parent f90dd68e78
commit cadb2f3eaf
17 changed files with 177 additions and 109 deletions
+1
View File
@@ -372,6 +372,7 @@ auth:
myproxy:
development: {}
costInsights:
baseCurrency: 'EUR'
engineerCost: 200000
products:
computeEngine:
+5
View File
@@ -21,6 +21,11 @@ export interface Config {
*/
engineerCost: number;
/**
* @visibility frontend
*/
baseCurrency?: string;
products?: {
[kind: string]: {
/**
@@ -18,7 +18,7 @@ import React from 'react';
import { fireEvent } from '@testing-library/react';
import { BarChart, BarChartProps } from './BarChart';
import { ResourceData } from '../../types';
import { createMockEntity } from '../../testUtils';
import { createMockEntity, MockConfigProvider } from '../../testUtils';
import { resourceSort } from '../../utils/sort';
import { renderInTestApp } from '@backstage/test-utils';
@@ -46,11 +46,13 @@ const renderWithProps = ({
resources = MockResources,
}: BarChartProps) => {
return renderInTestApp(
<BarChart
responsive={responsive}
displayAmount={displayAmount}
resources={resources}
/>,
<MockConfigProvider>
<BarChart
responsive={responsive}
displayAmount={displayAmount}
resources={resources}
/>
</MockConfigProvider>,
);
};
@@ -40,20 +40,26 @@ import { notEmpty } from '../../utils/assert';
import { useBarChartStyles } from '../../utils/styles';
import { resourceSort } from '../../utils/sort';
import { isInvalid, titleOf, tooltipItemOf } from '../../utils/graphs';
import { TooltipRenderer } from '../../types/Tooltip';
import { TooltipRenderer } from '../../types';
import { useConfig } from '../../hooks';
export const defaultTooltip: TooltipRenderer = ({ label, payload = [] }) => {
if (isInvalid({ label, payload })) return null;
const defaultTooltip = (baseCurrency: string) => {
const tooltip: TooltipRenderer = ({ label, payload = [] }) => {
if (isInvalid({ label, payload })) return null;
const title = titleOf(label);
const items = payload.map(tooltipItemOf).filter(notEmpty);
return (
<BarChartTooltip title={title}>
{items.map((item, index) => (
<BarChartTooltipItem key={`${item.label}-${index}`} item={item} />
))}
</BarChartTooltip>
);
const title = titleOf(label);
const items = payload
.map(p => tooltipItemOf(baseCurrency, p))
.filter(notEmpty);
return (
<BarChartTooltip title={title}>
{items.map((item, index) => (
<BarChartTooltipItem key={`${item.label}-${index}`} item={item} />
))}
</BarChartTooltip>
);
};
return tooltip;
};
/** @public */
@@ -69,12 +75,14 @@ export type BarChartProps = {
/** @public */
export const BarChart = (props: BarChartProps) => {
const { baseCurrency } = useConfig();
const {
resources,
responsive = true,
displayAmount = 6,
options = {},
tooltip = defaultTooltip,
tooltip = defaultTooltip(baseCurrency),
onClick,
onMouseMove,
} = props;
@@ -164,7 +172,7 @@ export const BarChart = (props: BarChartProps) => {
tick={BarChartTick}
/>
<YAxis
tickFormatter={currencyFormatter.format}
tickFormatter={currencyFormatter(baseCurrency).format}
domain={[() => 0, globalResourcesMax]}
tick={styles.axis}
/>
@@ -17,11 +17,14 @@
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { BarChartLegend } from './BarChartLegend';
import { MockConfigProvider } from '../../testUtils';
describe('<BarChartLegend />', () => {
it(`Should display the correct cost start and end`, async () => {
const rendered = await renderInTestApp(
<BarChartLegend costStart={1000} costEnd={5000} />,
<MockConfigProvider>
<BarChartLegend costStart={1000} costEnd={5000} />,
</MockConfigProvider>,
);
expect(rendered.getByText(/\$1,000/)).toBeInTheDocument();
expect(rendered.queryByText(/\$5,000/)).toBeInTheDocument();
@@ -20,6 +20,7 @@ import { LegendItem } from '../LegendItem';
import { currencyFormatter } from '../../utils/formatters';
import { CostInsightsTheme } from '../../types';
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
import { useConfig } from '../../hooks';
/** @public */
export type BarChartLegendOptions = {
@@ -45,6 +46,7 @@ export const BarChartLegend = (
const theme = useTheme<CostInsightsTheme>();
const classes = useStyles();
const { baseCurrency } = useConfig();
const data = Object.assign(
{
@@ -63,7 +65,7 @@ export const BarChartLegend = (
title={data.previousName}
markerColor={options.hideMarker ? undefined : data.previousFill}
>
{currencyFormatter.format(costStart)}
{currencyFormatter(baseCurrency).format(costStart)}
</LegendItem>
</Box>
<Box marginRight={2}>
@@ -71,7 +73,7 @@ export const BarChartLegend = (
title={data.currentName}
markerColor={options.hideMarker ? undefined : data.currentFill}
>
{currencyFormatter.format(costEnd)}
{currencyFormatter(baseCurrency).format(costEnd)}
</LegendItem>
</Box>
{children}
@@ -16,41 +16,41 @@
import React, { useState } from 'react';
import { DateTime } from 'luxon';
import {
useTheme,
Box,
Typography,
Divider,
emphasize,
Typography,
useTheme,
} from '@material-ui/core';
import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip as RechartsTooltip,
XAxis,
YAxis,
Tooltip as RechartsTooltip,
Area,
ResponsiveContainer,
CartesianGrid,
} from 'recharts';
import { Cost, DEFAULT_DATE_FORMAT, CostInsightsTheme } from '../../types';
import { Cost, CostInsightsTheme, DEFAULT_DATE_FORMAT } from '../../types';
import {
BarChartLegend,
BarChartTooltip as Tooltip,
BarChartTooltipItem as TooltipItem,
BarChartLegend,
} from '../BarChart';
import {
overviewGraphTickFormatter,
formatGraphValue,
isInvalid,
overviewGraphTickFormatter,
} from '../../utils/graphs';
import { useCostOverviewStyles as useStyles } from '../../utils/styles';
import { useFilters, useLastCompleteBillingDate } from '../../hooks';
import { useConfig, useFilters, useLastCompleteBillingDate } from '../../hooks';
import { mapFiltersToProps } from './selector';
import { getPreviousPeriodTotalCost } from '../../utils/change';
import { formatPeriod } from '../../utils/formatters';
import { aggregationSum } from '../../utils/sum';
import { BarChartLegendOptions } from '../BarChart/BarChartLegend';
import { TooltipRenderer } from '../../types/Tooltip';
import { BarChartLegendOptions } from '../BarChart';
import { TooltipRenderer } from '../../types';
export type CostOverviewBreakdownChartProps = {
costBreakdown: Cost[];
@@ -65,6 +65,7 @@ export const CostOverviewBreakdownChart = ({
}: CostOverviewBreakdownChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const classes = useStyles(theme);
const { baseCurrency } = useConfig();
const lastCompleteBillingDate = useLastCompleteBillingDate();
const { duration } = useFilters(mapFiltersToProps);
const [isExpanded, setExpanded] = useState(false);
@@ -187,7 +188,7 @@ export const CostOverviewBreakdownChart = ({
const dateTitle = date.toUTC().toFormat(DEFAULT_DATE_FORMAT);
const items = payload.map((p, i) => ({
label: p.dataKey as string,
value: formatGraphValue(Number(p.value), i),
value: formatGraphValue(baseCurrency)(Number(p.value), i),
fill: p.color!,
}));
const expandText = (
@@ -253,7 +254,7 @@ export const CostOverviewBreakdownChart = ({
<YAxis
domain={[() => 0, 'dataMax']}
tick={{ fill: classes.axis.fill }}
tickFormatter={formatGraphValue}
tickFormatter={formatGraphValue(baseCurrency)}
width={classes.yAxis.width}
/>
{renderAreas()}
@@ -48,7 +48,8 @@ import { useCostOverviewStyles as useStyles } from '../../utils/styles';
import { groupByDate, toDataMax, trendFrom } from '../../utils/charts';
import { aggregationSort } from '../../utils/sort';
import { CostOverviewLegend } from './CostOverviewLegend';
import { TooltipRenderer } from '../../types/Tooltip';
import { TooltipRenderer } from '../../types';
import { useConfig } from '../../hooks';
type CostOverviewChartProps = {
metric: Maybe<Metric>;
@@ -65,6 +66,7 @@ export const CostOverviewChart = ({
}: CostOverviewChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const styles = useStyles(theme);
const { baseCurrency } = useConfig();
const data = {
dailyCost: {
@@ -115,8 +117,16 @@ export const CostOverviewChart = ({
: data.metric.name,
value:
p.dataKey === data.dailyCost.dataKey
? formatGraphValue(Number(p.value), i, data.dailyCost.format)
: formatGraphValue(Number(p.value), i, data.metric.format),
? formatGraphValue(baseCurrency)(
Number(p.value),
i,
data.dailyCost.format,
)
: formatGraphValue(baseCurrency)(
Number(p.value),
i,
data.metric.format,
),
fill:
p.dataKey === data.dailyCost.dataKey
? theme.palette.blue
@@ -157,7 +167,7 @@ export const CostOverviewChart = ({
<YAxis
domain={[() => 0, 'dataMax']}
tick={{ fill: styles.axis.fill }}
tickFormatter={formatGraphValue}
tickFormatter={formatGraphValue(baseCurrency)}
width={styles.yAxis.width}
yAxisId={data.dailyCost.dataKey}
/>
@@ -19,6 +19,7 @@ import { wrapInTestApp } from '@backstage/test-utils';
import { ProductEntityDialog } from './ProductEntityDialog';
import { render } from '@testing-library/react';
import { Entity } from '../../types';
import { MockConfigProvider } from '../../testUtils';
const atomicEntity: Entity = {
id: null,
@@ -86,11 +87,13 @@ describe('<ProductEntityDialog/>', () => {
it('Should show a tab for a single sub-entity type', () => {
const { getByText } = render(
wrapInTestApp(
<ProductEntityDialog
open
entity={singleBreakdownEntity}
onClose={jest.fn()}
/>,
<MockConfigProvider>
<ProductEntityDialog
open
entity={singleBreakdownEntity}
onClose={jest.fn()}
/>
</MockConfigProvider>,
),
);
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
@@ -99,11 +102,13 @@ describe('<ProductEntityDialog/>', () => {
it('Should show tabs when multiple sub-entity types exist', () => {
const { getByText } = render(
wrapInTestApp(
<ProductEntityDialog
open
entity={multiBreakdownEntity}
onClose={jest.fn()}
/>,
<MockConfigProvider>
<ProductEntityDialog
open
entity={multiBreakdownEntity}
onClose={jest.fn()}
/>
</MockConfigProvider>,
),
);
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
@@ -22,6 +22,7 @@ import { useEntityDialogStyles as useStyles } from '../../utils/styles';
import { CostGrowthIndicator } from '../CostGrowth';
import { BarChartOptions, ChangeStatistic, Entity } from '../../types';
import { Table, TableColumn } from '@backstage/core-components';
import { useConfig } from '../../hooks';
export type ProductEntityTableOptions = Partial<
Pick<BarChartOptions, 'previousName' | 'currentName'>
@@ -35,7 +36,11 @@ type RowData = {
change: ChangeStatistic;
};
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
function createRenderer(
baseCurrency: string,
col: keyof RowData,
classes: Record<string, string>,
) {
return function render(rowData: {}): JSX.Element {
const row = rowData as RowData;
const rowStyles = classnames(classes.row, {
@@ -49,7 +54,7 @@ function createRenderer(col: keyof RowData, classes: Record<string, string>) {
case 'current':
return (
<Typography className={rowStyles}>
{costFormatter.format(row[col])}
{costFormatter(baseCurrency).format(row[col])}
</Typography>
);
case 'change':
@@ -99,6 +104,7 @@ export const ProductEntityTable = ({
options,
}: ProductEntityTableProps) => {
const classes = useStyles();
const { baseCurrency } = useConfig();
const entities = entity.entities[entityLabel];
const data = Object.assign(
@@ -116,7 +122,7 @@ export const ProductEntityTable = ({
{
field: 'label',
title: <Typography className={firstColClasses}>{entityLabel}</Typography>,
render: createRenderer('label', classes),
render: createRenderer(baseCurrency, 'label', classes),
customSort: createSorter('label'),
width: '33.33%',
},
@@ -126,7 +132,7 @@ export const ProductEntityTable = ({
<Typography className={classes.column}>{data.previousName}</Typography>
),
align: 'right',
render: createRenderer('previous', classes),
render: createRenderer(baseCurrency, 'previous', classes),
customSort: createSorter('previous'),
},
{
@@ -135,14 +141,14 @@ export const ProductEntityTable = ({
<Typography className={classes.column}>{data.currentName}</Typography>
),
align: 'right',
render: createRenderer('current', classes),
render: createRenderer(baseCurrency, 'current', classes),
customSort: createSorter('current'),
},
{
field: 'change',
title: <Typography className={lastColClasses}>Change</Typography>,
align: 'right',
render: createRenderer('change', classes),
render: createRenderer(baseCurrency, 'change', classes),
customSort: createSorter('change'),
},
];
@@ -50,7 +50,8 @@ import {
} from '../../utils/styles';
import { Duration, Entity, Maybe } from '../../types';
import { choose } from '../../utils/change';
import { TooltipRenderer } from '../../types/Tooltip';
import { TooltipRenderer } from '../../types';
import { useConfig } from '../../hooks';
export type ProductInsightsChartProps = {
billingDate: string;
@@ -65,6 +66,7 @@ export const ProductInsightsChart = ({
}: ProductInsightsChartProps) => {
const classes = useStyles();
const layoutClasses = useLayoutStyles();
const { baseCurrency } = useConfig();
// Only a single entities Record for the root product entity is supported
const entities = useMemo(() => {
@@ -131,7 +133,9 @@ export const ProductInsightsChart = ({
const id = label === '' ? null : label;
const title = titleOf(label);
const items = payload.map(tooltipItemOf).filter(notEmpty);
const items = payload
.map(p => tooltipItemOf(baseCurrency, p))
.filter(notEmpty);
const activeEntity = findAlways(entities, e => e.id === id);
const breakdowns = Object.keys(activeEntity.entities);
@@ -19,6 +19,7 @@ import { UnlabeledDataflowAlertCard } from './UnlabeledDataflowAlertCard';
import {
createMockUnlabeledDataflowData,
createMockUnlabeledDataflowAlertProject,
MockConfigProvider,
} from '../../testUtils';
import { renderInTestApp } from '@backstage/test-utils';
@@ -61,9 +62,11 @@ describe('<UnlabeledDataflowAlertCard />', () => {
'projects with unlabeled Dataflow jobs in the last 30 days.',
);
const rendered = await renderInTestApp(
<UnlabeledDataflowAlertCard
alert={MockUnlabeledDataflowAlertMultipleProjects}
/>,
<MockConfigProvider>
<UnlabeledDataflowAlertCard
alert={MockUnlabeledDataflowAlertMultipleProjects}
/>
</MockConfigProvider>,
);
expect(rendered.getByText(subheader)).toBeInTheDocument();
});
@@ -71,9 +74,11 @@ describe('<UnlabeledDataflowAlertCard />', () => {
it('renders the correct subheader for a single project', async () => {
const subheader = new RegExp('1 project');
const rendered = await renderInTestApp(
<UnlabeledDataflowAlertCard
alert={MockUnlabeledDataflowAlertSingleProject}
/>,
<MockConfigProvider>
<UnlabeledDataflowAlertCard
alert={MockUnlabeledDataflowAlertSingleProject}
/>
</MockConfigProvider>,
);
expect(rendered.getByText(subheader)).toBeInTheDocument();
});
@@ -46,6 +46,7 @@ import { configApiRef, useApi } from '@backstage/core-plugin-api';
* default: true
* metricB:
* name: Metric B
* baseCurrency: 'EUR'
* currencies:
* currencyA:
* label: Currency A
@@ -60,6 +61,7 @@ import { configApiRef, useApi } from '@backstage/core-plugin-api';
/** @public */
export type ConfigContextProps = {
baseCurrency: string;
metrics: Metric[];
products: Product[];
icons: Icon[];
@@ -72,6 +74,7 @@ export const ConfigContext = createContext<ConfigContextProps | undefined>(
);
const defaultState: ConfigContextProps = {
baseCurrency: 'USD',
metrics: [],
products: [],
icons: [],
@@ -110,6 +113,15 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => {
return [];
}
function getBaseCurrency(): string {
const baseCurrency = c.getOptionalString('costInsights.baseCurrency');
if (baseCurrency) {
return baseCurrency;
}
return defaultState.baseCurrency;
}
function getCurrencies(): Currency[] {
const currencies = c.getOptionalConfig('costInsights.currencies');
if (currencies) {
@@ -141,6 +153,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => {
}
function getConfig() {
const baseCurrency = getBaseCurrency();
const products = getProducts();
const metrics = getMetrics();
const engineerCost = getEngineerCost();
@@ -152,6 +165,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => {
setConfig(prevState => ({
...prevState,
baseCurrency,
metrics,
products,
engineerCost,
@@ -15,16 +15,13 @@
*/
import React, { PropsWithChildren } from 'react';
import { LoadingContext, LoadingContextProps } from '../hooks/useLoading';
import { GroupsContext, GroupsContextProps } from '../hooks/useGroups';
import { FilterContext, FilterContextProps } from '../hooks/useFilters';
import { ConfigContext, ConfigContextProps } from '../hooks/useConfig';
import { CurrencyContext, CurrencyContextProps } from '../hooks/useCurrency';
import {
BillingDateContext,
BillingDateContextProps,
} from '../hooks/useLastCompleteBillingDate';
import { ScrollContext, ScrollContextProps } from '../hooks/useScroll';
import { LoadingContext, LoadingContextProps } from '../hooks';
import { GroupsContext, GroupsContextProps } from '../hooks';
import { FilterContext, FilterContextProps } from '../hooks';
import { ConfigContext, ConfigContextProps } from '../hooks';
import { CurrencyContext, CurrencyContextProps } from '../hooks';
import { BillingDateContext, BillingDateContextProps } from '../hooks';
import { ScrollContext, ScrollContextProps } from '../hooks';
import { Group, Duration } from '../types';
export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }];
@@ -85,6 +82,7 @@ export const MockConfigProvider = (props: MockConfigProviderProps) => {
const { children, ...context } = props;
const defaultContext: ConfigContextProps = {
baseCurrency: 'USD',
metrics: [],
products: [],
icons: [],
@@ -39,7 +39,7 @@ describe('date formatters', () => {
0.00000040925, 0.21, 0.0000004, 0.4139877878, 0.00000234566,
];
const formattedValues = values.map(val =>
lengthyCurrencyFormatter.format(val),
lengthyCurrencyFormatter('USD').format(val),
);
expect(formattedValues).toEqual([
'$0.00000041',
+21 -18
View File
@@ -17,7 +17,7 @@
import { DateTime, Duration as LuxonDuration } from 'luxon';
import pluralize from 'pluralize';
import { ChangeStatistic, Duration } from '../types';
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration';
import { notEmpty } from './assert';
export type Period = {
@@ -25,25 +25,28 @@ export type Period = {
periodEnd: string;
};
export const costFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
export const costFormatter = (currency: string) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
});
export const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
export const currencyFormatter = (currency: string) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
export const lengthyCurrencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
minimumSignificantDigits: 2,
maximumSignificantDigits: 2,
});
export const lengthyCurrencyFormatter = (currency: string) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
minimumSignificantDigits: 2,
maximumSignificantDigits: 2,
});
export const numberFormatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
+16 -15
View File
@@ -23,29 +23,30 @@ import {
lengthyCurrencyFormatter,
} from './formatters';
export function formatGraphValue(
value: number,
_index: number,
format?: string,
) {
if (format === 'number') {
return value.toLocaleString();
}
export const formatGraphValue =
(baseCurrency: string) =>
(value: number, _index: number, format?: string) => {
if (format === 'number') {
return value.toLocaleString();
}
if (value < 1) {
return lengthyCurrencyFormatter.format(value);
}
if (value < 1) {
return lengthyCurrencyFormatter(baseCurrency).format(value);
}
return currencyFormatter.format(value);
}
return currencyFormatter(baseCurrency).format(value);
};
export const overviewGraphTickFormatter = (millis: string | number) =>
typeof millis === 'number' ? dateFormatter.format(millis) : millis;
export const tooltipItemOf = (payload: Payload<string, string>) => {
export const tooltipItemOf = (
baseCurrency: string,
payload: Payload<string, string>,
) => {
const value =
typeof payload.value === 'number'
? currencyFormatter.format(payload.value)
? currencyFormatter(baseCurrency).format(payload.value)
: payload.value;
const fill = payload.color as string;