Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import { EnvironmentOverviewTab } from './EnvironmentOverviewTab';
import { Resource, EnvironmentProperties } from '../../resources';
import { RadiusApi } from '../../api';
import { radiusApiRef } from '../../plugin';

// Mock the child components
jest.mock('./EnvironmentDetailsTable', () => ({
Expand All @@ -16,6 +19,24 @@ jest.mock('../recipes/RecipeTable', () => ({
),
}));

const api: Pick<RadiusApi, 'getResourceById'> = {
getResourceById: async <T,>() =>
({
id: 'unused',
type: 'Radius.Core/recipePacks',
name: 'unused',
systemData: {} as Record<string, never>,
properties: {} as T,
}),
};

const renderTab = (environment: Resource<EnvironmentProperties>) =>
renderInTestApp(
<TestApiProvider apis={[[radiusApiRef, api]]}>
<EnvironmentOverviewTab environment={environment} />
</TestApiProvider>,
);

describe('EnvironmentOverviewTab', () => {
const mockEnvironment: Resource<EnvironmentProperties> = {
id: '/planes/radius/local/resourceGroups/test-group/providers/Applications.Core/environments/test-env',
Expand All @@ -35,24 +56,22 @@ describe('EnvironmentOverviewTab', () => {
},
};

it('should render EnvironmentDetailsTable component', () => {
render(<EnvironmentOverviewTab environment={mockEnvironment} />);
it('should render EnvironmentDetailsTable component', async () => {
await renderTab(mockEnvironment);

expect(screen.getByTestId('environment-details-table')).toBeInTheDocument();
expect(screen.getByText('EnvironmentDetailsTable')).toBeInTheDocument();
});

it('should render RecipeTable component with title', () => {
render(<EnvironmentOverviewTab environment={mockEnvironment} />);
it('should render RecipeTable component with title', async () => {
await renderTab(mockEnvironment);

expect(screen.getByTestId('recipe-table')).toBeInTheDocument();
expect(screen.getByText('RecipeTable - Recipes')).toBeInTheDocument();
});

it('should render both components in Grid layout', () => {
const { container } = render(
<EnvironmentOverviewTab environment={mockEnvironment} />,
);
it('should render both components in Grid layout', async () => {
const { container } = await renderTab(mockEnvironment);

// Check that Grid containers are present
const grids = container.querySelectorAll('.MuiGrid-container');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,68 @@
import React from 'react';
import { EnvironmentProperties, Resource } from '../../resources';
import React, { useMemo } from 'react';
import {
EnvironmentProperties,
RecipePackProperties,
Resource,
} from '../../resources';
import { EnvironmentDetailsTable } from './EnvironmentDetailsTable';
import { RecipeTable } from '../recipes/RecipeTable';
import { aggregateRecipesFromEnvironment } from '../recipes/recipeAggregation';
import { Grid } from '@material-ui/core';
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { radiusApiRef } from '../../plugin';

export const EnvironmentOverviewTab = (props: {
environment: Resource<EnvironmentProperties>;
}) => {
const radiusApi = useApi(radiusApiRef);
const packIds = useMemo(
() => props.environment.properties?.recipePacks ?? [],
[props.environment],
);
const needsPacks = packIds.length > 0;

// Recipe packs are not embedded in the environment resource. The environment
// only stores the pack ids; fetch each one individually via getResourceById.
const { value, loading, error } = useAsync(async (): Promise<
Record<string, Resource<RecipePackProperties>>
> => {
if (!needsPacks) {
return {};
}
const results = await Promise.allSettled(
packIds.map(id =>
radiusApi.getResourceById<RecipePackProperties>({ id }),
),
);
const map: Record<string, Resource<RecipePackProperties>> = {};
for (const result of results) {
if (result.status === 'fulfilled') {
map[result.value.id] = result.value;
}
}
return map;
}, [needsPacks, packIds.join(',')]);

const recipes = useMemo(
() => aggregateRecipesFromEnvironment(props.environment, value ?? {}),
[props.environment, value],
);

return (
<Grid container spacing={3} direction="column">
<Grid item>
<EnvironmentDetailsTable environment={props.environment} />
</Grid>
<Grid item>
<RecipeTable environment={props.environment} title="Recipes" />
{needsPacks && loading ? (
<Progress data-testid="progress" />
) : error ? (
<ResponseErrorPanel error={error} />
) : (
<RecipeTable recipes={recipes} title="Recipes" />
)}
</Grid>
</Grid>
);
Expand Down
61 changes: 52 additions & 9 deletions plugins/plugin-radius/src/components/recipes/RecipeListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import {
FormControl,
Grid,
Expand All @@ -15,22 +15,34 @@ import {
ResponseErrorPanel,
} from '@backstage/core-components';
import { radiusApiRef } from '../../plugin';
import { EnvironmentProperties, Resource, ResourceList } from '../../resources';
import {
EnvironmentProperties,
RecipePackProperties,
Resource,
} from '../../resources';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { RecipeTable } from './RecipeTable';
import { aggregateRecipesFromEnvironment } from './recipeAggregation';

export const RecipeListPageContent2 = ({
environments,
packsById,
}: {
environments: Resource<EnvironmentProperties>[];
packsById: Record<string, Resource<RecipePackProperties>>;
}) => {
const first = environments.length > 0 ? environments[0].id : undefined;

const [selected, setSelected] = useState(first);

const env = environments.find(e => e.id === selected);

const recipes = useMemo(
() => (env ? aggregateRecipesFromEnvironment(env, packsById) : []),
[env, packsById],
);

return (
<>
<Grid container item xs={12}>
Expand All @@ -54,7 +66,7 @@ export const RecipeListPageContent2 = ({
</Grid>
<Grid item>
{env ? (
<RecipeTable environment={env} />
<RecipeTable recipes={recipes} />
) : (
<Typography variant="h6">
Select an environment to display recipes.
Expand All @@ -67,11 +79,37 @@ export const RecipeListPageContent2 = ({

export const RecipeListPageContent = () => {
const radiusApi = useApi(radiusApiRef);
const { value, loading, error } = useAsync(
async (): Promise<ResourceList<EnvironmentProperties>> =>
radiusApi.listEnvironments(),
[],
);
const { value, loading, error } = useAsync(async (): Promise<{
environments: Resource<EnvironmentProperties>[];
packsById: Record<string, Resource<RecipePackProperties>>;
}> => {
const environments = await radiusApi.listEnvironments();

// Collect every recipe pack id referenced by any environment, then fetch
// each pack individually via getResourceById. Recipe packs are not part of
// the environment resource itself — the environment only carries the ids.
const packIds = new Set<string>();
for (const env of environments.value) {
for (const id of env.properties?.recipePacks ?? []) {
packIds.add(id);
}
}

const packResults = await Promise.allSettled(
Array.from(packIds).map(id =>
radiusApi.getResourceById<RecipePackProperties>({ id }),
),
);

const packsById: Record<string, Resource<RecipePackProperties>> = {};
for (const result of packResults) {
if (result.status === 'fulfilled') {
packsById[result.value.id] = result.value;
}
}

return { environments: environments.value, packsById };
}, []);

if (loading) {
return <Progress data-testid="progress" />;
Expand All @@ -83,7 +121,12 @@ export const RecipeListPageContent = () => {
throw new Error('This should not happen.');
}

return <RecipeListPageContent2 environments={value.value} />;
return (
<RecipeListPageContent2
environments={value.environments}
packsById={value.packsById}
/>
);
};

export const RecipeListPage = () => {
Expand Down
35 changes: 6 additions & 29 deletions plugins/plugin-radius/src/components/recipes/RecipeTable.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,19 @@
import React from 'react';
import { Table, TableColumn } from '@backstage/core-components';
import { EnvironmentProperties, Resource } from '../../resources';

interface DisplayRecipe {
type: string;
templatePath: string;
templateKind: string;
}
import { DisplayRecipe } from './recipeAggregation';

export const RecipeTable = ({
environment,
recipes,
title,
}: {
environment: Resource<EnvironmentProperties>;
recipes: DisplayRecipe[];
title?: string;
}) => {
const raw = environment.properties?.recipes;

let recipes: DisplayRecipe[] = [];

// Recipes are stored two-levels of nested object, so we need to flatten them out.
// First level is the recipe type, second level is the recipe name.
if (raw) {
recipes = Object.keys(raw).flatMap(recipeType =>
Object.keys(raw[recipeType]).map(recipeName => {
return {
type: recipeType,
name: recipeName,
...raw[recipeType][recipeName],
};
}),
);
}

const columns: TableColumn<DisplayRecipe>[] = [
{ title: 'Recipe Pack', field: 'recipePack' },
{ title: 'Resource Type', field: 'type' },
{ title: 'Recipe Kind', field: 'templateKind' },
{ title: 'Recipe Location', field: 'templatePath' },
{ title: 'Recipe Kind', field: 'recipeKind' },
{ title: 'Recipe Location', field: 'recipeLocation' },
];

return (
Expand Down
Loading
Loading