diff --git a/.changeset/four-adults-provide.md b/.changeset/four-adults-provide.md new file mode 100644 index 0000000000..0edb4807d9 --- /dev/null +++ b/.changeset/four-adults-provide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Provide the ability to change the base currency from USD to any other currency in cost insights plugin diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index cc90b4d7a2..f65194551a 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -178,6 +178,21 @@ costInsights: name: Metric C ``` +### Base Currency (Optional) + +In the case you would like to show your baseline costs on the graph on other currency than US dollars. + +```yaml +## ./app-config.yaml +costInsights: + engineerCost: 200000 + baseCurrency: + locale: nl-NL + options: + currency: EUR + minimumFractionDigits: 3 +``` + ### Currencies (Optional) In the `Cost Overview` panel, users can choose from a dropdown of currencies to see costs in, such as Engineers or USD. Currencies must be defined as keys on the `currencies` field. A user-friendly label and unit are **required**. If not set, the `defaultCurrencies` in `currency.ts` will be used. diff --git a/plugins/cost-insights/api-report.md b/plugins/cost-insights/api-report.md index dd4e404450..b4c818f1e4 100644 --- a/plugins/cost-insights/api-report.md +++ b/plugins/cost-insights/api-report.md @@ -227,6 +227,7 @@ export type ChartData = { // @public (undocumented) export type ConfigContextProps = { + baseCurrency: Intl.NumberFormat; metrics: Metric[]; products: Product[]; icons: Icon[]; diff --git a/plugins/cost-insights/config.d.ts b/plugins/cost-insights/config.d.ts index 4144b6d6a5..5317485555 100644 --- a/plugins/cost-insights/config.d.ts +++ b/plugins/cost-insights/config.d.ts @@ -21,6 +21,58 @@ export interface Config { */ engineerCost: number; + /** + * @visibility frontend + */ + baseCurrency?: { + /** + * @visibility frontend + */ + locale?: string; + options?: { + /** + * @visibility frontend + */ + localeMatcher?: string | undefined; + /** + * @visibility frontend + */ + style?: string | undefined; + /** + * @visibility frontend + */ + currency?: string | undefined; + /** + * @visibility frontend + */ + currencySign?: string | undefined; + /** + * @visibility frontend + */ + useGrouping?: boolean | undefined; + /** + * @visibility frontend + */ + minimumIntegerDigits?: number | undefined; + /** + * @visibility frontend + */ + minimumFractionDigits?: number | undefined; + /** + * @visibility frontend + */ + maximumFractionDigits?: number | undefined; + /** + * @visibility frontend + */ + minimumSignificantDigits?: number | undefined; + /** + * @visibility frontend + */ + maximumSignificantDigits?: number | undefined; + }; + }; + products?: { [kind: string]: { /** diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx index 4e3ca9d78c..530ca16e8f 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx @@ -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( - , + + + , ); }; diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.tsx index 4f4dea8ddd..fa4ae18e24 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChart.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChart.tsx @@ -40,20 +40,24 @@ 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: Intl.NumberFormat) => { + const tooltip: TooltipRenderer = ({ label, payload = [] }) => { + if (isInvalid({ label, payload })) return null; - const title = titleOf(label); - const items = payload.map(tooltipItemOf).filter(notEmpty); - return ( - - {items.map((item, index) => ( - - ))} - - ); + const title = titleOf(label); + const items = payload.map(tooltipItemOf(baseCurrency)).filter(notEmpty); + return ( + + {items.map((item, index) => ( + + ))} + + ); + }; + return tooltip; }; /** @public */ @@ -69,12 +73,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 +170,7 @@ export const BarChart = (props: BarChartProps) => { tick={BarChartTick} /> 0, globalResourcesMax]} tick={styles.axis} /> diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLegend.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLegend.test.tsx index 07ff144862..690f0bbcd8 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartLegend.test.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLegend.test.tsx @@ -17,11 +17,14 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { BarChartLegend } from './BarChartLegend'; +import { MockConfigProvider } from '../../testUtils'; describe('', () => { it(`Should display the correct cost start and end`, async () => { const rendered = await renderInTestApp( - , + + , + , ); expect(rendered.getByText(/\$1,000/)).toBeInTheDocument(); expect(rendered.queryByText(/\$5,000/)).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLegend.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLegend.tsx index 72c3120dd4..becd5467d1 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartLegend.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLegend.tsx @@ -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(); 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)} @@ -71,7 +73,7 @@ export const BarChartLegend = ( title={data.currentName} markerColor={options.hideMarker ? undefined : data.currentFill} > - {currencyFormatter.format(costEnd)} + {currencyFormatter(baseCurrency).format(costEnd)} {children} diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx index 962dc6b332..80a8d5f9ac 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx @@ -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(); const classes = useStyles(theme); + const { baseCurrency } = useConfig(); const lastCompleteBillingDate = useLastCompleteBillingDate(); const { duration } = useFilters(mapFiltersToProps); const [isExpanded, setExpanded] = useState(false); @@ -185,9 +186,10 @@ export const CostOverviewBreakdownChart = ({ ? DateTime.fromMillis(label) : DateTime.fromISO(label!); const dateTitle = date.toUTC().toFormat(DEFAULT_DATE_FORMAT); + const formatGraphValueWith = formatGraphValue(baseCurrency); const items = payload.map((p, i) => ({ label: p.dataKey as string, - value: formatGraphValue(Number(p.value), i), + value: formatGraphValueWith(Number(p.value), i), fill: p.color!, })); const expandText = ( @@ -253,7 +255,7 @@ export const CostOverviewBreakdownChart = ({ 0, 'dataMax']} tick={{ fill: classes.axis.fill }} - tickFormatter={formatGraphValue} + tickFormatter={formatGraphValue(baseCurrency)} width={classes.yAxis.width} /> {renderAreas()} diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx index 1c58bf58e6..0a87474476 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx @@ -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; @@ -65,6 +66,7 @@ export const CostOverviewChart = ({ }: CostOverviewChartProps) => { const theme = useTheme(); const styles = useStyles(theme); + const { baseCurrency } = useConfig(); const data = { dailyCost: { @@ -106,6 +108,7 @@ export const CostOverviewChart = ({ ? DateTime.fromMillis(label) : DateTime.fromISO(label!); const title = date.toUTC().toFormat(DEFAULT_DATE_FORMAT); + const formatGraphValueWith = formatGraphValue(baseCurrency); const items = payload .filter(p => dataKeys.includes(p.dataKey as string)) .map((p, i) => ({ @@ -115,8 +118,8 @@ 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), + ? formatGraphValueWith(Number(p.value), i, data.dailyCost.format) + : formatGraphValueWith(Number(p.value), i, data.metric.format), fill: p.dataKey === data.dailyCost.dataKey ? theme.palette.blue @@ -157,7 +160,7 @@ export const CostOverviewChart = ({ 0, 'dataMax']} tick={{ fill: styles.axis.fill }} - tickFormatter={formatGraphValue} + tickFormatter={formatGraphValue(baseCurrency)} width={styles.yAxis.width} yAxisId={data.dailyCost.dataKey} /> diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.test.tsx index d3d12abe74..fef201ec12 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.test.tsx @@ -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('', () => { it('Should show a tab for a single sub-entity type', () => { const { getByText } = render( wrapInTestApp( - , + + + , ), ); expect(getByText('Breakdown by SKU')).toBeInTheDocument(); @@ -99,11 +102,13 @@ describe('', () => { it('Should show tabs when multiple sub-entity types exist', () => { const { getByText } = render( wrapInTestApp( - , + + + , ), ); expect(getByText('Breakdown by SKU')).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx index 95ba4f2f2d..c20c7d4a1f 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx @@ -17,11 +17,12 @@ import React from 'react'; import classnames from 'classnames'; import { Typography } from '@material-ui/core'; -import { costFormatter, formatChange } from '../../utils/formatters'; +import { formatChange } from '../../utils/formatters'; 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 @@ -35,36 +36,38 @@ type RowData = { change: ChangeStatistic; }; -function createRenderer(col: keyof RowData, classes: Record) { - return function render(rowData: {}): JSX.Element { - const row = rowData as RowData; - const rowStyles = classnames(classes.row, { - [classes.rowTotal]: row.id === 'total', - [classes.colFirst]: col === 'label', - [classes.colLast]: col === 'change', - }); +const createRenderer = + (baseCurrency: Intl.NumberFormat) => + (col: keyof RowData, classes: Record) => { + return function render(rowData: {}): JSX.Element { + const row = rowData as RowData; + const rowStyles = classnames(classes.row, { + [classes.rowTotal]: row.id === 'total', + [classes.colFirst]: col === 'label', + [classes.colLast]: col === 'change', + }); - switch (col) { - case 'previous': - case 'current': - return ( - - {costFormatter.format(row[col])} - - ); - case 'change': - return ( - - ); - default: - return {row.label}; - } + switch (col) { + case 'previous': + case 'current': + return ( + + {baseCurrency.format(row[col])} + + ); + case 'change': + return ( + + ); + default: + return {row.label}; + } + }; }; -} // material-table does not support fixed rows. Override the sorting algorithm // to force Total row to bottom by default or when a user sort toggles a column. @@ -99,6 +102,7 @@ export const ProductEntityTable = ({ options, }: ProductEntityTableProps) => { const classes = useStyles(); + const { baseCurrency } = useConfig(); const entities = entity.entities[entityLabel]; const data = Object.assign( @@ -116,7 +120,7 @@ export const ProductEntityTable = ({ { field: 'label', title: {entityLabel}, - render: createRenderer('label', classes), + render: createRenderer(baseCurrency)('label', classes), customSort: createSorter('label'), width: '33.33%', }, @@ -126,7 +130,7 @@ export const ProductEntityTable = ({ {data.previousName} ), align: 'right', - render: createRenderer('previous', classes), + render: createRenderer(baseCurrency)('previous', classes), customSort: createSorter('previous'), }, { @@ -135,14 +139,14 @@ export const ProductEntityTable = ({ {data.currentName} ), align: 'right', - render: createRenderer('current', classes), + render: createRenderer(baseCurrency)('current', classes), customSort: createSorter('current'), }, { field: 'change', title: Change, align: 'right', - render: createRenderer('change', classes), + render: createRenderer(baseCurrency)('change', classes), customSort: createSorter('change'), }, ]; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx index b77b415e1e..f44e24f443 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx @@ -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,7 @@ export const ProductInsightsChart = ({ const id = label === '' ? null : label; const title = titleOf(label); - const items = payload.map(tooltipItemOf).filter(notEmpty); + const items = payload.map(tooltipItemOf(baseCurrency)).filter(notEmpty); const activeEntity = findAlways(entities, e => e.id === id); const breakdowns = Object.keys(activeEntity.entities); diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx index 06353535a5..3ef6e54a53 100644 --- a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx @@ -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('', () => { 'projects with unlabeled Dataflow jobs in the last 30 days.', ); const rendered = await renderInTestApp( - , + + + , ); expect(rendered.getByText(subheader)).toBeInTheDocument(); }); @@ -71,9 +74,11 @@ describe('', () => { it('renders the correct subheader for a single project', async () => { const subheader = new RegExp('1 project'); const rendered = await renderInTestApp( - , + + + , ); expect(rendered.getByText(subheader)).toBeInTheDocument(); }); diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx index 322e1e35f1..6095f97d49 100644 --- a/plugins/cost-insights/src/hooks/useConfig.tsx +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -25,7 +25,7 @@ import { Config as BackstageConfig } from '@backstage/config'; import { Currency, Icon, Metric, Product } from '../types'; import { getIcon } from '../utils/navigation'; import { validateCurrencies, validateMetrics } from '../utils/config'; -import { defaultCurrencies } from '../utils/currency'; +import { createCurrencyFormat, defaultCurrencies } from '../utils/currency'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; /* @@ -46,6 +46,11 @@ import { configApiRef, useApi } from '@backstage/core-plugin-api'; * default: true * metricB: * name: Metric B + * baseCurrency: + * locale: nl-NL + * options: + * currency: EUR + * minimumFractionDigits: 3 * currencies: * currencyA: * label: Currency A @@ -60,6 +65,7 @@ import { configApiRef, useApi } from '@backstage/core-plugin-api'; /** @public */ export type ConfigContextProps = { + baseCurrency: Intl.NumberFormat; metrics: Metric[]; products: Product[]; icons: Icon[]; @@ -72,6 +78,7 @@ export const ConfigContext = createContext( ); const defaultState: ConfigContextProps = { + baseCurrency: createCurrencyFormat(), metrics: [], products: [], icons: [], @@ -110,6 +117,42 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => { return []; } + function getBaseCurrency(): Intl.NumberFormat { + const baseCurrency = c.getOptionalConfig('costInsights.baseCurrency'); + if (baseCurrency) { + const options = baseCurrency.getOptionalConfig('options'); + return new Intl.NumberFormat( + baseCurrency.getOptionalString('locale'), + options + ? { + localeMatcher: options.getOptionalString('localeMatcher'), + style: 'currency', + currency: options.getOptionalString('currency'), + currencySign: options.getOptionalString('currencySign'), + useGrouping: options.getOptionalBoolean('useGrouping'), + minimumIntegerDigits: options.getOptionalNumber( + 'minimumIntegerDigits', + ), + minimumFractionDigits: options.getOptionalNumber( + 'minimumFractionDigits', + ), + maximumFractionDigits: options.getOptionalNumber( + 'maximumFractionDigits', + ), + minimumSignificantDigits: options.getOptionalNumber( + 'minimumSignificantDigits', + ), + maximumSignificantDigits: options.getOptionalNumber( + 'maximumSignificantDigits', + ), + } + : undefined, + ); + } + + return defaultState.baseCurrency; + } + function getCurrencies(): Currency[] { const currencies = c.getOptionalConfig('costInsights.currencies'); if (currencies) { @@ -141,6 +184,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => { } function getConfig() { + const baseCurrency = getBaseCurrency(); const products = getProducts(); const metrics = getMetrics(); const engineerCost = getEngineerCost(); @@ -152,6 +196,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => { setConfig(prevState => ({ ...prevState, + baseCurrency, metrics, products, engineerCost, diff --git a/plugins/cost-insights/src/testUtils/providers.tsx b/plugins/cost-insights/src/testUtils/providers.tsx index 8088fca3c6..bdba7c26fb 100644 --- a/plugins/cost-insights/src/testUtils/providers.tsx +++ b/plugins/cost-insights/src/testUtils/providers.tsx @@ -15,19 +15,15 @@ */ 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 { Group, Duration } from '../types'; - -export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }]; +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 { Duration } from '../types'; +import { createCurrencyFormat } from '../utils/currency'; export type MockFilterProviderProps = PropsWithChildren< Partial @@ -85,6 +81,7 @@ export const MockConfigProvider = (props: MockConfigProviderProps) => { const { children, ...context } = props; const defaultContext: ConfigContextProps = { + baseCurrency: createCurrencyFormat(), metrics: [], products: [], icons: [], diff --git a/plugins/cost-insights/src/utils/currency.ts b/plugins/cost-insights/src/utils/currency.ts index 60115aad3a..98c968fc53 100644 --- a/plugins/cost-insights/src/utils/currency.ts +++ b/plugins/cost-insights/src/utils/currency.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { Currency, CurrencyType, Duration } from '../types'; -import { assertNever } from '../utils/assert'; +import { assertNever } from './assert'; export const rateOf = (cost: number, duration: Duration) => { switch (duration) { @@ -61,3 +61,12 @@ export const defaultCurrencies: Currency[] = [ rate: 5.5, }, ]; + +export const createCurrencyFormat = ( + currency: string = 'USD', + locale: string = 'en-US', +) => + new Intl.NumberFormat(locale, { + currency, + style: 'currency', + }); diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts index 8bf0a6a9d7..1190ac17ae 100644 --- a/plugins/cost-insights/src/utils/formatters.test.ts +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -21,6 +21,7 @@ import { quarterOf, } from './formatters'; import { Duration } from '../types'; +import { createCurrencyFormat } from './currency'; Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf()); @@ -39,7 +40,7 @@ describe('date formatters', () => { 0.00000040925, 0.21, 0.0000004, 0.4139877878, 0.00000234566, ]; const formattedValues = values.map(val => - lengthyCurrencyFormatter.format(val), + lengthyCurrencyFormatter(createCurrencyFormat()).format(val), ); expect(formattedValues).toEqual([ '$0.00000041', @@ -49,6 +50,22 @@ describe('date formatters', () => { '$0.0000023', ]); }); + + it('Correctly formats values in euros to two significant digits', () => { + const values = [ + 0.00000040925, 0.21, 0.0000004, 0.4139877878, 0.00000234566, + ]; + const formattedValues = values.map(val => + lengthyCurrencyFormatter(createCurrencyFormat('EUR')).format(val), + ); + expect(formattedValues).toEqual([ + '€0.00000041', + '€0.21', + '€0.00000040', + '€0.41', + '€0.0000023', + ]); + }); }); describe.each` diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index e9b19097a4..fd3fb4c0ef 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -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 currencyFormatter = (currency: Intl.NumberFormat) => { + const options = currency.resolvedOptions(); -export const currencyFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, -}); + return new Intl.NumberFormat(options.locale, { + style: 'currency', + currency: options.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: Intl.NumberFormat) => { + const options = currency.resolvedOptions(); + + return new Intl.NumberFormat(options.locale, { + style: 'currency', + currency: options.currency, + minimumFractionDigits: 0, + minimumSignificantDigits: 2, + maximumSignificantDigits: 2, + }); +}; export const numberFormatter = new Intl.NumberFormat('en-US', { minimumFractionDigits: 0, diff --git a/plugins/cost-insights/src/utils/graphs.test.ts b/plugins/cost-insights/src/utils/graphs.test.ts new file mode 100644 index 0000000000..72749d7682 --- /dev/null +++ b/plugins/cost-insights/src/utils/graphs.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { formatGraphValue, tooltipItemOf } from './graphs'; +import { DataKey } from '../types'; +import { createCurrencyFormat } from './currency'; + +describe('graphs', () => { + it('formatGraphValue', () => { + expect(formatGraphValue(createCurrencyFormat('SEK'))(1000, 0)).toEqual( + 'SEK 1,000', + ); + expect(formatGraphValue(createCurrencyFormat('EUR'))(1000, 0)).toEqual( + '€1,000', + ); + expect(formatGraphValue(createCurrencyFormat('USD'))(1000, 0)).toEqual( + '$1,000', + ); + }); + it('tooltipItemOf', () => { + expect( + tooltipItemOf(createCurrencyFormat('EUR'))({ + value: '1000', + color: 'red', + dataKey: DataKey.Current, + name: 'Kubernetes', + }), + ).toEqual({ + fill: 'red', + label: 'Kubernetes', + value: '€1,000.00', + }); + }); +}); diff --git a/plugins/cost-insights/src/utils/graphs.ts b/plugins/cost-insights/src/utils/graphs.ts index 172f6cd7e3..896d09f865 100644 --- a/plugins/cost-insights/src/utils/graphs.ts +++ b/plugins/cost-insights/src/utils/graphs.ts @@ -23,44 +23,43 @@ import { lengthyCurrencyFormatter, } from './formatters'; -export function formatGraphValue( - value: number, - _index: number, - format?: string, -) { - if (format === 'number') { - return value.toLocaleString(); - } +export const formatGraphValue = + (baseCurrency: Intl.NumberFormat) => + (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) => { - const value = - typeof payload.value === 'number' - ? currencyFormatter.format(payload.value) - : payload.value; - const fill = payload.color as string; +export const tooltipItemOf = + (baseCurrency: Intl.NumberFormat) => (payload: Payload) => { + const value = + payload.value && !isNaN(Number(payload.value)) + ? baseCurrency.format(Number(payload.value)) + : payload.value; + const fill = payload.color as string; - switch (payload.dataKey) { - case DataKey.Current: - case DataKey.Previous: - return { - label: payload.name, - value: value, - fill: fill, - }; - default: - return null; - } -}; + switch (payload.dataKey) { + case DataKey.Current: + case DataKey.Previous: + return { + label: payload.name, + value: value, + fill: fill, + }; + default: + return null; + } + }; export const resourceOf = (entity: Entity | AlertCost): ResourceData => ({ name: entity.id,