stack rank product panels

This commit is contained in:
Ryan Vazquez
2020-11-24 18:16:13 -05:00
parent 74f8f22273
commit 2739de3fdc
11 changed files with 499 additions and 133 deletions
+3 -3
View File
@@ -332,14 +332,14 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
project: 'example-project',
periodStart: '2020-Q2',
periodEnd: '2020-Q3',
aggregation: [60_000, 120_000],
aggregation: [100, 120_000],
change: {
ratio: 1,
ratio: 10.123123,
amount: 60000,
},
products: [
{ id: 'Compute Engine', aggregation: [58_000, 118_000] },
{ id: 'Cloud Dataflow', aggregation: [1200, 1500] },
{ id: 'Cloud Dataflow', aggregation: [0, 15_000] },
{ id: 'Cloud Storage', aggregation: [800, 500] },
],
};
@@ -16,7 +16,7 @@
import React from 'react';
import { Box, Typography, Grid } from '@material-ui/core';
import { ProductInsightsCard } from '../ProductInsightsCard';
import { ProductInsightsCardList } from '../ProductInsightsCard';
import { useConfig } from '../../hooks';
export const ProductInsights = ({}) => {
@@ -30,11 +30,7 @@ export const ProductInsights = ({}) => {
</Typography>
</Box>
<Grid container direction="column">
{config.products.map(product => (
<Grid item key={product.kind} style={{ position: 'relative' }}>
<ProductInsightsCard product={product} />
</Grid>
))}
<ProductInsightsCardList products={config.products} />
</Grid>
</>
);
@@ -22,19 +22,19 @@ import {
createMockEntity,
mockDefaultLoadingState,
MockComputeEngine,
MockProductFilters,
} from '../../utils/mockData';
import {
MockCostInsightsApiProvider,
MockBillingDateProvider,
MockConfigProvider,
MockCostInsightsApiProvider,
MockCurrencyProvider,
MockFilterProvider,
MockGroupsProvider,
MockBillingDateProvider,
MockScrollProvider,
MockLoadingProvider,
} from '../../utils/tests';
import { Duration, Entity, Product, ProductPeriod } from '../../types';
import { Duration, Entity, Product } from '../../types';
// suppress recharts componentDidUpdate warnings
jest.spyOn(console, 'warn').mockImplementation(() => {});
const costInsightsApi = (entity: Entity): Partial<CostInsightsApi> => ({
getProductInsights: () => Promise.resolve(entity),
@@ -53,29 +53,25 @@ const mockProductCost = createMockEntity(() => ({
const renderProductInsightsCardInTestApp = async (
entity: Entity,
product: Product,
duration: Duration,
duration = Duration.P30D,
onSelectAsync = jest.fn(() => Promise.resolve(mockProductCost)),
) =>
await renderInTestApp(
<MockCostInsightsApiProvider costInsightsApi={costInsightsApi(entity)}>
<MockConfigProvider>
<MockLoadingProvider state={mockDefaultLoadingState}>
<MockGroupsProvider>
<MockCurrencyProvider>
<MockLoadingProvider state={mockDefaultLoadingState}>
<MockBillingDateProvider>
<MockFilterProvider
productFilters={MockProductFilters.map((p: ProductPeriod) => ({
...p,
duration: duration,
}))}
>
<MockScrollProvider>
<MockCurrencyProvider>
<ProductInsightsCard product={product} />
</MockCurrencyProvider>
</MockScrollProvider>
</MockFilterProvider>
<MockScrollProvider>
<ProductInsightsCard
product={product}
initialState={{ entity, duration }}
onSelectAsync={onSelectAsync}
/>
</MockScrollProvider>
</MockBillingDateProvider>
</MockGroupsProvider>
</MockLoadingProvider>
</MockLoadingProvider>
</MockCurrencyProvider>
</MockConfigProvider>
</MockCostInsightsApiProvider>,
);
@@ -100,7 +96,6 @@ describe('<ProductInsightsCard/>', () => {
const rendered = await renderProductInsightsCardInTestApp(
entity,
MockComputeEngine,
Duration.P1M,
);
const subheader = 'entities, sorted by cost';
const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`);
@@ -14,46 +14,84 @@
* limitations under the License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { InfoCard, useApi } from '@backstage/core';
import Alert from '@material-ui/lab/Alert';
import React, {
PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { InfoCard } from '@backstage/core';
import { Typography } from '@material-ui/core';
import { costInsightsApiRef } from '../../api';
import { ProductInsightsChart } from './ProductInsightsChart';
import { PeriodSelect } from '../PeriodSelect';
import {
useFilters,
useLastCompleteBillingDate,
useLoading,
useScroll,
} from '../../hooks';
import { ProductInsightsChart } from './ProductInsightsChart';
import { ProductInsightsErrorCard } from './ProductInsightsErrorCard';
import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
import { mapFiltersToProps, mapLoadingToProps } from './selector';
import { Duration, Maybe, Product, Entity } from '../../types';
import { DefaultLoadingAction } from '../../utils/loading';
import { Duration, Entity, Maybe, Product } from '../../types';
import {
useLastCompleteBillingDate,
useScroll,
useLoading,
MapLoadingToProps,
} from '../../hooks';
import { pluralOf } from '../../utils/grammar';
type ProductInsightsCardProps = {
type LoadingProps = (isLoading: boolean) => void;
export type ProductInsightsCardProps = {
product: Product;
initialState: {
entity: Maybe<Entity>;
duration: Duration;
};
onSelectAsync: (product: Product, duration: Duration) => Promise<Entity>;
};
export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
const client = useApi(costInsightsApiRef);
const mapLoadingToProps: MapLoadingToProps<LoadingProps> = ({ dispatch }) => (
isLoading: boolean,
) => dispatch({ [DefaultLoadingAction.CostInsightsProducts]: isLoading });
export const ProductInsightsCard = ({
initialState,
product,
onSelectAsync,
}: PropsWithChildren<ProductInsightsCardProps>) => {
const classes = useStyles();
const mountedRef = useRef(false);
const { ScrollAnchor } = useScroll(product.kind);
const lastCompleteBillingDate = useLastCompleteBillingDate();
const [entity, setEntity] = useState<Maybe<Entity>>(null);
const [error, setError] = useState<Maybe<Error>>(null);
const dispatchLoading = useLoading(mapLoadingToProps);
const lastCompleteBillingDate = useLastCompleteBillingDate();
const [entity, setEntity] = useState<Maybe<Entity>>(initialState.entity);
const [duration, setDuration] = useState<Duration>(initialState.duration);
const { group, product: productFilter, setProduct, project } = useFilters(
mapFiltersToProps(product.kind),
);
const { loadingProduct, dispatchLoading } = useLoading(
mapLoadingToProps(product.kind),
);
/* eslint-disable react-hooks/exhaustive-deps */
const dispatchLoadingProduct = useCallback(dispatchLoading, []);
/* eslint-enable react-hooks/exhaustive-deps */
// @see CostInsightsPage
// eslint-disable-next-line react-hooks/exhaustive-deps
const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]);
useEffect(() => {
async function handleOnSelectAsync() {
dispatchLoadingProduct(true);
try {
const e = await onSelectAsync(product, duration);
setEntity(e);
} catch (e) {
setEntity(null);
setError(e);
} finally {
dispatchLoadingProduct(false);
}
}
if (mountedRef.current) {
handleOnSelectAsync();
}
}, [product, duration, onSelectAsync, dispatchLoadingProduct]);
useEffect(function hasComponentMounted() {
mountedRef.current = true;
}, []);
const amount = entity?.entities?.length || 0;
const hasCostsWithinTimeframe = !!(entity?.change && amount);
@@ -62,77 +100,31 @@ export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost`
: null;
useEffect(() => {
async function load() {
if (loadingProduct) {
try {
const e: Entity = await client.getProductInsights({
product: product.kind,
group: group!,
duration: productFilter!.duration,
lastCompleteBillingDate,
project,
});
setEntity(e);
} catch (e) {
setError(e);
} finally {
dispatchLoadingProduct(false);
}
}
}
load();
}, [
client,
product,
setEntity,
loadingProduct,
dispatchLoadingProduct,
productFilter,
group,
product.kind,
project,
lastCompleteBillingDate,
]);
const onPeriodSelect = (duration: Duration) => {
dispatchLoadingProduct(true);
setProduct(duration);
};
const infoCardProps = {
headerProps: {
classes: classes,
action: (
<PeriodSelect
duration={productFilter.duration}
onSelect={onPeriodSelect}
/>
),
action: <PeriodSelect duration={duration} onSelect={setDuration} />,
},
};
if (error) {
if (error || !entity) {
return (
<InfoCard title={product.name} {...infoCardProps}>
<ScrollAnchor behavior="smooth" top={-12} />
<Alert severity="error">{`Error: Could not fetch product insights for ${product.name}`}</Alert>
</InfoCard>
<ProductInsightsErrorCard
product={product}
duration={duration}
onSelect={setDuration}
/>
);
}
if (!entity) {
return null;
}
return (
<InfoCard title={product.name} subheader={subheader} {...infoCardProps}>
<ScrollAnchor behavior="smooth" top={-12} />
{hasCostsWithinTimeframe ? (
<ProductInsightsChart
billingDate={lastCompleteBillingDate}
duration={productFilter.duration}
duration={duration}
entity={entity}
billingDate={lastCompleteBillingDate}
/>
) : (
<Typography>
@@ -0,0 +1,156 @@
/*
* 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 { ProductInsightsCardList } from './ProductInsightsCardList';
import { ProductInsightsOptions } from '../../api';
import { mockDefaultLoadingState, MockProducts } from '../../utils/mockData';
import {
MockConfigProvider,
MockCostInsightsApiProvider,
MockCurrencyProvider,
MockFilterProvider,
MockBillingDateProvider,
MockScrollProvider,
MockLoadingProvider,
} from '../../utils/tests';
import { Entity } from '../../types';
// suppress recharts componentDidUpdate warnings
jest.spyOn(console, 'warn').mockImplementation(() => {});
const MockComputeEngineInsights: Entity = {
id: 'compute-engine',
entities: [],
aggregation: [0, 0],
change: {
ratio: 0,
amount: 0,
},
};
const MockCloudDataflowInsights: Entity = {
id: 'cloud-dataflow',
entities: [],
aggregation: [1_000, 2_000],
change: {
ratio: 1,
amount: 1_000,
},
};
const MockCloudStorageInsights: Entity = {
id: 'cloud-storage',
entities: [],
aggregation: [2_000, 4_000],
change: {
ratio: 1,
amount: 2_000,
},
};
const MockBigQueryInsights: Entity = {
id: 'big-query',
entities: [],
aggregation: [8_000, 16_000],
change: {
ratio: 1,
amount: 8_000,
},
};
const MockBigTableInsights: Entity = {
id: 'big-table',
entities: [],
aggregation: [16_000, 32_000],
change: {
ratio: 1,
amount: 16_000,
},
};
const MockCloudPubSubInsights: Entity = {
id: 'cloud-pub-sub',
entities: [],
aggregation: [32_000, 64_000],
change: {
ratio: 1,
amount: 32_000,
},
};
const ProductEntityMap = {
[MockBigQueryInsights.id!]: MockBigQueryInsights,
[MockBigTableInsights.id!]: MockBigTableInsights,
[MockCloudPubSubInsights.id!]: MockCloudPubSubInsights,
[MockCloudStorageInsights.id!]: MockCloudStorageInsights,
[MockCloudDataflowInsights.id!]: MockCloudDataflowInsights,
[MockComputeEngineInsights.id!]: MockComputeEngineInsights,
};
const costInsightsApi = {
getProductInsights: ({ product }: ProductInsightsOptions): Promise<Entity> =>
Promise.resolve(ProductEntityMap[product]),
};
function renderInContext(children: JSX.Element) {
return renderInTestApp(
<MockCostInsightsApiProvider costInsightsApi={costInsightsApi}>
<MockConfigProvider>
<MockFilterProvider>
<MockCurrencyProvider>
<MockLoadingProvider state={mockDefaultLoadingState}>
<MockBillingDateProvider>
<MockScrollProvider>{children}</MockScrollProvider>
</MockBillingDateProvider>
</MockLoadingProvider>
</MockCurrencyProvider>
</MockFilterProvider>
</MockConfigProvider>
</MockCostInsightsApiProvider>,
);
}
describe('<ProductInsightsCardList />', () => {
it('should render each product panel', async () => {
const noComputeEngineCostsRgx = /There are no Compute Engine costs within this timeframe for your team's projects./;
const { getByText } = await renderInContext(
<ProductInsightsCardList products={MockProducts} />,
);
expect(getByText(noComputeEngineCostsRgx)).toBeInTheDocument();
MockProducts.forEach(product =>
expect(getByText(product.name)).toBeInTheDocument(),
);
});
it('product panels should be sorted by total aggregated cost', async () => {
const { queryAllByTestId } = await renderInContext(
<ProductInsightsCardList products={MockProducts} />,
);
const expectedOrder = MockProducts.map(
product => `product-list-item-${product.kind}`,
).reverse();
const productPanels = queryAllByTestId(/^product-list-item/);
expect(productPanels.length).toBe(MockProducts.length);
Array.from(productPanels)
.map(el => el.getAttribute('data-testid'))
.forEach((id, i) => {
expect(id).toBe(expectedOrder[i]);
});
});
});
@@ -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, { useCallback, useEffect, useReducer, useRef } from 'react';
import { Box, CircularProgress } from '@material-ui/core';
import { useApi } from '@backstage/core';
import { costInsightsApiRef } from '../../api';
import { ProductInsightsCard } from './ProductInsightsCard';
import { Duration, Entity, Maybe, Product } from '../../types';
import { DefaultLoadingAction } from '../../utils/loading';
import {
useFilters,
useLoading,
useLastCompleteBillingDate,
MapLoadingToProps,
} from '../../hooks';
type State = {
[kind: string]: ProductState;
};
type ProductState = {
product: Product;
entity: Maybe<Entity>;
duration: Duration;
};
type LoadingProps = (isLoading: boolean) => void;
type ProductInsightsCardListProps = {
products: Product[];
};
const DEFAULT_DURATION = Duration.P30D;
function totalAggregationSort(a: ProductState, b: ProductState): number {
const [prevA, currA] = a.entity?.aggregation ?? [0, 0];
const [prevB, currB] = b.entity?.aggregation ?? [0, 0];
return prevB + currB - (prevA + currA);
}
const mapLoadingToProps: MapLoadingToProps<LoadingProps> = ({ dispatch }) => (
isLoading: boolean,
) => dispatch({ [DefaultLoadingAction.CostInsightsProducts]: isLoading });
function reducer(prevState: State, action: State): State {
return {
...prevState,
...action,
};
}
export const ProductInsightsCardList = ({
products,
}: ProductInsightsCardListProps) => {
const onceRef = useRef(false);
const client = useApi(costInsightsApiRef);
const filters = useFilters(p => p.pageFilters);
const [state, dispatch] = useReducer(reducer, {});
const lastCompleteBillingDate = useLastCompleteBillingDate();
const dispatchLoading = useLoading(mapLoadingToProps);
/* eslint-disable react-hooks/exhaustive-deps */
// See @CostInsightsPage
const dispatchLoadingProducts = useCallback(dispatchLoading, []);
/* eslint-enable react-hooks/exhaustive-deps */
const initialProductStates = onceRef.current
? Object.values(state).sort(totalAggregationSort)
: Object.values(state);
const onSelectAsyncMemo = useCallback(
async function onSelectAsync(
product: Product,
duration: Duration,
): Promise<Entity> {
if (filters.group) {
return client.getProductInsights({
group: filters.group,
project: filters.project,
product: product.kind,
duration: duration,
lastCompleteBillingDate: lastCompleteBillingDate,
});
}
return Promise.reject(
new Error(
`Cannot fetch insights for ${product.name}: Expected to have a group but found none.`,
),
);
},
[client, filters.group, filters.project, lastCompleteBillingDate],
);
useEffect(() => {
async function getAllProductInsights(
products: Product[],
group: string,
project: Maybe<string>,
lastCompleteBillingDate: string,
) {
const requests = products.map(product =>
client.getProductInsights({
group: group,
project: project,
product: product.kind,
duration: DEFAULT_DURATION,
lastCompleteBillingDate: lastCompleteBillingDate,
}),
);
try {
dispatchLoadingProducts(true);
const responses = await Promise.allSettled(requests);
const results = responses.map(response =>
response.status === 'fulfilled' ? response.value : null,
);
const initialState = results.reduce((acc, entity, index) => {
const product = products[index];
return {
...acc,
[product.kind]: {
product: product,
entity: entity,
duration: DEFAULT_DURATION,
},
};
}, {});
dispatch(initialState);
} finally {
dispatchLoadingProducts(false);
}
}
if (filters.group) {
onceRef.current = true;
getAllProductInsights(
products,
filters.group,
filters.project,
lastCompleteBillingDate,
);
}
}, [
client,
products,
filters.group,
filters.project,
lastCompleteBillingDate,
dispatchLoadingProducts,
]);
if (!initialProductStates.length) {
return <CircularProgress />;
}
return (
<>
{initialProductStates.map(({ product, entity, duration }) => (
<Box
key={product.kind}
mb={6}
position="relative"
data-testid={`product-list-item-${product.kind}`}
>
<ProductInsightsCard
product={product}
onSelectAsync={onSelectAsyncMemo}
initialState={{ entity: entity, duration: duration }}
/>
</Box>
))}
</>
);
};
@@ -0,0 +1,50 @@
/*
* 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 { InfoCard } from '@backstage/core';
import { default as Alert } from '@material-ui/lab/Alert';
import { PeriodSelect } from '../PeriodSelect';
import { useScroll } from '../../hooks';
import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
import { Duration, Product } from '../../types';
export type ProductInsightsErrorCardProps = {
product: Product;
duration: Duration;
onSelect: (duration: Duration) => void;
};
export const ProductInsightsErrorCard = ({
product,
duration,
onSelect,
}: ProductInsightsErrorCardProps) => {
const classes = useStyles();
const { ScrollAnchor } = useScroll(product.kind);
const headerProps = {
classes: classes,
action: <PeriodSelect duration={duration} onSelect={onSelect} />,
};
return (
<InfoCard title={product.name} headerProps={headerProps}>
<ScrollAnchor behavior="smooth" top={-12} />
<Alert severity="error">{`Error: Could not fetch product insights for ${product.name}`}</Alert>
</InfoCard>
);
};
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { ProductInsightsCard } from './ProductInsightsCard';
export { ProductInsightsCardList } from './ProductInsightsCardList';
export { ProductInsightsChart } from './ProductInsightsChart';
@@ -21,7 +21,6 @@ import React, {
SetStateAction,
useContext,
useEffect,
useMemo,
useReducer,
useState,
} from 'react';
@@ -30,10 +29,9 @@ import { Loading } from '../types';
import {
DefaultLoadingAction,
getDefaultState,
getLoadingActions,
INITIAL_LOADING_ACTIONS,
} from '../utils/loading';
import { useBackdropStyles as useStyles } from '../utils/styles';
import { useConfig } from './useConfig';
export type LoadingContextProps = {
state: Loading;
@@ -56,10 +54,7 @@ function reducer(prevState: Loading, action: Partial<Loading>): Loading {
export const LoadingProvider = ({ children }: PropsWithChildren<{}>) => {
const classes = useStyles();
const { products } = useConfig();
const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [
products,
]);
const actions = INITIAL_LOADING_ACTIONS;
const [state, dispatch] = useReducer(reducer, getDefaultState(actions));
const [isBackdropVisible, setBackdropVisible] = useState(false);
+2 -8
View File
@@ -21,11 +21,13 @@ export enum DefaultLoadingAction {
LastCompleteBillingDate = 'billing-date',
CostInsightsInitial = 'cost-insights-initial',
CostInsightsPage = 'cost-insights-page',
CostInsightsProducts = 'cost-insights-products',
}
export const INITIAL_LOADING_ACTIONS = [
DefaultLoadingAction.UserGroups,
DefaultLoadingAction.CostInsightsInitial,
DefaultLoadingAction.CostInsightsProducts,
];
export const getDefaultState = (loadingActions: string[]): Loading => {
@@ -54,11 +56,3 @@ export const getResetStateWithoutInitial = (
return { ...defaultState, [action]: loadingActionState };
}, {});
};
export function getLoadingActions(products: string[]): string[] {
return ([
DefaultLoadingAction.UserGroups,
DefaultLoadingAction.CostInsightsInitial,
DefaultLoadingAction.CostInsightsPage,
] as string[]).concat(products);
}
+2 -2
View File
@@ -145,14 +145,14 @@ export const MockProducts: Product[] = Object.keys(MockProductTypes).map(
})),
);
export const MockLoadingActions = ([
export const MockDefaultLoadingActions = ([
DefaultLoadingAction.UserGroups,
DefaultLoadingAction.CostInsightsInitial,
DefaultLoadingAction.CostInsightsPage,
] as string[]).concat(MockProducts.map(product => product.kind));
export const mockDefaultLoadingState = getDefaultLoadingState(
MockLoadingActions,
MockDefaultLoadingActions,
);
export const MockComputeEngine = findAlways(