stack rank product panels
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+19
-24
@@ -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>
|
||||
|
||||
+156
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user