Merge pull request #3268 from backstage/ryanv-cost-insights-custom-toolips
Ryanv cost insights custom toolips
This commit is contained in:
@@ -19,9 +19,9 @@ import {
|
||||
Alert,
|
||||
Cost,
|
||||
Duration,
|
||||
Entity,
|
||||
Group,
|
||||
Project,
|
||||
ProductCost,
|
||||
Maybe,
|
||||
MetricData,
|
||||
} from '../types';
|
||||
@@ -138,7 +138,7 @@ export type CostInsightsApi = {
|
||||
*
|
||||
* @param options Options to use when fetching insights for a particular cloud product and interval timeframe.
|
||||
*/
|
||||
getProductInsights(options: ProductInsightsOptions): Promise<ProductCost>;
|
||||
getProductInsights(options: ProductInsightsOptions): Promise<Entity>;
|
||||
/**
|
||||
* Get current cost alerts for a given group. These show up as Action Items for the group on the
|
||||
* Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure
|
||||
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
DateAggregation,
|
||||
DEFAULT_DATE_FORMAT,
|
||||
Duration,
|
||||
Entity,
|
||||
Group,
|
||||
MetricData,
|
||||
ProductCost,
|
||||
Project,
|
||||
ProjectGrowthData,
|
||||
Trendline,
|
||||
@@ -194,8 +194,9 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
|
||||
async getProductInsights(
|
||||
productInsightsOptions: ProductInsightsOptions,
|
||||
): Promise<ProductCost> {
|
||||
): Promise<Entity> {
|
||||
const projectProductInsights = await this.request(productInsightsOptions, {
|
||||
id: productInsightsOptions.product,
|
||||
aggregation: [80_000, 110_000],
|
||||
change: {
|
||||
ratio: 0.375,
|
||||
@@ -205,69 +206,152 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
{
|
||||
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
|
||||
aggregation: [45_000, 50_000],
|
||||
change: {
|
||||
ratio: 0.111,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 20_000],
|
||||
change: {
|
||||
ratio: 0.333,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [20_000, 30_000],
|
||||
change: {
|
||||
ratio: 0.5,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const productInsights: Entity = await this.request(productInsightsOptions, {
|
||||
id: productInsightsOptions.product,
|
||||
aggregation: [200_000, 250_000],
|
||||
change: {
|
||||
ratio: 0.2,
|
||||
amount: 50_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
|
||||
aggregation: [36_000, 42_000],
|
||||
change: {
|
||||
ratio: 0.1666,
|
||||
amount: 6_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 20_000],
|
||||
change: {
|
||||
ratio: -0.33333333,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [20_000, 30_000],
|
||||
change: {
|
||||
ratio: 0.5,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [18_000, 25_000],
|
||||
change: {
|
||||
ratio: 0.38,
|
||||
amount: 7_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-d',
|
||||
aggregation: [15_000, 30_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 15_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-e',
|
||||
aggregation: [0, 10_000],
|
||||
entities: [],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'entity-f',
|
||||
aggregation: [17_000, 19_000],
|
||||
change: {
|
||||
ratio: 0.118,
|
||||
amount: 2_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-g',
|
||||
aggregation: [80_000, 60_000],
|
||||
change: {
|
||||
ratio: -0.25,
|
||||
amount: -20_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'vCPU Time Batch Belgium',
|
||||
aggregation: [15_000, 15_000],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'RAM Time Belgium',
|
||||
aggregation: [15_000, 30_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 15_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'Local Disk Time PD Standard Belgium',
|
||||
aggregation: [50_000, 15_000],
|
||||
change: {
|
||||
ratio: -0.7,
|
||||
amount: -35_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const productInsights: ProductCost = await this.request(
|
||||
productInsightsOptions,
|
||||
{
|
||||
aggregation: [200_000, 250_000],
|
||||
change: {
|
||||
ratio: 0.2,
|
||||
amount: 50_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
|
||||
aggregation: [15_000, 30_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 20_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [20_000, 30_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [18_000, 25_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-d',
|
||||
aggregation: [36_000, 42_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-e',
|
||||
aggregation: [0, 10_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-f',
|
||||
aggregation: [17_000, 19_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-g',
|
||||
aggregation: [49_000, 30_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-h',
|
||||
aggregation: [0, 34_000],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return productInsightsOptions.project
|
||||
? projectProductInsights
|
||||
|
||||
@@ -15,56 +15,40 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TooltipPayload } from 'recharts';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { BarChart, BarChartProps } from './BarChart';
|
||||
import { BarChartData, ResourceData } from '../../types';
|
||||
import { ResourceData } from '../../types';
|
||||
import { createMockEntity } from '../../utils/mockData';
|
||||
import { resourceSort } from '../../utils/sort';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { TooltipItemProps } from '../Tooltip';
|
||||
import { costInsightsLightTheme } from '../../utils/styles';
|
||||
|
||||
// Disable responsive container console warnings generated by recharts; doesn't disable React warnings.
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const MockEntities = [...Array(10)].map((_, index) =>
|
||||
createMockEntity(() => ({
|
||||
createMockEntity(mock => ({
|
||||
...mock,
|
||||
id: `test-id-${index + 1}`,
|
||||
// grow resource costs linearly for testing
|
||||
aggregation: [index * 1000, (index + 1) * 1000],
|
||||
})),
|
||||
);
|
||||
|
||||
const MockBarChartData: BarChartData = {
|
||||
previousFill: costInsightsLightTheme.palette.yellow,
|
||||
currentFill: costInsightsLightTheme.palette.darkBlue,
|
||||
previousName: 'Last Period',
|
||||
currentName: 'Current Period',
|
||||
};
|
||||
|
||||
const MockResources: ResourceData[] = MockEntities.map(entity => ({
|
||||
name: entity.id,
|
||||
previous: entity.aggregation[0],
|
||||
current: entity.aggregation[1],
|
||||
}));
|
||||
|
||||
const MockTooltipItem = (payload: TooltipPayload): TooltipItemProps => ({
|
||||
label: payload.name,
|
||||
value: payload.value as string,
|
||||
fill: payload.fill as string,
|
||||
});
|
||||
|
||||
const renderWithProps = ({
|
||||
responsive = false,
|
||||
displayAmount = 6,
|
||||
barChartData = MockBarChartData,
|
||||
getTooltipItem = MockTooltipItem,
|
||||
resources = MockResources,
|
||||
}: BarChartProps) => {
|
||||
return renderInTestApp(
|
||||
<BarChart
|
||||
responsive={responsive}
|
||||
displayAmount={displayAmount}
|
||||
barChartData={barChartData}
|
||||
getTooltipItem={getTooltipItem}
|
||||
resources={resources}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -20,22 +20,21 @@ import {
|
||||
BarChart as RechartsBarChart,
|
||||
CartesianGrid,
|
||||
ContentRenderer,
|
||||
TooltipProps,
|
||||
TooltipProps as RechartsTooltipProps,
|
||||
RechartsFunction,
|
||||
ResponsiveContainer,
|
||||
Tooltip as RechartsTooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
TooltipPayload,
|
||||
} from 'recharts';
|
||||
import { Box, useTheme } from '@material-ui/core';
|
||||
import { BarChartTick } from './BarChartTick';
|
||||
import { BarChartStepper } from './BarChartStepper';
|
||||
import { Tooltip, TooltipItemProps } from '../Tooltip';
|
||||
|
||||
import { BarChartTooltip } from './BarChartTooltip';
|
||||
import { BarChartTooltipItem } from './BarChartTooltipItem';
|
||||
import { currencyFormatter } from '../../utils/formatters';
|
||||
import {
|
||||
BarChartData,
|
||||
Maybe,
|
||||
ResourceData,
|
||||
DataKey,
|
||||
CostInsightsTheme,
|
||||
@@ -43,28 +42,58 @@ import {
|
||||
import { notEmpty } from '../../utils/assert';
|
||||
import { useBarChartStyles } from '../../utils/styles';
|
||||
import { resourceSort } from '../../utils/sort';
|
||||
import { isInvalid, titleOf, tooltipItemOf } from '../../utils/graphs';
|
||||
|
||||
export const defaultTooltip: ContentRenderer<RechartsTooltipProps> = ({
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export type BarChartProps = {
|
||||
resources: ResourceData[];
|
||||
responsive?: boolean;
|
||||
displayAmount?: number;
|
||||
barChartData: BarChartData;
|
||||
getTooltipItem: (payload: TooltipPayload) => Maybe<TooltipItemProps>;
|
||||
resources: ResourceData[];
|
||||
options?: Partial<BarChartData>;
|
||||
tooltip?: ContentRenderer<RechartsTooltipProps>;
|
||||
onClick?: RechartsFunction;
|
||||
onMouseMove?: RechartsFunction;
|
||||
};
|
||||
|
||||
export const BarChart = ({
|
||||
resources,
|
||||
responsive = true,
|
||||
displayAmount = 6,
|
||||
barChartData,
|
||||
getTooltipItem,
|
||||
resources,
|
||||
options = {},
|
||||
tooltip = defaultTooltip,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
}: BarChartProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const styles = useBarChartStyles(theme);
|
||||
const [activeChart, setActiveChart] = useState(false);
|
||||
const [stepWindow, setStepWindow] = useState(() => [0, displayAmount]);
|
||||
|
||||
const { previousFill, currentFill, previousName, currentName } = barChartData;
|
||||
const data = Object.assign(
|
||||
{
|
||||
previousFill: theme.palette.lightBlue,
|
||||
currentFill: theme.palette.darkBlue,
|
||||
previousName: 'Previous',
|
||||
currentName: 'Current',
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const [stepStart, stepEnd] = stepWindow;
|
||||
const steps = Math.ceil(resources.length / displayAmount);
|
||||
@@ -95,24 +124,11 @@ export const BarChart = ({
|
||||
[setStepWindow, resources, displayAmount],
|
||||
);
|
||||
|
||||
const handleChartEnter = () => setActiveChart(true);
|
||||
|
||||
const handleChartLeave = () => setActiveChart(false);
|
||||
|
||||
const renderTooltipContent: ContentRenderer<TooltipProps> = ({
|
||||
label,
|
||||
payload,
|
||||
}) => {
|
||||
if (!(payload && typeof label === 'string')) return [null, null];
|
||||
const items = payload.map(getTooltipItem).filter(notEmpty);
|
||||
return <Tooltip label={label} items={items} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
onMouseLeave={handleChartLeave}
|
||||
onMouseEnter={handleChartEnter}
|
||||
onMouseLeave={() => setActiveChart(false)}
|
||||
onMouseEnter={() => setActiveChart(true)}
|
||||
data-testid="bar-chart-wrapper"
|
||||
>
|
||||
{/* Setting fixed values for height and width generates a console warning in testing but enables ResponsiveContainer to render its children. */}
|
||||
@@ -121,16 +137,22 @@ export const BarChart = ({
|
||||
width={responsive ? '100%' : styles.container.width}
|
||||
>
|
||||
<RechartsBarChart
|
||||
style={{ cursor: onClick ? 'pointer' : null }}
|
||||
onClick={onClick}
|
||||
onMouseMove={onMouseMove}
|
||||
data={sortedResources}
|
||||
margin={styles.barChart.margin}
|
||||
barSize={45}
|
||||
data-testid="bar-chart"
|
||||
>
|
||||
<RechartsTooltip
|
||||
cursor={styles.cursor}
|
||||
animationDuration={100}
|
||||
content={renderTooltipContent}
|
||||
/>
|
||||
{tooltip && (
|
||||
<RechartsTooltip
|
||||
filterNull
|
||||
cursor={styles.cursor}
|
||||
animationDuration={100}
|
||||
content={tooltip}
|
||||
/>
|
||||
)}
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
stroke={styles.cartesianGrid.stroke}
|
||||
@@ -145,18 +167,18 @@ export const BarChart = ({
|
||||
<YAxis
|
||||
tickFormatter={currencyFormatter.format}
|
||||
domain={[() => 0, globalResourcesMax]}
|
||||
tick={{ fill: styles.axis.fill }}
|
||||
tick={styles.axis}
|
||||
/>
|
||||
<Bar
|
||||
dataKey={DataKey.Previous}
|
||||
name={previousName}
|
||||
fill={previousFill}
|
||||
name={data.previousName}
|
||||
fill={data.previousFill}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey={DataKey.Current}
|
||||
name={currentName}
|
||||
fill={currentFill}
|
||||
name={data.currentName}
|
||||
fill={data.currentFill}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import { useBarChartLabelStyles } from '../../utils/styles';
|
||||
|
||||
type BarChartLabel = {
|
||||
@@ -23,6 +23,7 @@ type BarChartLabel = {
|
||||
y: number;
|
||||
height: number;
|
||||
width: number;
|
||||
details?: JSX.Element;
|
||||
};
|
||||
|
||||
export const BarChartLabel = ({
|
||||
@@ -30,11 +31,11 @@ export const BarChartLabel = ({
|
||||
y,
|
||||
height,
|
||||
width,
|
||||
details,
|
||||
children,
|
||||
}: PropsWithChildren<BarChartLabel>) => {
|
||||
const classes = useBarChartLabelStyles();
|
||||
const translateX = width * -0.5;
|
||||
const childArray = React.Children.toArray(children);
|
||||
|
||||
return (
|
||||
<foreignObject
|
||||
@@ -46,8 +47,10 @@ export const BarChartLabel = ({
|
||||
width={width}
|
||||
>
|
||||
<Box display="flex" flexDirection="column" justifyContent="center">
|
||||
<b className={classes.label}>{childArray[0]}</b>
|
||||
{childArray.slice(1)}
|
||||
<Typography className={classes.label} gutterBottom>
|
||||
{children}
|
||||
</Typography>
|
||||
{details}
|
||||
</Box>
|
||||
</foreignObject>
|
||||
);
|
||||
|
||||
+6
-11
@@ -15,20 +15,15 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { UnlabeledDataflowBarChartLegend } from './UnlabeledDataflowBarChartLegend';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { BarChartLegend } from './BarChartLegend';
|
||||
|
||||
describe('<UnlabeledDataflowBarChartLegend />', () => {
|
||||
it('Displays the correct text', async () => {
|
||||
describe('<BarChartLegend />', () => {
|
||||
it(`Should display the correct cost start and end`, async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<UnlabeledDataflowBarChartLegend
|
||||
unlabeledCost={9842.822}
|
||||
labeledCost={2309.1211}
|
||||
/>,
|
||||
<BarChartLegend costStart={1000} costEnd={5000} />,
|
||||
);
|
||||
expect(rendered.getByText('Total Unlabeled Cost')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Total Labeled Cost')).toBeInTheDocument();
|
||||
expect(rendered.getByText('$9,843')).toBeInTheDocument();
|
||||
expect(rendered.getByText('$2,309')).toBeInTheDocument();
|
||||
expect(rendered.getByText(/\$1,000/)).toBeInTheDocument();
|
||||
expect(rendered.queryByText(/\$5,000/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+31
-20
@@ -14,47 +14,58 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { Box, useTheme } from '@material-ui/core';
|
||||
import { LegendItem } from '../LegendItem';
|
||||
import { CostGrowth } from '../CostGrowth';
|
||||
import { currencyFormatter } from '../../utils/formatters';
|
||||
import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types';
|
||||
import { CostInsightsTheme } from '../../types';
|
||||
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
|
||||
|
||||
export type ResourceGrowthBarChartLegendProps = {
|
||||
change: ChangeStatistic;
|
||||
duration: Duration;
|
||||
type BarChartLegendOptions = {
|
||||
previousName: string;
|
||||
previousFill: string;
|
||||
currentName: string;
|
||||
costStart: number;
|
||||
costEnd: number;
|
||||
currentFill: string;
|
||||
};
|
||||
|
||||
export const ResourceGrowthBarChartLegend = ({
|
||||
change,
|
||||
duration,
|
||||
previousName,
|
||||
currentName,
|
||||
export type BarChartLegendProps = {
|
||||
costStart: number;
|
||||
costEnd: number;
|
||||
options?: Partial<BarChartLegendOptions>;
|
||||
};
|
||||
|
||||
export const BarChartLegend = ({
|
||||
costStart,
|
||||
costEnd,
|
||||
}: ResourceGrowthBarChartLegendProps) => {
|
||||
options = {},
|
||||
children,
|
||||
}: PropsWithChildren<BarChartLegendProps>) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const classes = useStyles();
|
||||
|
||||
const data = Object.assign(
|
||||
{
|
||||
previousName: 'Previous',
|
||||
previousFill: theme.palette.lightBlue,
|
||||
currentName: 'Current',
|
||||
currentFill: theme.palette.darkBlue,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="row">
|
||||
<Box className={classes.legend} display="flex" flexDirection="row">
|
||||
<Box marginRight={2}>
|
||||
<LegendItem title={previousName} markerColor={theme.palette.lightBlue}>
|
||||
<LegendItem title={data.previousName} markerColor={data.previousFill}>
|
||||
{currencyFormatter.format(costStart)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
<Box marginRight={2}>
|
||||
<LegendItem title={currentName} markerColor={theme.palette.darkBlue}>
|
||||
<LegendItem title={data.currentName} markerColor={data.currentFill}>
|
||||
{currencyFormatter.format(costEnd)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
<LegendItem title={`Cost ${change.ratio <= 0 ? 'Savings' : 'Growth'}`}>
|
||||
<CostGrowth change={change} duration={duration} />
|
||||
</LegendItem>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,7 @@ type BarChartTickProps = {
|
||||
value: any;
|
||||
};
|
||||
visibleTicksCount: number;
|
||||
details?: JSX.Element;
|
||||
};
|
||||
|
||||
export const BarChartTick = ({
|
||||
@@ -35,11 +36,18 @@ export const BarChartTick = ({
|
||||
width,
|
||||
payload,
|
||||
visibleTicksCount,
|
||||
details,
|
||||
}: BarChartTickProps) => {
|
||||
const gutterWidth = 5;
|
||||
const labelWidth = width / visibleTicksCount - gutterWidth * 2;
|
||||
return (
|
||||
<BarChartLabel x={x} y={y} height={height} width={labelWidth}>
|
||||
<BarChartLabel
|
||||
x={x}
|
||||
y={y}
|
||||
height={height}
|
||||
width={labelWidth}
|
||||
details={details}
|
||||
>
|
||||
{!payload.value ? 'Unlabeled' : payload.value}
|
||||
</BarChartLabel>
|
||||
);
|
||||
|
||||
+7
-14
@@ -16,10 +16,11 @@
|
||||
|
||||
import React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import { BarChartTooltip } from './BarChartTooltip';
|
||||
import { BarChartTooltipItem } from './BarChartTooltipItem';
|
||||
import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider';
|
||||
|
||||
const mockTooltipItems = [
|
||||
const items = [
|
||||
{
|
||||
label: 'Cost',
|
||||
value: '$1,000,000',
|
||||
@@ -32,22 +33,14 @@ const mockTooltipItems = [
|
||||
},
|
||||
];
|
||||
|
||||
describe('<Tooltip/>', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<CostInsightsThemeProvider>
|
||||
<Tooltip label="05/16/2020" items={mockTooltipItems} />
|
||||
</CostInsightsThemeProvider>,
|
||||
);
|
||||
expect(
|
||||
rendered.container.querySelector('.tooltip-content'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
const tooltipItems = () =>
|
||||
items.map(item => <BarChartTooltipItem key={item.label} item={item} />);
|
||||
|
||||
describe('<BarChartTooltip/>', () => {
|
||||
it('formats label and tooltip item text correctly', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<CostInsightsThemeProvider>
|
||||
<Tooltip label="05/16/2020" items={mockTooltipItems} />
|
||||
<BarChartTooltip title="05/16/2020">{tooltipItems}</BarChartTooltip>
|
||||
</CostInsightsThemeProvider>,
|
||||
);
|
||||
expect(rendered.getByText('05/16/2020')).toBeInTheDocument();
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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, { ReactNode, PropsWithChildren } from 'react';
|
||||
import { Box, Divider, Typography } from '@material-ui/core';
|
||||
import { useTooltipStyles as useStyles } from '../../utils/styles';
|
||||
|
||||
export type BarChartTooltipProps = {
|
||||
title: string;
|
||||
content?: ReactNode | string;
|
||||
subtitle?: ReactNode;
|
||||
topRight?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
|
||||
export const BarChartTooltip = ({
|
||||
title,
|
||||
content,
|
||||
subtitle,
|
||||
topRight,
|
||||
actions,
|
||||
children,
|
||||
}: PropsWithChildren<BarChartTooltipProps>) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Box className={classes.tooltip} display="flex" flexDirection="column">
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="baseline"
|
||||
px={2}
|
||||
pt={2}
|
||||
>
|
||||
<Box display="flex" flexDirection="column">
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
{subtitle && (
|
||||
<Typography className={classes.subtitle} variant="subtitle1">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{topRight}
|
||||
</Box>
|
||||
{content && (
|
||||
<Box px={2} pt={2}>
|
||||
<Typography variant="body1" paragraph>
|
||||
{content}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box display="flex" flexDirection="column" p={2}>
|
||||
{children}
|
||||
</Box>
|
||||
{actions && (
|
||||
<>
|
||||
<Divider className={classes.divider} variant="fullWidth" />
|
||||
<Box display="flex" flexDirection="column" p={2}>
|
||||
{actions}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+11
-8
@@ -19,15 +19,18 @@ import { Box, Typography } from '@material-ui/core';
|
||||
import LensIcon from '@material-ui/icons/Lens';
|
||||
import { useTooltipStyles as useStyles } from '../../utils/styles';
|
||||
|
||||
export type TooltipItemProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
export type TooltipItem = {
|
||||
fill: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const TooltipItem = ({ fill, label, value }: TooltipItemProps) => {
|
||||
export type BarChartTooltipItemProps = {
|
||||
item: TooltipItem;
|
||||
};
|
||||
|
||||
export const BarChartTooltipItem = ({ item }: BarChartTooltipItemProps) => {
|
||||
const classes = useStyles();
|
||||
const style = { fill: fill };
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -37,11 +40,11 @@ export const TooltipItem = ({ fill, label, value }: TooltipItemProps) => {
|
||||
>
|
||||
<Box display="flex" alignContent="center" marginRight=".5em">
|
||||
<Box display="flex" alignItems="center" marginRight=".5em">
|
||||
<LensIcon className={classes.lensIcon} style={style} />
|
||||
<LensIcon className={classes.lensIcon} style={{ fill: item.fill }} />
|
||||
</Box>
|
||||
<Typography>{label}</Typography>
|
||||
<Typography>{item.label}</Typography>
|
||||
</Box>
|
||||
<Typography display="block">{value}</Typography>
|
||||
<Typography display="block">{item.value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -16,3 +16,12 @@
|
||||
|
||||
export { BarChart } from './BarChart';
|
||||
export type { BarChartProps } from './BarChart';
|
||||
export { BarChartLegend } from './BarChartLegend';
|
||||
export type { BarChartLegendProps } from './BarChartLegend';
|
||||
export { BarChartTooltip } from './BarChartTooltip';
|
||||
export type { BarChartTooltipProps } from './BarChartTooltip';
|
||||
export { BarChartTooltipItem } from './BarChartTooltipItem';
|
||||
export type {
|
||||
TooltipItem,
|
||||
BarChartTooltipItemProps,
|
||||
} from './BarChartTooltipItem';
|
||||
|
||||
@@ -49,7 +49,7 @@ export const CostGrowth = ({ change, duration }: CostGrowthProps) => {
|
||||
const converted = amount / (currency.rate ?? rate);
|
||||
|
||||
// Determine if growth is significant enough to highlight
|
||||
const growth = growthOf(engineers, change.ratio);
|
||||
const growth = growthOf(change.ratio, engineers);
|
||||
const classes = classnames({
|
||||
[styles.excess]: growth === GrowthType.Excess,
|
||||
[styles.savings]: growth === GrowthType.Savings,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { CostGrowthIndicator } from './CostGrowthIndicator';
|
||||
import { ChangeThreshold, EngineerThreshold } from '../../types';
|
||||
|
||||
describe.each`
|
||||
ratio | amount | ariaLabel
|
||||
${-0.1} | ${undefined} | ${'savings'}
|
||||
${0.01} | ${undefined} | ${'excess'}
|
||||
${ChangeThreshold.lower} | ${EngineerThreshold} | ${'savings'}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${'savings'}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${'savings'}
|
||||
${ChangeThreshold.upper} | ${EngineerThreshold} | ${'excess'}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${'excess'}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${'excess'}
|
||||
`('growthOf', ({ ratio, amount, ariaLabel }) => {
|
||||
it(`should display the correct indicator for ${ariaLabel}`, async () => {
|
||||
const { getByLabelText } = await renderInTestApp(
|
||||
<CostGrowthIndicator ratio={ratio} amount={amount} />,
|
||||
);
|
||||
expect(getByLabelText(ariaLabel)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
ratio | amount
|
||||
${0} | ${undefined}
|
||||
${ChangeThreshold.lower} | ${0}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1}
|
||||
`('growthOf', ({ ratio, amount }) => {
|
||||
it('should display the correct indicator for negligible growth', async () => {
|
||||
const { queryByLabelText } = await renderInTestApp(
|
||||
<CostGrowthIndicator ratio={ratio} amount={amount} />,
|
||||
);
|
||||
expect(queryByLabelText('savings')).not.toBeInTheDocument();
|
||||
expect(queryByLabelText('excess')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Typography, TypographyProps } from '@material-ui/core';
|
||||
import { default as ArrowDropUp } from '@material-ui/icons/ArrowDropUp';
|
||||
import { default as ArrowDropDown } from '@material-ui/icons/ArrowDropDown';
|
||||
import { growthOf } from '../../utils/change';
|
||||
import { GrowthType } from '../../types';
|
||||
import { useCostGrowthStyles as useStyles } from '../../utils/styles';
|
||||
|
||||
export type CostGrowthIndicatorProps = TypographyProps & {
|
||||
ratio: number;
|
||||
amount?: number;
|
||||
formatter?: (amount: number) => string;
|
||||
};
|
||||
|
||||
export const CostGrowthIndicator = ({
|
||||
ratio,
|
||||
amount,
|
||||
formatter,
|
||||
className,
|
||||
...props
|
||||
}: CostGrowthIndicatorProps) => {
|
||||
const classes = useStyles();
|
||||
const growth = growthOf(ratio, amount);
|
||||
|
||||
const classNames = classnames(classes.indicator, className, {
|
||||
[classes.savings]: growth === GrowthType.Savings,
|
||||
[classes.excess]: growth === GrowthType.Excess,
|
||||
});
|
||||
|
||||
// Display cost as a factor of engineer cost growth and percentage growth
|
||||
if (typeof amount === 'number') {
|
||||
return (
|
||||
<Typography className={classNames} component="span" {...props}>
|
||||
{formatter ? formatter(amount) : amount}
|
||||
{growth === GrowthType.Savings && (
|
||||
<ArrowDropDown aria-label="savings" />
|
||||
)}
|
||||
{growth === GrowthType.Excess && <ArrowDropUp aria-label="excess" />}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
// Display cost as a factor of percent change
|
||||
return (
|
||||
<Typography className={classNames} component="span" {...props}>
|
||||
{formatter ? formatter(ratio) : ratio}
|
||||
{ratio < 0 && <ArrowDropDown aria-label="savings" />}
|
||||
{ratio > 0 && <ArrowDropUp aria-label="excess" />}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
@@ -16,3 +16,5 @@
|
||||
|
||||
export { CostGrowth } from './CostGrowth';
|
||||
export type { CostGrowthProps } from './CostGrowth';
|
||||
export { CostGrowthIndicator } from './CostGrowthIndicator';
|
||||
export type { CostGrowthIndicatorProps } from './CostGrowthIndicator';
|
||||
|
||||
@@ -98,7 +98,7 @@ export const CostOverviewCard = ({
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="flex-end" alignItems="center">
|
||||
{config.metrics.length > 1 && (
|
||||
{config.metrics.length && (
|
||||
<MetricSelect
|
||||
metric={filters.metric}
|
||||
metrics={config.metrics}
|
||||
|
||||
@@ -14,32 +14,38 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
import {
|
||||
ComposedChart,
|
||||
ContentRenderer,
|
||||
TooltipProps,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Tooltip as RechartsTooltip,
|
||||
CartesianGrid,
|
||||
Area,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
TooltipPayload,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartData,
|
||||
Cost,
|
||||
DEFAULT_DATE_FORMAT,
|
||||
Maybe,
|
||||
Metric,
|
||||
MetricData,
|
||||
CostInsightsTheme,
|
||||
} from '../../types';
|
||||
import {
|
||||
BarChartTooltip as Tooltip,
|
||||
BarChartTooltipItem as TooltipItem,
|
||||
} from '../BarChart';
|
||||
import {
|
||||
overviewGraphTickFormatter,
|
||||
formatGraphValue,
|
||||
isInvalid,
|
||||
} from '../../utils/graphs';
|
||||
import { CostOverviewTooltip } from './CostOverviewTooltip';
|
||||
import { TooltipItemProps } from '../Tooltip';
|
||||
import { useCostOverviewStyles as useStyles } from '../../utils/styles';
|
||||
import { groupByDate, toDataMax, trendFrom } from '../../utils/charts';
|
||||
import { aggregationSort } from '../../utils/sort';
|
||||
@@ -91,22 +97,39 @@ export const CostOverviewChart = ({
|
||||
: {}),
|
||||
}));
|
||||
|
||||
function tooltipFormatter(payload: TooltipPayload): TooltipItemProps {
|
||||
return {
|
||||
label:
|
||||
payload.dataKey === data.dailyCost.dataKey
|
||||
? data.dailyCost.name
|
||||
: data.metric.name,
|
||||
value:
|
||||
payload.dataKey === data.dailyCost.dataKey
|
||||
? formatGraphValue(payload.value as number, data.dailyCost.format)
|
||||
: formatGraphValue(payload.value as number, data.metric.format),
|
||||
fill:
|
||||
payload.dataKey === data.dailyCost.dataKey
|
||||
? theme.palette.blue
|
||||
: theme.palette.magenta,
|
||||
};
|
||||
}
|
||||
const tooltipRenderer: ContentRenderer<TooltipProps> = ({
|
||||
label,
|
||||
payload = [],
|
||||
}) => {
|
||||
if (isInvalid({ label, payload })) return null;
|
||||
|
||||
const dataKeys = [data.dailyCost.dataKey, data.metric.dataKey];
|
||||
const title = moment(label).format(DEFAULT_DATE_FORMAT);
|
||||
const items = payload
|
||||
.filter(p => dataKeys.includes(p.dataKey as string))
|
||||
.map(p => ({
|
||||
label:
|
||||
p.dataKey === data.dailyCost.dataKey
|
||||
? data.dailyCost.name
|
||||
: data.metric.name,
|
||||
value:
|
||||
p.dataKey === data.dailyCost.dataKey
|
||||
? formatGraphValue(p.value as number, data.dailyCost.format)
|
||||
: formatGraphValue(p.value as number, data.metric.format),
|
||||
fill:
|
||||
p.dataKey === data.dailyCost.dataKey
|
||||
? theme.palette.blue
|
||||
: theme.palette.magenta,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
{items.map((item, index) => (
|
||||
<TooltipItem key={`${item.label}-${index}`} item={item} />
|
||||
))}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
@@ -168,15 +191,7 @@ export const CostOverviewChart = ({
|
||||
yAxisId={data.metric.dataKey}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
<CostOverviewTooltip
|
||||
dataKeys={[data.dailyCost.dataKey, data.metric.dataKey]}
|
||||
format={tooltipFormatter}
|
||||
/>
|
||||
}
|
||||
animationDuration={100}
|
||||
/>
|
||||
<RechartsTooltip content={tooltipRenderer} animationDuration={100} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import moment from 'moment';
|
||||
import { TooltipPayload, TooltipProps } from 'recharts';
|
||||
import { Tooltip, TooltipItemProps } from '../../components/Tooltip';
|
||||
import { DEFAULT_DATE_FORMAT } from '../../types';
|
||||
|
||||
export type CostOverviewTooltipProps = TooltipProps & {
|
||||
dataKeys: Array<string>;
|
||||
format: (payload: TooltipPayload) => TooltipItemProps;
|
||||
};
|
||||
|
||||
export const CostOverviewTooltip = ({
|
||||
label,
|
||||
payload,
|
||||
dataKeys,
|
||||
format,
|
||||
}: CostOverviewTooltipProps) => {
|
||||
const tooltipLabel = moment(label).format(DEFAULT_DATE_FORMAT);
|
||||
const items = payload
|
||||
?.filter((p: TooltipPayload) => dataKeys.includes(p.dataKey as string))
|
||||
.map(p => format(p));
|
||||
return <Tooltip label={tooltipLabel} items={items} />;
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { useConfig } from '../../hooks';
|
||||
|
||||
export const ProductInsights = ({}) => {
|
||||
const config = useConfig();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mt={0} mb={5} textAlign="center">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { Dialog, IconButton, Typography } from '@material-ui/core';
|
||||
import { default as CloseButton } from '@material-ui/icons/Close';
|
||||
import { CostGrowthIndicator } from '../CostGrowth';
|
||||
import { currencyFormatter, formatPercent } from '../../utils/formatters';
|
||||
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
|
||||
import { BarChartOptions, Entity } from '../../types';
|
||||
|
||||
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
return function render(rowData: {}): JSX.Element {
|
||||
const row = rowData as RowData;
|
||||
const rowStyles = classnames(classes.row, {
|
||||
[classes.rowTotal]: row.id === 'total',
|
||||
[classes.colFirst]: col === 'sku',
|
||||
[classes.colLast]: col === 'ratio',
|
||||
});
|
||||
|
||||
switch (col) {
|
||||
case 'previous':
|
||||
case 'current':
|
||||
return (
|
||||
<Typography className={rowStyles}>
|
||||
{currencyFormatter.format(row[col])}
|
||||
</Typography>
|
||||
);
|
||||
case 'ratio':
|
||||
return (
|
||||
<CostGrowthIndicator
|
||||
className={rowStyles}
|
||||
ratio={row.ratio}
|
||||
formatter={amount => formatPercent(Math.abs(amount))}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Typography className={rowStyles}>{row.sku}</Typography>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
function createSorter(field?: keyof Omit<RowData, 'id'>) {
|
||||
return function rowSort(data1: {}, data2: {}): number {
|
||||
const a = data1 as RowData;
|
||||
const b = data2 as RowData;
|
||||
if (a.id === 'total') return 1;
|
||||
if (b.id === 'total') return 1;
|
||||
if (field === 'sku') return a.sku.localeCompare(b.sku);
|
||||
|
||||
return field
|
||||
? a[field] - b[field]
|
||||
: b.previous + b.current - (a.previous - a.current);
|
||||
};
|
||||
}
|
||||
|
||||
const defaultEntity: Entity = {
|
||||
id: null,
|
||||
aggregation: [0, 0],
|
||||
change: { ratio: 0, amount: 0 },
|
||||
entities: [],
|
||||
};
|
||||
|
||||
type RowData = {
|
||||
id: string;
|
||||
sku: string;
|
||||
previous: number;
|
||||
current: number;
|
||||
ratio: number;
|
||||
};
|
||||
|
||||
type ProductEntityDialogOptions = Partial<
|
||||
Pick<BarChartOptions, 'previousName' | 'currentName'>
|
||||
>;
|
||||
|
||||
type ProductEntityDialogProps = {
|
||||
open: boolean;
|
||||
entity?: Entity;
|
||||
options?: ProductEntityDialogOptions;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const ProductEntityDialog = ({
|
||||
open,
|
||||
entity = defaultEntity,
|
||||
options = {},
|
||||
onClose,
|
||||
}: ProductEntityDialogProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const data = Object.assign(
|
||||
{
|
||||
previousName: 'Previous',
|
||||
currentName: 'Current',
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const firstColClasses = classnames(classes.column, classes.colFirst);
|
||||
const lastColClasses = classnames(classes.column, classes.colLast);
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
field: 'sku',
|
||||
title: <Typography className={firstColClasses}>SKU</Typography>,
|
||||
render: createRenderer('sku', classes),
|
||||
customSort: createSorter('sku'),
|
||||
},
|
||||
{
|
||||
field: 'previous',
|
||||
title: (
|
||||
<Typography className={classes.column}>{data.previousName}</Typography>
|
||||
),
|
||||
align: 'right',
|
||||
render: createRenderer('previous', classes),
|
||||
customSort: createSorter('previous'),
|
||||
},
|
||||
{
|
||||
field: 'current',
|
||||
title: (
|
||||
<Typography className={classes.column}>{data.currentName}</Typography>
|
||||
),
|
||||
align: 'right',
|
||||
render: createRenderer('current', classes),
|
||||
customSort: createSorter('current'),
|
||||
},
|
||||
{
|
||||
field: 'ratio',
|
||||
title: <Typography className={lastColClasses}>M/M</Typography>,
|
||||
align: 'right',
|
||||
render: createRenderer('ratio', classes),
|
||||
customSort: createSorter('ratio'),
|
||||
},
|
||||
];
|
||||
|
||||
const rowData: RowData[] = entity.entities
|
||||
.map(e => ({
|
||||
id: e.id || 'Unknown',
|
||||
sku: e.id || 'Unknown',
|
||||
previous: e.aggregation[0],
|
||||
current: e.aggregation[1],
|
||||
ratio: e.change.ratio,
|
||||
}))
|
||||
.concat({
|
||||
id: 'total',
|
||||
sku: 'Total',
|
||||
previous: entity.aggregation[0],
|
||||
current: entity.aggregation[1],
|
||||
ratio: entity.change.ratio,
|
||||
})
|
||||
.sort(createSorter());
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} scroll="body" fullWidth maxWidth="lg">
|
||||
<IconButton className={classes.closeButton} onClick={onClose}>
|
||||
<CloseButton />
|
||||
</IconButton>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={rowData}
|
||||
title={entity.id || 'Unknown'}
|
||||
subtitle="Resource breakdown"
|
||||
options={{
|
||||
paging: false,
|
||||
search: false,
|
||||
hideFilterIcons: true,
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+19
-19
@@ -20,7 +20,6 @@ import { ProductInsightsCard } from './ProductInsightsCard';
|
||||
import { CostInsightsApi } from '../../api';
|
||||
import {
|
||||
createMockEntity,
|
||||
createMockProductCost,
|
||||
mockDefaultLoadingState,
|
||||
MockComputeEngine,
|
||||
MockProductFilters,
|
||||
@@ -35,16 +34,14 @@ import {
|
||||
MockScrollProvider,
|
||||
MockLoadingProvider,
|
||||
} from '../../utils/tests';
|
||||
import { Duration, Product, ProductCost, ProductPeriod } from '../../types';
|
||||
import { Duration, Entity, Product, ProductPeriod } from '../../types';
|
||||
|
||||
const costInsightsApi = (
|
||||
productCost: ProductCost,
|
||||
): Partial<CostInsightsApi> => ({
|
||||
getProductInsights: () =>
|
||||
Promise.resolve(productCost) as Promise<ProductCost>,
|
||||
const costInsightsApi = (entity: Entity): Partial<CostInsightsApi> => ({
|
||||
getProductInsights: () => Promise.resolve(entity),
|
||||
});
|
||||
|
||||
const mockProductCost = createMockProductCost(() => ({
|
||||
const mockProductCost = createMockEntity(() => ({
|
||||
id: 'test-id',
|
||||
entities: [],
|
||||
aggregation: [3000, 4000],
|
||||
change: {
|
||||
@@ -54,12 +51,12 @@ const mockProductCost = createMockProductCost(() => ({
|
||||
}));
|
||||
|
||||
const renderProductInsightsCardInTestApp = async (
|
||||
productCost: ProductCost,
|
||||
entity: Entity,
|
||||
product: Product,
|
||||
duration: Duration,
|
||||
) =>
|
||||
await renderInTestApp(
|
||||
<MockCostInsightsApiProvider costInsightsApi={costInsightsApi(productCost)}>
|
||||
<MockCostInsightsApiProvider costInsightsApi={costInsightsApi(entity)}>
|
||||
<MockConfigProvider>
|
||||
<MockLoadingProvider state={mockDefaultLoadingState}>
|
||||
<MockGroupsProvider>
|
||||
@@ -96,27 +93,30 @@ describe('<ProductInsightsCard/>', () => {
|
||||
});
|
||||
|
||||
it('Should render the right subheader for products with cost data', async () => {
|
||||
const productCost = {
|
||||
const entity = {
|
||||
...mockProductCost,
|
||||
entities: [...Array(1000)].map(createMockEntity),
|
||||
};
|
||||
const rendered = await renderProductInsightsCardInTestApp(
|
||||
productCost,
|
||||
entity,
|
||||
MockComputeEngine,
|
||||
Duration.P1M,
|
||||
);
|
||||
const subheader = 'entities, sorted by cost';
|
||||
const subheaderRgx = new RegExp(
|
||||
`${productCost.entities.length} ${subheader}`,
|
||||
);
|
||||
const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`);
|
||||
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render the right subheader if there is no cost data or change data', async () => {
|
||||
const productCost = { entities: [], aggregation: [0, 0] } as ProductCost;
|
||||
const entity: Entity = {
|
||||
id: 'test-id',
|
||||
entities: [],
|
||||
aggregation: [0, 0],
|
||||
change: { ratio: 0, amount: 0 },
|
||||
};
|
||||
const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`;
|
||||
const rendered = await renderProductInsightsCardInTestApp(
|
||||
productCost,
|
||||
entity,
|
||||
MockComputeEngine,
|
||||
Duration.P1M,
|
||||
);
|
||||
@@ -138,12 +138,12 @@ describe('<ProductInsightsCard/>', () => {
|
||||
'Should display the correct relative time',
|
||||
({ duration, periodStartText, periodEndText }) => {
|
||||
it(`Should display the correct relative time for ${duration}`, async () => {
|
||||
const productCost = {
|
||||
const entity = {
|
||||
...mockProductCost,
|
||||
entities: [...Array(3)].map(createMockEntity),
|
||||
};
|
||||
const rendered = await renderProductInsightsCardInTestApp(
|
||||
productCost,
|
||||
entity,
|
||||
MockComputeEngine,
|
||||
duration,
|
||||
);
|
||||
|
||||
@@ -16,12 +16,11 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { InfoCard, useApi } from '@backstage/core';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { costInsightsApiRef } from '../../api';
|
||||
import { ProductInsightsChart } from './ProductInsightsChart';
|
||||
import { PeriodSelect } from '../PeriodSelect';
|
||||
import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart';
|
||||
import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend';
|
||||
import {
|
||||
useFilters,
|
||||
useLastCompleteBillingDate,
|
||||
@@ -30,9 +29,8 @@ import {
|
||||
} from '../../hooks';
|
||||
import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
|
||||
import { mapFiltersToProps, mapLoadingToProps } from './selector';
|
||||
import { Duration, Maybe, Product, ProductCost } from '../../types';
|
||||
import { Duration, Maybe, Product, Entity } from '../../types';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
import { formatPeriod } from '../../utils/formatters';
|
||||
|
||||
type ProductInsightsCardProps = {
|
||||
product: Product;
|
||||
@@ -43,7 +41,7 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
|
||||
const classes = useStyles();
|
||||
const { ScrollAnchor } = useScroll(product.kind);
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const [resource, setResource] = useState<Maybe<ProductCost>>(null);
|
||||
const [entity, setEntity] = useState<Maybe<Entity>>(null);
|
||||
const [error, setError] = useState<Maybe<Error>>(null);
|
||||
|
||||
const { group, product: productFilter, setProduct, project } = useFilters(
|
||||
@@ -57,39 +55,25 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]);
|
||||
|
||||
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 amount = entity?.entities?.length || 0;
|
||||
const hasCostsWithinTimeframe = !!(entity?.change && amount);
|
||||
|
||||
const subheader = hasCostsWithinTimeframe
|
||||
? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost`
|
||||
: null;
|
||||
|
||||
const costStart = resource?.aggregation[0] || 0;
|
||||
const costEnd = resource?.aggregation[1] || 0;
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (loadingProduct) {
|
||||
try {
|
||||
const p: ProductCost = await client.getProductInsights({
|
||||
const e: Entity = await client.getProductInsights({
|
||||
product: product.kind,
|
||||
group: group!,
|
||||
duration: productFilter!.duration,
|
||||
lastCompleteBillingDate,
|
||||
project,
|
||||
});
|
||||
setResource(p);
|
||||
setEntity(e);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
@@ -101,7 +85,7 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
|
||||
}, [
|
||||
client,
|
||||
product,
|
||||
setResource,
|
||||
setEntity,
|
||||
loadingProduct,
|
||||
dispatchLoadingProduct,
|
||||
productFilter,
|
||||
@@ -137,7 +121,7 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -145,23 +129,11 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
|
||||
<InfoCard title={product.name} subheader={subheader} {...infoCardProps}>
|
||||
<ScrollAnchor behavior="smooth" top={-12} />
|
||||
{hasCostsWithinTimeframe ? (
|
||||
<Box display="flex" flexDirection="column">
|
||||
<Box pb={2}>
|
||||
<ResourceGrowthBarChartLegend
|
||||
duration={productFilter.duration}
|
||||
change={resource.change!}
|
||||
previousName={previousName}
|
||||
currentName={currentName}
|
||||
costStart={costStart}
|
||||
costEnd={costEnd}
|
||||
/>
|
||||
</Box>
|
||||
<ResourceGrowthBarChart
|
||||
previousName={previousName}
|
||||
currentName={currentName}
|
||||
resources={resource.entities || []}
|
||||
/>
|
||||
</Box>
|
||||
<ProductInsightsChart
|
||||
billingDate={lastCompleteBillingDate}
|
||||
duration={productFilter.duration}
|
||||
entity={entity}
|
||||
/>
|
||||
) : (
|
||||
<Typography>
|
||||
There are no {product.name} costs within this timeframe for your
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ContentRenderer,
|
||||
TooltipProps as RechartsTooltipProps,
|
||||
RechartsFunction,
|
||||
} from 'recharts';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen';
|
||||
import { LegendItem } from '../LegendItem';
|
||||
import { ProductEntityDialog } from './ProductEntityDialog';
|
||||
import { CostGrowth, CostGrowthIndicator } from '../CostGrowth';
|
||||
import {
|
||||
BarChart,
|
||||
BarChartLegend,
|
||||
BarChartTooltip,
|
||||
BarChartTooltipItem,
|
||||
} from '../BarChart';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
import { findAlways, notEmpty } from '../../utils/assert';
|
||||
import { formatPeriod, formatPercent } from '../../utils/formatters';
|
||||
import {
|
||||
titleOf,
|
||||
tooltipItemOf,
|
||||
resourceOf,
|
||||
isInvalid,
|
||||
isActiveLabel,
|
||||
} from '../../utils/graphs';
|
||||
import {
|
||||
useProductInsightsChartStyles as useStyles,
|
||||
useBarChartLayoutStyles as useLayoutStyles,
|
||||
} from '../../utils/styles';
|
||||
import { BarChartOptions, Duration, Entity, Maybe } from '../../types';
|
||||
|
||||
export type ProductInsightsChartProps = {
|
||||
billingDate: string;
|
||||
entity: Entity;
|
||||
duration: Duration;
|
||||
};
|
||||
|
||||
export const ProductInsightsChart = ({
|
||||
billingDate,
|
||||
entity,
|
||||
duration,
|
||||
}: ProductInsightsChartProps) => {
|
||||
const classes = useStyles();
|
||||
const layoutClasses = useLayoutStyles();
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [isClickable, setClickable] = useState(true);
|
||||
const [selectLabel, setSelected] = useState<Maybe<string>>(null);
|
||||
const [activeLabel, setActive] = useState<Maybe<string>>(null);
|
||||
|
||||
const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`;
|
||||
const costStart = entity.aggregation[0];
|
||||
const costEnd = entity.aggregation[1];
|
||||
const resources = entity.entities.map(resourceOf);
|
||||
|
||||
const options: Partial<BarChartOptions> = {
|
||||
previousName: formatPeriod(duration, billingDate, false),
|
||||
currentName: formatPeriod(duration, billingDate, true),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function toggleModal() {
|
||||
if (selectLabel) {
|
||||
setOpen(true);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleModal();
|
||||
}, [selectLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
// disable click if an entity is unlabeled or if it does not have any skus
|
||||
function toggleClickableEntity() {
|
||||
if (activeLabel) {
|
||||
const hasSkus = entity.entities.find(e => e.id === activeLabel)
|
||||
?.entities.length;
|
||||
if (hasSkus) {
|
||||
setClickable(true);
|
||||
} else {
|
||||
setClickable(false);
|
||||
}
|
||||
} else {
|
||||
setClickable(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleClickableEntity();
|
||||
}, [activeLabel, entity]);
|
||||
|
||||
const onMouseMove: RechartsFunction = (
|
||||
data: Record<'activeLabel', string | undefined>,
|
||||
) => {
|
||||
if (isActiveLabel(data)) {
|
||||
setActive(data.activeLabel!);
|
||||
} else {
|
||||
setActive(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onClick: RechartsFunction = (data: Record<'activeLabel', string>) => {
|
||||
if (isActiveLabel(data)) {
|
||||
setSelected(data.activeLabel);
|
||||
} else {
|
||||
setSelected(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderProductInsightsTooltip: ContentRenderer<RechartsTooltipProps> = ({
|
||||
label,
|
||||
payload = [],
|
||||
}) => {
|
||||
/* Labels and payloads may be undefined or empty */
|
||||
if (isInvalid({ label, payload })) return null;
|
||||
|
||||
/*
|
||||
* recharts coerces null values to strings
|
||||
* entity -> resource -> payload
|
||||
* { id: null } -> { name: null } -> { label: '' }
|
||||
*/
|
||||
const id = label === '' ? null : label;
|
||||
|
||||
const title = titleOf(label);
|
||||
const items = payload.map(tooltipItemOf).filter(notEmpty);
|
||||
|
||||
const activeEntity = findAlways(entity.entities, e => e.id === id);
|
||||
const ratio = activeEntity.change.ratio;
|
||||
const skus = activeEntity.entities;
|
||||
const subtitle = `${skus.length} ${pluralOf(skus.length, 'SKU')}`;
|
||||
|
||||
if (skus.length) {
|
||||
return (
|
||||
<BarChartTooltip
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
topRight={
|
||||
<CostGrowthIndicator
|
||||
className={classes.indicator}
|
||||
ratio={ratio}
|
||||
formatter={formatPercent}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<Box className={classes.actions}>
|
||||
<FullScreenIcon />
|
||||
<Typography>Click for breakdown</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<BarChartTooltipItem key={`${item.label}-${index}`} item={item} />
|
||||
))}
|
||||
</BarChartTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// If an entity doesn't have any skus, there aren't any costs to break down.
|
||||
return (
|
||||
<BarChartTooltip
|
||||
title={title}
|
||||
topRight={
|
||||
<CostGrowthIndicator
|
||||
className={classes.indicator}
|
||||
ratio={ratio}
|
||||
formatter={formatPercent}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
id
|
||||
? null
|
||||
: "This product has costs that are not labeled and therefore can't be attributed to a specific entity."
|
||||
}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<BarChartTooltipItem key={`${item.label}-${index}`} item={item} />
|
||||
))}
|
||||
</BarChartTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const barChartProps = isClickable ? { onClick } : {};
|
||||
|
||||
return (
|
||||
<Box className={layoutClasses.wrapper}>
|
||||
<BarChartLegend costStart={costStart} costEnd={costEnd} options={options}>
|
||||
<LegendItem title={legendTitle}>
|
||||
<CostGrowth change={entity.change} duration={duration} />
|
||||
</LegendItem>
|
||||
</BarChartLegend>
|
||||
<BarChart
|
||||
resources={resources}
|
||||
tooltip={renderProductInsightsTooltip}
|
||||
onMouseMove={onMouseMove}
|
||||
options={options}
|
||||
{...barChartProps}
|
||||
/>
|
||||
{selectLabel && entity.entities.length && (
|
||||
<ProductEntityDialog
|
||||
open={isOpen}
|
||||
onClose={() => setSelected(null)}
|
||||
entity={entity.entities.find(e => e.id === selectLabel)}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export { ProductInsightsCard } from './ProductInsightsCard';
|
||||
export { ProductInsightsChart } from './ProductInsightsChart';
|
||||
|
||||
+3
-28
@@ -15,12 +15,9 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { InfoCard } from '@backstage/core';
|
||||
import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart';
|
||||
import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend';
|
||||
import { Duration, ProjectGrowthData } from '../../types';
|
||||
import { ProjectGrowthAlertChart } from './ProjectGrowthAlertChart';
|
||||
import { ProjectGrowthData } from '../../types';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
|
||||
type ProjectGrowthAlertProps = {
|
||||
@@ -28,39 +25,17 @@ type ProjectGrowthAlertProps = {
|
||||
};
|
||||
|
||||
export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
|
||||
const [costStart, costEnd] = alert.aggregation;
|
||||
|
||||
const subheader = `
|
||||
${alert.products.length} ${pluralOf(alert.products.length, 'product')}${
|
||||
alert.products.length > 1 ? ', sorted by cost' : ''
|
||||
}`;
|
||||
const previousName = moment(alert.periodStart, 'YYYY-[Q]Q').format(
|
||||
'[Q]Q YYYY',
|
||||
);
|
||||
const currentName = moment(alert.periodEnd, 'YYYY-[Q]Q').format('[Q]Q YYYY');
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
title={`Project growth for ${alert.project}`}
|
||||
subheader={subheader}
|
||||
>
|
||||
<Box display="flex" flexDirection="column">
|
||||
<Box pb={2}>
|
||||
<ResourceGrowthBarChartLegend
|
||||
change={alert.change}
|
||||
duration={Duration.P3M}
|
||||
previousName={previousName}
|
||||
currentName={currentName}
|
||||
costStart={costStart}
|
||||
costEnd={costEnd}
|
||||
/>
|
||||
</Box>
|
||||
<ResourceGrowthBarChart
|
||||
resources={alert.products}
|
||||
previousName={previousName}
|
||||
currentName={currentName}
|
||||
/>
|
||||
</Box>
|
||||
<ProjectGrowthAlertChart alert={alert} />
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import moment from 'moment';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { BarChart, BarChartLegend } from '../BarChart';
|
||||
import { LegendItem } from '../LegendItem';
|
||||
import { CostGrowth } from '../CostGrowth';
|
||||
import { BarChartOptions, Duration, ProjectGrowthData } from '../../types';
|
||||
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
|
||||
import { resourceOf } from '../../utils/graphs';
|
||||
|
||||
type ProjectGrowthAlertChartProps = {
|
||||
alert: ProjectGrowthData;
|
||||
};
|
||||
|
||||
export const ProjectGrowthAlertChart = ({
|
||||
alert,
|
||||
}: ProjectGrowthAlertChartProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const costStart = alert.aggregation[0];
|
||||
const costEnd = alert.aggregation[1];
|
||||
const resourceData = alert.products.map(resourceOf);
|
||||
|
||||
const options: Partial<BarChartOptions> = {
|
||||
previousName: moment(alert.periodStart, 'YYYY-[Q]Q').format('[Q]Q YYYY'),
|
||||
currentName: moment(alert.periodEnd, 'YYYY-[Q]Q').format('[Q]Q YYYY'),
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={classes.wrapper}>
|
||||
<BarChartLegend costStart={costStart} costEnd={costEnd} options={options}>
|
||||
<LegendItem title="Cost Growth">
|
||||
<CostGrowth change={alert.change} duration={Duration.P3M} />
|
||||
</LegendItem>
|
||||
</BarChartLegend>
|
||||
<BarChart resources={resourceData} options={options} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+38
-35
@@ -15,19 +15,22 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import { InfoCard } from '@backstage/core';
|
||||
import { AlertInstructionsLayout } from '../AlertInstructionsLayout';
|
||||
import { ProductInsightsChart } from '../ProductInsightsCard';
|
||||
import {
|
||||
Alert,
|
||||
DEFAULT_DATE_FORMAT,
|
||||
Duration,
|
||||
Entity,
|
||||
Product,
|
||||
ProjectGrowthData,
|
||||
} from '../../types';
|
||||
import { ProjectGrowthAlert } from '../../utils/alerts';
|
||||
import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend';
|
||||
import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart';
|
||||
|
||||
const today = moment().format(DEFAULT_DATE_FORMAT);
|
||||
|
||||
export const ProjectGrowthInstructionsPage = () => {
|
||||
const alertData: ProjectGrowthData = {
|
||||
@@ -54,6 +57,7 @@ export const ProjectGrowthInstructionsPage = () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const projectGrowthAlert: Alert = new ProjectGrowthAlert(alertData);
|
||||
|
||||
const product: Product = {
|
||||
@@ -61,20 +65,34 @@ export const ProjectGrowthInstructionsPage = () => {
|
||||
name: 'Compute Engine',
|
||||
};
|
||||
|
||||
const entities: Entity[] = [
|
||||
{
|
||||
id: 'service-one',
|
||||
aggregation: [18200, 58500],
|
||||
const entity: Entity = {
|
||||
id: 'example-id',
|
||||
aggregation: [20_000, 60_000],
|
||||
change: {
|
||||
ratio: 3,
|
||||
amount: 40_000,
|
||||
},
|
||||
{
|
||||
id: 'service-two',
|
||||
aggregation: [1200, 1300],
|
||||
},
|
||||
{
|
||||
id: 'service-three',
|
||||
aggregation: [600, 200],
|
||||
},
|
||||
];
|
||||
entities: [
|
||||
{
|
||||
id: 'service-one',
|
||||
aggregation: [18_200, 58_500],
|
||||
entities: [],
|
||||
change: { ratio: 2.21, amount: 40_300 },
|
||||
},
|
||||
{
|
||||
id: 'service-two',
|
||||
aggregation: [1200, 1300],
|
||||
entities: [],
|
||||
change: { ratio: 0.083, amount: 100 },
|
||||
},
|
||||
{
|
||||
id: 'service-three',
|
||||
aggregation: [600, 200],
|
||||
entities: [],
|
||||
change: { ratio: -0.666, amount: -400 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertInstructionsLayout title="Investigating Growth">
|
||||
@@ -153,27 +171,12 @@ export const ProjectGrowthInstructionsPage = () => {
|
||||
) that has grown in cost:
|
||||
</Typography>
|
||||
<Box mt={2} mb={2}>
|
||||
{/* ProductInsightsCard without API query / PeriodSelect */}
|
||||
<InfoCard title={product.name} subheader="3 entities, sorted by cost">
|
||||
<Box display="flex" flexDirection="column">
|
||||
<Box paddingY={1}>
|
||||
<ResourceGrowthBarChartLegend
|
||||
duration={Duration.P3M}
|
||||
change={{ ratio: 3, amount: 40000 }}
|
||||
previousName="Q2 2020"
|
||||
currentName="Q3 2020"
|
||||
costStart={20000}
|
||||
costEnd={60000}
|
||||
/>
|
||||
</Box>
|
||||
<Box paddingY={1}>
|
||||
<ResourceGrowthBarChart
|
||||
resources={entities}
|
||||
previousName="Q2 2020"
|
||||
currentName="Q3 2020"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<ProductInsightsChart
|
||||
billingDate={today}
|
||||
duration={Duration.P3M}
|
||||
entity={entity}
|
||||
/>
|
||||
</InfoCard>
|
||||
</Box>
|
||||
<Typography paragraph>
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import { ResourceGrowthBarChart } from './ResourceGrowthBarChart';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { createMockEntity } from '../../utils/mockData';
|
||||
|
||||
const MockResources = [...Array(10)].map((_, index) =>
|
||||
createMockEntity(() => ({
|
||||
id: `test-id-${index + 1}`,
|
||||
// grow resource costs linearly for testing
|
||||
aggregation: [index * 1000, (index + 1) * 1000],
|
||||
})),
|
||||
);
|
||||
|
||||
describe('<ResourceGrowthBarChart/>', () => {
|
||||
it('Pre-renders without exploding', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<ResourceGrowthBarChart
|
||||
resources={MockResources}
|
||||
previousName="Q2 2020"
|
||||
currentName="Q3 2020"
|
||||
/>,
|
||||
);
|
||||
expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import { TooltipPayload } from 'recharts';
|
||||
import { currencyFormatter } from '../../utils/formatters';
|
||||
import {
|
||||
AlertCost,
|
||||
BarChartData,
|
||||
CostInsightsTheme,
|
||||
DataKey,
|
||||
Entity,
|
||||
Maybe,
|
||||
ResourceData,
|
||||
} from '../../types';
|
||||
import { BarChart } from '../BarChart';
|
||||
import { TooltipItemProps } from '../Tooltip';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
|
||||
export type ResourceGrowthBarChartProps = {
|
||||
resources: Array<Entity | AlertCost>;
|
||||
previousName: string;
|
||||
currentName: string;
|
||||
};
|
||||
|
||||
export const ResourceGrowthBarChart = ({
|
||||
resources,
|
||||
previousName,
|
||||
currentName,
|
||||
}: ResourceGrowthBarChartProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const getTooltipItem = (payload: TooltipPayload): Maybe<TooltipItemProps> => {
|
||||
const value =
|
||||
typeof payload.value === 'number'
|
||||
? currencyFormatter.format(payload.value)
|
||||
: (payload.value as string);
|
||||
const fill = payload.fill as string;
|
||||
|
||||
switch (payload.dataKey) {
|
||||
case DataKey.Current:
|
||||
case DataKey.Previous:
|
||||
return {
|
||||
label: payload.name,
|
||||
value: value,
|
||||
fill: fill,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const barChartData: BarChartData = {
|
||||
previousFill: theme.palette.lightBlue,
|
||||
currentFill: theme.palette.darkBlue,
|
||||
previousName: previousName,
|
||||
currentName: currentName,
|
||||
};
|
||||
|
||||
const resourceData: ResourceData[] = resources.map(resource => {
|
||||
return {
|
||||
name: resource.id,
|
||||
previous: resource.aggregation[0],
|
||||
current: resource.aggregation[1],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
barChartData={barChartData}
|
||||
getTooltipItem={getTooltipItem}
|
||||
resources={resourceData}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { ResourceGrowthBarChart } from './ResourceGrowthBarChart';
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { ResourceGrowthBarChartLegend } from './ResourceGrowthBarChartLegend';
|
||||
import { defaultCurrencies } from '../../utils/currency';
|
||||
import { findAlways } from '../../utils/assert';
|
||||
import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests';
|
||||
import { Duration } from '../../types';
|
||||
|
||||
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
|
||||
|
||||
const MockContext = ({ children }: PropsWithChildren<{}>) => (
|
||||
<MockConfigProvider engineerCost={200_000}>
|
||||
<MockCurrencyProvider currency={engineers}>{children}</MockCurrencyProvider>
|
||||
</MockConfigProvider>
|
||||
);
|
||||
|
||||
describe('<ResourceGrowthBarChartLegend />', () => {
|
||||
describe.each`
|
||||
ratio | amount | costText | engineerTest
|
||||
${2.5} | ${300_000} | ${'Cost Growth'} | ${/\~6 engineers/}
|
||||
${-2.5} | ${-120_000} | ${'Cost Savings'} | ${/\~2 engineers/}
|
||||
`(
|
||||
'Should display the cost text',
|
||||
({ ratio, amount, costText, engineerTest }) => {
|
||||
it(`Should display the correct cost and engineer text for ${ratio} percent change`, async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<MockContext>
|
||||
<ResourceGrowthBarChartLegend
|
||||
duration={Duration.P3M}
|
||||
change={{ ratio, amount }}
|
||||
previousName="Q2 2020"
|
||||
currentName="Q3 2020"
|
||||
costStart={1000}
|
||||
costEnd={5000}
|
||||
/>
|
||||
</MockContext>,
|
||||
);
|
||||
expect(rendered.getByText(costText)).toBeInTheDocument();
|
||||
expect(rendered.queryByText(engineerTest)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { ResourceGrowthBarChartLegend } from './ResourceGrowthBarChartLegend';
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import { TooltipItem, TooltipItemProps } from './TooltipItem';
|
||||
import { useTooltipStyles } from '../../utils/styles';
|
||||
|
||||
export type TooltipProps = {
|
||||
label?: string;
|
||||
items?: Array<TooltipItemProps>;
|
||||
};
|
||||
|
||||
export const Tooltip = ({ label, items }: TooltipProps) => {
|
||||
const classes = useTooltipStyles();
|
||||
return (
|
||||
<Box
|
||||
className={`tooltip-content ${classes.tooltip}`}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
minWidth={250}
|
||||
>
|
||||
{label && (
|
||||
<Typography display="block" gutterBottom>
|
||||
<b>{label}</b>
|
||||
</Typography>
|
||||
)}
|
||||
{items &&
|
||||
items.map((item, index) => (
|
||||
<TooltipItem
|
||||
key={`${item.label}-${index}`}
|
||||
fill={item.fill}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { Tooltip } from './Tooltip';
|
||||
export type { TooltipProps } from './Tooltip';
|
||||
export { TooltipItem } from './TooltipItem';
|
||||
export type { TooltipItemProps } from './TooltipItem';
|
||||
+23
-14
@@ -15,12 +15,12 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { InfoCard } from '@backstage/core';
|
||||
import { UnlabeledDataflowBarChart } from './UnlabeledDataflowBarChart';
|
||||
import { UnlabeledDataflowBarChartLegend } from './UnlabeledDataflowBarChartLegend';
|
||||
import { UnlabeledDataflowData } from '../../types';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { BarChart, BarChartLegend } from '../BarChart';
|
||||
import { UnlabeledDataflowData, ResourceData } from '../../types';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
|
||||
|
||||
type UnlabeledDataflowAlertProps = {
|
||||
alert: UnlabeledDataflowData;
|
||||
@@ -29,22 +29,31 @@ type UnlabeledDataflowAlertProps = {
|
||||
export const UnlabeledDataflowAlertCard = ({
|
||||
alert,
|
||||
}: UnlabeledDataflowAlertProps) => {
|
||||
const classes = useStyles();
|
||||
const projects = pluralOf(alert.projects.length, 'project');
|
||||
const subheader = `
|
||||
Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days.
|
||||
`;
|
||||
const options = {
|
||||
previousName: 'Unlabeled Cost',
|
||||
currentName: 'Labeled Cost',
|
||||
};
|
||||
|
||||
const resources: ResourceData[] = alert.projects.map(project => ({
|
||||
name: project.id,
|
||||
previous: project.unlabeledCost,
|
||||
current: project.labeledCost,
|
||||
}));
|
||||
|
||||
return (
|
||||
<InfoCard title="Label Dataflow" subheader={subheader}>
|
||||
<Box display="flex" flexDirection="column">
|
||||
<Box paddingY={1}>
|
||||
<UnlabeledDataflowBarChartLegend
|
||||
unlabeledCost={alert.unlabeledCost}
|
||||
labeledCost={alert.labeledCost}
|
||||
/>
|
||||
</Box>
|
||||
<Box paddingY={1}>
|
||||
<UnlabeledDataflowBarChart projects={alert.projects} />
|
||||
</Box>
|
||||
<Box className={classes.wrapper}>
|
||||
<BarChartLegend
|
||||
costStart={alert.labeledCost}
|
||||
costEnd={alert.unlabeledCost}
|
||||
options={options}
|
||||
/>
|
||||
<BarChart resources={resources} options={options} />
|
||||
</Box>
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import { TooltipPayload } from 'recharts';
|
||||
import { BarChart } from '../BarChart';
|
||||
import { TooltipItemProps } from '../Tooltip';
|
||||
import {
|
||||
BarChartData,
|
||||
CostInsightsTheme,
|
||||
ResourceData,
|
||||
UnlabeledDataflowAlertProject,
|
||||
} from '../../types';
|
||||
import { currencyFormatter } from '../../utils/formatters';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
|
||||
type UnlabeledDataflowBarChartProps = {
|
||||
projects: Array<UnlabeledDataflowAlertProject>;
|
||||
};
|
||||
|
||||
export const UnlabeledDataflowBarChart = ({
|
||||
projects,
|
||||
}: UnlabeledDataflowBarChartProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const barChartData: BarChartData = {
|
||||
previousFill: theme.palette.lightBlue,
|
||||
currentFill: theme.palette.darkBlue,
|
||||
previousName: 'Unlabeled Cost',
|
||||
currentName: 'Labeled Cost',
|
||||
};
|
||||
|
||||
const getTooltipItem = (payload: TooltipPayload): TooltipItemProps => {
|
||||
return {
|
||||
label: payload.name,
|
||||
value:
|
||||
typeof payload.value === 'number'
|
||||
? currencyFormatter.format(payload.value)
|
||||
: (payload.value as string),
|
||||
fill: payload.fill as string,
|
||||
};
|
||||
};
|
||||
|
||||
const resources: ResourceData[] = projects.map(project => {
|
||||
return {
|
||||
name: project.id,
|
||||
previous: project.unlabeledCost || 0,
|
||||
current: project.labeledCost || 0,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
barChartData={barChartData}
|
||||
resources={resources}
|
||||
getTooltipItem={getTooltipItem}
|
||||
/>
|
||||
);
|
||||
};
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import { Box, useTheme } from '@material-ui/core';
|
||||
import { LegendItem } from '../LegendItem';
|
||||
import { currencyFormatter } from '../../utils/formatters';
|
||||
import { CostInsightsTheme } from '../../types';
|
||||
|
||||
type UnlabeledDataflowBarChartLegendProps = {
|
||||
labeledCost: number;
|
||||
unlabeledCost: number;
|
||||
};
|
||||
|
||||
export const UnlabeledDataflowBarChartLegend = ({
|
||||
unlabeledCost,
|
||||
labeledCost,
|
||||
}: UnlabeledDataflowBarChartLegendProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
return (
|
||||
<Box
|
||||
className="dataflow-label-chart-legend"
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
>
|
||||
<Box marginRight={2}>
|
||||
<LegendItem
|
||||
title="Total Unlabeled Cost"
|
||||
markerColor={theme.palette.lightBlue}
|
||||
>
|
||||
{currencyFormatter.format(unlabeledCost)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
<Box marginRight={2}>
|
||||
<LegendItem
|
||||
title="Total Labeled Cost"
|
||||
markerColor={theme.palette.darkBlue}
|
||||
>
|
||||
{currencyFormatter.format(labeledCost)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -17,4 +17,3 @@
|
||||
export * from './BarChart';
|
||||
export * from './CostGrowth';
|
||||
export * from './LegendItem';
|
||||
export * from './Tooltip';
|
||||
|
||||
@@ -41,13 +41,16 @@ export interface ResourceData {
|
||||
name: Maybe<string>;
|
||||
}
|
||||
|
||||
export interface BarChartData {
|
||||
export interface BarChartOptions {
|
||||
previousFill: string;
|
||||
currentFill: string;
|
||||
previousName: string;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
/** deprecated use BarChartOptions instead */
|
||||
export interface BarChartData extends BarChartOptions {}
|
||||
|
||||
export enum DataKey {
|
||||
Previous = 'previous',
|
||||
Current = 'current',
|
||||
|
||||
@@ -20,5 +20,68 @@ import { Maybe } from './Maybe';
|
||||
export interface Entity {
|
||||
id: Maybe<string>;
|
||||
aggregation: [number, number];
|
||||
change?: Maybe<ChangeStatistic>;
|
||||
entities: Entity[];
|
||||
change: ChangeStatistic;
|
||||
}
|
||||
|
||||
/*
|
||||
An entity is a tree-like structure that represents any unique
|
||||
product or service that generates cost over a fixed period of time.
|
||||
An entity could be atomic or composite. An atomic entity is indivisible
|
||||
and cannot be broken into sub-entities.
|
||||
|
||||
A composite entity can be broken down recursively into sub-entities
|
||||
that generate cost **over the same time period**. All costs must sum to the root cost.
|
||||
|
||||
Entities with null ids are considered "unlabeled" - costs without attribution.
|
||||
If an entity is a composite, it may only have one (1) null child but may have any number of
|
||||
null grandchildren.
|
||||
|
||||
{
|
||||
id: 'product',
|
||||
aggregation: [0, 200],
|
||||
change: {
|
||||
ratio: 2000,
|
||||
amount: 200
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'service-a',
|
||||
aggregation: [0, 100],
|
||||
change: {
|
||||
ratio: 100,
|
||||
amount: 100
|
||||
},
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'service-b',
|
||||
aggregation: [0, 100],
|
||||
change: {
|
||||
ratio: 100,
|
||||
amount: 100
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'service-b-sku-a',
|
||||
aggregation: [0, 25],
|
||||
change: {
|
||||
ratio: 25,
|
||||
amount: 25
|
||||
},
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: null, // Unlabeled cost for service-b
|
||||
aggregation: [0, 75],
|
||||
change: {
|
||||
ratio: 75,
|
||||
amount: 75
|
||||
},
|
||||
entities: []
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -14,17 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from './Entity';
|
||||
import { ChangeStatistic } from './ChangeStatistic';
|
||||
import { Maybe } from './Maybe';
|
||||
|
||||
export interface Product {
|
||||
kind: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProductCost {
|
||||
entities?: Array<Entity>;
|
||||
aggregation: [number, number];
|
||||
change?: Maybe<ChangeStatistic>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 { growthOf } from './change';
|
||||
import { GrowthType, ChangeThreshold, EngineerThreshold } from '../types';
|
||||
|
||||
const GrowthMap = {
|
||||
[GrowthType.Negligible]: 'negligible growth',
|
||||
[GrowthType.Savings]: 'cost savings',
|
||||
[GrowthType.Excess]: 'cost excess',
|
||||
};
|
||||
|
||||
describe.each`
|
||||
ratio | amount | expected
|
||||
${0.0} | ${undefined} | ${GrowthType.Negligible}
|
||||
${0.0} | ${EngineerThreshold} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${undefined} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper - 0.01} | ${undefined} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower} | ${undefined} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower} | ${EngineerThreshold} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower - 0.01} | ${undefined} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.upper} | ${undefined} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper} | ${EngineerThreshold} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper + 0.01} | ${undefined} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Excess}
|
||||
`(
|
||||
'growthOf',
|
||||
({
|
||||
ratio,
|
||||
amount,
|
||||
expected,
|
||||
}: {
|
||||
ratio: number;
|
||||
amount: number;
|
||||
expected: GrowthType;
|
||||
}) => {
|
||||
it(`should display ${GrowthMap[expected]}`, () => {
|
||||
expect(growthOf(ratio, amount)).toBe(expected);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -24,14 +24,18 @@ import {
|
||||
} from '../types';
|
||||
import { aggregationSort } from '../utils/sort';
|
||||
|
||||
// Used by <CostGrowth /> for displaying status colors
|
||||
export function growthOf(amount: number, ratio: number) {
|
||||
if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) {
|
||||
return GrowthType.Excess;
|
||||
}
|
||||
|
||||
if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) {
|
||||
return GrowthType.Savings;
|
||||
// Used for displaying status colors
|
||||
export function growthOf(ratio: number, amount?: number) {
|
||||
if (typeof amount === 'number') {
|
||||
if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) {
|
||||
return GrowthType.Excess;
|
||||
}
|
||||
if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) {
|
||||
return GrowthType.Savings;
|
||||
}
|
||||
} else {
|
||||
if (ratio >= ChangeThreshold.upper) return GrowthType.Excess;
|
||||
if (ratio <= ChangeThreshold.lower) return GrowthType.Savings;
|
||||
}
|
||||
|
||||
return GrowthType.Negligible;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 { TooltipPayload, TooltipProps } from 'recharts';
|
||||
import { AlertCost, DataKey, Entity, ResourceData } from '../types';
|
||||
import {
|
||||
currencyFormatter,
|
||||
dateFormatter,
|
||||
lengthyCurrencyFormatter,
|
||||
} from './formatters';
|
||||
|
||||
export function formatGraphValue(value: number, format?: string) {
|
||||
if (format === 'number') {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
if (value < 1) {
|
||||
return lengthyCurrencyFormatter.format(value);
|
||||
}
|
||||
|
||||
return currencyFormatter.format(value);
|
||||
}
|
||||
|
||||
export const overviewGraphTickFormatter = (millis: string | number) =>
|
||||
typeof millis === 'number' ? dateFormatter.format(millis) : millis;
|
||||
|
||||
export const tooltipItemOf = (payload: TooltipPayload) => {
|
||||
const value =
|
||||
typeof payload.value === 'number'
|
||||
? currencyFormatter.format(payload.value)
|
||||
: (payload.value as string);
|
||||
const fill = payload.fill as string;
|
||||
|
||||
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,
|
||||
previous: entity.aggregation[0],
|
||||
current: entity.aggregation[1],
|
||||
});
|
||||
|
||||
export const titleOf = (label?: string | number) => {
|
||||
return label ? String(label) : 'Unlabeled';
|
||||
};
|
||||
|
||||
export const isInvalid = ({ label, payload }: TooltipProps) => {
|
||||
// null labels are empty strings, which are valid
|
||||
return label === undefined || !payload || !payload.length;
|
||||
};
|
||||
|
||||
export const isActiveLabel = (
|
||||
data?: Record<'activeLabel', string | undefined>,
|
||||
) => {
|
||||
return data?.activeLabel && data.activeLabel !== '';
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
currencyFormatter,
|
||||
dateFormatter,
|
||||
lengthyCurrencyFormatter,
|
||||
} from './formatters';
|
||||
|
||||
export function formatGraphValue(value: number, format?: string) {
|
||||
if (format === 'number') {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
if (value < 1) {
|
||||
return lengthyCurrencyFormatter.format(value);
|
||||
}
|
||||
|
||||
return currencyFormatter.format(value);
|
||||
}
|
||||
|
||||
export const overviewGraphTickFormatter = (millis: string | number) =>
|
||||
typeof millis === 'number' ? dateFormatter.format(millis) : millis;
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Duration,
|
||||
Entity,
|
||||
Product,
|
||||
ProductCost,
|
||||
ProductFilters,
|
||||
ProjectGrowthData,
|
||||
UnlabeledDataflowAlertProject,
|
||||
@@ -41,6 +40,11 @@ export const createMockEntity = (
|
||||
const defaultEntity: Entity = {
|
||||
id: 'test-entity',
|
||||
aggregation: [100, 200],
|
||||
entities: [],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof callback === 'function') {
|
||||
@@ -62,19 +66,6 @@ export const createMockProduct = (
|
||||
return { ...defaultProduct };
|
||||
};
|
||||
|
||||
export const createMockProductCost = (
|
||||
callback?: mockEntityRenderer<ProductCost>,
|
||||
): ProductCost => {
|
||||
const defaultProduct: ProductCost = {
|
||||
entities: [],
|
||||
aggregation: [0, 0],
|
||||
};
|
||||
if (typeof callback === 'function') {
|
||||
return callback({ ...defaultProduct });
|
||||
}
|
||||
return { ...defaultProduct };
|
||||
};
|
||||
|
||||
export const createMockProjectGrowthData = (
|
||||
callback?: mockAlertRenderer<ProjectGrowthData>,
|
||||
): ProjectGrowthData => {
|
||||
|
||||
@@ -19,5 +19,6 @@ export const aggregationSort = (
|
||||
a: DateAggregation,
|
||||
b: DateAggregation,
|
||||
): number => a.date.localeCompare(b.date);
|
||||
|
||||
export const resourceSort = (a: ResourceData, b: ResourceData) =>
|
||||
b.previous + b.current - (a.previous + a.current);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import {
|
||||
createStyles,
|
||||
emphasize,
|
||||
darken,
|
||||
getLuminance,
|
||||
lighten,
|
||||
@@ -116,6 +117,18 @@ export const useBarChartStyles = (theme: CostInsightsTheme) => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const useBarChartLayoutStyles = makeStyles<BackstageTheme>(theme =>
|
||||
createStyles({
|
||||
wrapper: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
legend: {
|
||||
paddingBottom: theme.spacing(2),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const useBarChartStepperButtonStyles = makeStyles<CostInsightsTheme>(
|
||||
(theme: CostInsightsTheme) =>
|
||||
createStyles({
|
||||
@@ -146,12 +159,13 @@ export const useBarChartStepperButtonStyles = makeStyles<CostInsightsTheme>(
|
||||
}),
|
||||
);
|
||||
|
||||
export const useBarChartLabelStyles = makeStyles(() =>
|
||||
export const useBarChartLabelStyles = makeStyles<BackstageTheme>(theme =>
|
||||
createStyles({
|
||||
foreignObject: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
label: {
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
display: 'block',
|
||||
textDecoration: 'none',
|
||||
overflow: 'hidden',
|
||||
@@ -162,6 +176,11 @@ export const useBarChartLabelStyles = makeStyles(() =>
|
||||
marginLeft: 2,
|
||||
fontSize: '1.25em',
|
||||
},
|
||||
button: {
|
||||
textTransform: 'none',
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
fontSize: theme.typography.fontSize,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -244,6 +263,12 @@ export const useCostGrowthStyles = makeStyles<BackstageTheme>(
|
||||
savings: {
|
||||
color: theme.palette.status.ok,
|
||||
},
|
||||
indicator: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -356,16 +381,31 @@ export const useTooltipStyles = makeStyles<CostInsightsTheme>(
|
||||
(theme: CostInsightsTheme) =>
|
||||
createStyles({
|
||||
tooltip: {
|
||||
padding: theme.spacing(1.5),
|
||||
backgroundColor: theme.palette.tooltip.background,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
boxShadow: theme.shadows[1],
|
||||
color: theme.palette.tooltip.color,
|
||||
fontSize: theme.typography.fontSize,
|
||||
width: 250,
|
||||
},
|
||||
actions: {
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
header: {
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
content: {
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
lensIcon: {
|
||||
fontSize: `.75rem`,
|
||||
},
|
||||
divider: {
|
||||
backgroundColor: emphasize(theme.palette.divider, 1),
|
||||
},
|
||||
subtitle: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -446,6 +486,21 @@ export const useProductInsightsCardStyles = makeStyles<BackstageTheme>(
|
||||
}),
|
||||
);
|
||||
|
||||
export const useProductInsightsChartStyles = makeStyles<BackstageTheme>(
|
||||
(theme: BackstageTheme) =>
|
||||
createStyles({
|
||||
indicator: {
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
fontSize: '1.25rem',
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const useBackdropStyles = makeStyles<BackstageTheme>(
|
||||
(theme: BackstageTheme) =>
|
||||
createStyles({
|
||||
@@ -463,3 +518,42 @@ export const useSubtleTypographyStyles = makeStyles<BackstageTheme>(
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const useEntityDialogStyles = makeStyles<BackstageTheme>(theme =>
|
||||
createStyles({
|
||||
dialogContent: {
|
||||
padding: 0,
|
||||
},
|
||||
dialogTitle: {
|
||||
padding: 0,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
right: theme.spacing(1),
|
||||
top: theme.spacing(1),
|
||||
color: theme.palette.grey[500],
|
||||
zIndex: 100,
|
||||
},
|
||||
row: {
|
||||
fontSize: theme.typography.fontSize * 1.25,
|
||||
},
|
||||
rowTotal: {
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
},
|
||||
colFirst: {
|
||||
paddingLeft: theme.spacing(2),
|
||||
},
|
||||
colLast: {
|
||||
paddingRight: theme.spacing(2),
|
||||
},
|
||||
column: {
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
},
|
||||
growth: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignContent: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user