Merge pull request #10061 from backstage/blam/next-scaffolder-frontend

Introduce the `next` scaffolder frontend
This commit is contained in:
Fredrik Adelöw
2022-03-21 17:01:54 +01:00
committed by GitHub
32 changed files with 2181 additions and 3 deletions
+23
View File
@@ -20,6 +20,7 @@ import { JsonObject } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
import { JsonValue } from '@backstage/types';
import { Observable } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -126,6 +127,22 @@ export type LogEvent = {
taskId: string;
};
// @alpha
export type NextRouterProps = {
components?: {
TemplateCardComponent?: React_2.ComponentType<{
template: TemplateEntityV1beta3;
}>;
TaskPageComponent?: React_2.ComponentType<{}>;
};
groups?: TemplateGroupFilter[];
};
// @alpha
export const NextScaffolderPage: (
props: PropsWithChildren<NextRouterProps>,
) => JSX.Element;
// @public
export const OwnedEntityPickerFieldExtension: FieldExtensionComponent<
string,
@@ -354,6 +371,12 @@ export type TaskPageProps = {
loadingText?: string;
};
// @alpha (undocumented)
export type TemplateGroupFilter = {
title?: React_2.ReactNode;
filter: (entity: Entity) => boolean;
};
// @public
export type TemplateParameterSchema = {
title: string;
+5 -3
View File
@@ -9,7 +9,8 @@
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
"types": "dist/index.d.ts",
"alphaTypes": "dist/index.alpha.d.ts"
},
"backstage": {
"role": "frontend-plugin"
@@ -24,7 +25,7 @@
"backstage"
],
"scripts": {
"build": "backstage-cli package build",
"build": "backstage-cli package build --experimental-type-build",
"start": "backstage-cli package start",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
@@ -95,6 +96,7 @@
"msw": "^0.35.0"
},
"files": [
"dist"
"dist",
"alpha"
]
}
+5
View File
@@ -55,6 +55,11 @@ export {
RepoUrlPickerFieldExtension,
ScaffolderPage,
scaffolderPlugin,
NextScaffolderPage,
} from './plugin';
export * from './components';
export type { TaskPageProps } from './components/TaskPage';
/** next exports */
export type { NextRouterProps } from './next';
export type { TemplateGroupFilter } from './next';
@@ -0,0 +1,83 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TemplateListPage } from '../TemplateListPage';
import { TemplateWizardPage } from '../TemplateWizardPage';
import { Router } from './Router';
import { renderInTestApp } from '@backstage/test-utils';
import {
createScaffolderFieldExtension,
ScaffolderFieldExtensions,
} from '../../extensions';
import { scaffolderPlugin } from '../../plugin';
jest.mock('../TemplateListPage', () => ({
TemplateListPage: jest.fn(() => null),
}));
jest.mock('../TemplateWizardPage', () => ({
TemplateWizardPage: jest.fn(() => null),
}));
describe('Router', () => {
beforeEach(() => {
(TemplateWizardPage as jest.Mock).mockClear();
(TemplateListPage as jest.Mock).mockClear();
});
describe('/', () => {
it('should render the TemplateListPage', async () => {
await renderInTestApp(<Router />);
expect(TemplateListPage).toHaveBeenCalled();
});
});
describe('/templates/:templateName', () => {
it('should render the TemplateWizard page', async () => {
await renderInTestApp(<Router />, { routeEntries: ['/templates/foo'] });
expect(TemplateWizardPage).toHaveBeenCalled();
});
it('should extract the fieldExtensions and pass them through', async () => {
const mockComponent = () => null;
const CustomFieldExtension = scaffolderPlugin.provide(
createScaffolderFieldExtension({
name: 'custom',
component: mockComponent,
}),
);
await renderInTestApp(
<Router>
<ScaffolderFieldExtensions>
<CustomFieldExtension />
</ScaffolderFieldExtensions>
</Router>,
{ routeEntries: ['/templates/foo'] },
);
const mock = TemplateWizardPage as jest.Mock;
const [{ customFieldExtensions }] = mock.mock.calls[0];
expect(customFieldExtensions).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'custom', component: mockComponent }),
]),
);
});
});
});
@@ -0,0 +1,99 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import { Routes, Route, useOutlet } from 'react-router';
import { TemplateListPage } from '../TemplateListPage';
import { SecretsContextProvider } from '../TemplateWizardPage/SecretsContext';
import { TemplateWizardPage } from '../TemplateWizardPage';
import {
FieldExtensionOptions,
FIELD_EXTENSION_WRAPPER_KEY,
FIELD_EXTENSION_KEY,
DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS,
} from '../../extensions';
import { useElementFilter } from '@backstage/core-plugin-api';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '../TemplateListPage/TemplateGroups';
/**
* The Props for the Scaffolder Router
*
* @alpha
*/
export type NextRouterProps = {
components?: {
TemplateCardComponent?: React.ComponentType<{
template: TemplateEntityV1beta3;
}>;
TaskPageComponent?: React.ComponentType<{}>;
};
groups?: TemplateGroupFilter[];
};
/**
* The Scaffolder Router
*
* @alpha
*/
export const Router = (props: PropsWithChildren<NextRouterProps>) => {
const { components: { TemplateCardComponent } = {} } = props;
const outlet = useOutlet() || props.children;
const customFieldExtensions = useElementFilter(outlet, elements =>
elements
.selectByComponentData({
key: FIELD_EXTENSION_WRAPPER_KEY,
})
.findComponentData<FieldExtensionOptions>({
key: FIELD_EXTENSION_KEY,
}),
);
const fieldExtensions = [
...customFieldExtensions,
...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter(
({ name }) =>
!customFieldExtensions.some(
customFieldExtension => customFieldExtension.name === name,
),
),
];
return (
<Routes>
<Route
path="/"
element={
<TemplateListPage
TemplateCardComponent={TemplateCardComponent}
groups={props.groups}
/>
}
/>
<Route
path="/templates/:templateName"
element={
<SecretsContextProvider>
<TemplateWizardPage customFieldExtensions={fieldExtensions} />
</SecretsContextProvider>
}
/>
</Routes>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Router } from './Router';
export type { NextRouterProps } from './Router';
@@ -0,0 +1,152 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useEntityTypeFilter } from '@backstage/plugin-catalog-react';
import { CategoryPicker } from './CategoryPicker';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { alertApiRef } from '@backstage/core-plugin-api';
import { fireEvent } from '@testing-library/react';
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntityTypeFilter: jest.fn(),
}));
describe('CategoryPicker', () => {
const mockAlertApi = { post: jest.fn() };
beforeEach(() => {
mockAlertApi.post.mockClear();
});
it('should post the error to errorApi if an errors is returned', async () => {
(useEntityTypeFilter as jest.Mock).mockReturnValue({
error: new Error('something broked'),
});
await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
expect(mockAlertApi.post).toHaveBeenCalledWith({
message: expect.stringContaining('something broked'),
severity: 'error',
});
});
it('should render loading if the hook is loading', async () => {
(useEntityTypeFilter as jest.Mock).mockReturnValue({
loading: true,
});
const { findByTestId } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
expect(await findByTestId('progress')).toBeInTheDocument();
});
it('should not render if there is no available types', async () => {
(useEntityTypeFilter as jest.Mock).mockReturnValue({
availableTypes: null,
});
const { queryByText } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
expect(queryByText('Categories')).not.toBeInTheDocument();
});
it('renders the autocomplete with the availableTypes', async () => {
const mockAvailableTypes = ['foo', 'bar'];
(useEntityTypeFilter as jest.Mock).mockReturnValue({
availableTypes: mockAvailableTypes,
});
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
const openButton = getByRole('button', { name: 'Open' });
openButton.click();
expect(getByRole('checkbox', { name: 'Foo' })).toBeInTheDocument();
expect(getByRole('checkbox', { name: 'Bar' })).toBeInTheDocument();
});
it('should call setSelectedTypes when one of the options are called', async () => {
const mockAvailableTypes = ['foo', 'bar'];
const mockSetSelectedTypes = jest.fn();
(useEntityTypeFilter as jest.Mock).mockReturnValue({
availableTypes: mockAvailableTypes,
setSelectedTypes: mockSetSelectedTypes,
});
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
const openButton = getByRole('button', { name: 'Open' });
await fireEvent(openButton, new MouseEvent('click', { bubbles: true }));
const fooCheckbox = getByRole('checkbox', { name: 'Foo' });
await fireEvent(fooCheckbox, new MouseEvent('click', { bubbles: true }));
expect(mockSetSelectedTypes).toHaveBeenCalledWith(['foo']);
await fireEvent(openButton, new MouseEvent('click', { bubbles: true }));
const barCheckbox = getByRole('checkbox', { name: 'Bar' });
await fireEvent(barCheckbox, new MouseEvent('click', { bubbles: true }));
expect(mockSetSelectedTypes).toHaveBeenCalledWith(['foo', 'bar']);
});
it('should render the selectedTypes already in the document', async () => {
const mockAvailableTypes = ['foo', 'bar'];
const mockSelectedTypes = ['foo'];
(useEntityTypeFilter as jest.Mock).mockReturnValue({
availableTypes: mockAvailableTypes,
selectedTypes: mockSelectedTypes,
});
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
const openButton = getByRole('button', { name: 'Open' });
await fireEvent(openButton, new MouseEvent('click', { bubbles: true }));
const fooCheckbox = getByRole('checkbox', { name: 'Foo' });
expect(fooCheckbox).toBeChecked();
});
});
@@ -0,0 +1,85 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import capitalize from 'lodash/capitalize';
import { Progress } from '@backstage/core-components';
import {
Box,
Checkbox,
FormControlLabel,
TextField,
Typography,
} from '@material-ui/core';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Autocomplete } from '@material-ui/lab';
import { useEntityTypeFilter } from '@backstage/plugin-catalog-react';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
/**
* The Category Picker that is rendered on the left side for picking
* categories and filtering the template list.
*/
export const CategoryPicker = () => {
const alertApi = useApi(alertApiRef);
const { error, loading, availableTypes, selectedTypes, setSelectedTypes } =
useEntityTypeFilter();
if (loading) return <Progress />;
if (error) {
alertApi.post({
message: `Failed to load entity types with error: ${error}`,
severity: 'error',
});
return null;
}
if (!availableTypes) return null;
return (
<Box pb={1} pt={1}>
<Typography variant="button">Categories</Typography>
<Autocomplete
multiple
aria-label="Categories"
options={availableTypes}
value={selectedTypes}
onChange={(_: object, value: string[]) => setSelectedTypes(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
label={capitalize(option)}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon />}
renderInput={params => <TextField {...params} variant="outlined" />}
/>
</Box>
);
};
@@ -0,0 +1,57 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { RegisterExistingButton } from './RegisterExistingButton';
import { usePermission } from '@backstage/plugin-permission-react';
jest.mock('@backstage/plugin-permission-react', () => ({
usePermission: jest.fn(),
}));
describe('RegisterExistingButton', () => {
beforeEach(() => {
(usePermission as jest.Mock).mockClear();
});
it('should not render if to is unset', async () => {
(usePermission as jest.Mock).mockReturnValue({ allowed: true });
const { queryByText } = await renderInTestApp(
<RegisterExistingButton title="Pick me" />,
);
expect(await queryByText('Pick me')).not.toBeInTheDocument();
});
it('should not render if permissions are not allowed', async () => {
(usePermission as jest.Mock).mockReturnValue({ allowed: false });
const { queryByText } = await renderInTestApp(
<RegisterExistingButton title="Pick me" to="blah" />,
);
expect(await queryByText('Pick me')).not.toBeInTheDocument();
});
it('should render the button with the text', async () => {
(usePermission as jest.Mock).mockReturnValue({ allowed: true });
const { queryByText } = await renderInTestApp(
<RegisterExistingButton title="Pick me" to="blah" />,
);
expect(await queryByText('Pick me')).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BackstageTheme } from '@backstage/theme';
import Button from '@material-ui/core/Button';
import IconButton from '@material-ui/core/IconButton';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import React from 'react';
import { Link as RouterLink, LinkProps } from 'react-router-dom';
import AddCircleOutline from '@material-ui/icons/AddCircleOutline';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common';
import { usePermission } from '@backstage/plugin-permission-react';
/**
* Properties for {@link RegisterExistingButton}
*
* @alpha
*/
export type RegisterExistingButtonProps = {
title: string;
} & Partial<Pick<LinkProps, 'to'>>;
/**
* A button that helps users to register an existing component.
* @alpha
*/
export const RegisterExistingButton = (props: RegisterExistingButtonProps) => {
const { title, to } = props;
const { allowed } = usePermission(catalogEntityCreatePermission);
const isXSScreen = useMediaQuery<BackstageTheme>(theme =>
theme.breakpoints.down('xs'),
);
if (!to || !allowed) {
return null;
}
return isXSScreen ? (
<IconButton
component={RouterLink}
color="primary"
title={title}
size="small"
to={to}
>
<AddCircleOutline />
</IconButton>
) : (
<Button component={RouterLink} variant="contained" color="primary" to={to}>
{title}
</Button>
);
};
@@ -0,0 +1,188 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { CardHeader } from './CardHeader';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import {
MockStorageApi,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { starredEntitiesApiRef } from '@backstage/plugin-catalog-react';
import { DefaultStarredEntitiesApi } from '@backstage/plugin-catalog';
import Observable from 'zen-observable';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
describe('CardHeader', () => {
it('should select the correct theme from the theme provider from the header', () => {
// Can't really test what we want here.
// But we can check that we call the getPage theme with the right type of template at least.
const mockTheme = {
...lightTheme,
getPageTheme: jest.fn(lightTheme.getPageTheme),
};
render(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<ThemeProvider theme={mockTheme}>
<CardHeader
template={{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
}}
/>
</ThemeProvider>
</TestApiProvider>,
);
expect(mockTheme.getPageTheme).toHaveBeenCalledWith({ themeId: 'service' });
});
it('should render the type', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<CardHeader
template={{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
}}
/>
</TestApiProvider>,
);
expect(getByText('service')).toBeInTheDocument();
});
it('should enable favoriting of the entity', async () => {
const starredEntitiesApi = {
starredEntitie$: () => new Observable(() => {}),
toggleStarred: jest.fn(async () => {}),
};
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
};
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[starredEntitiesApiRef, starredEntitiesApi]]}>
<CardHeader template={mockTemplate} />
</TestApiProvider>,
);
const favorite = getByRole('button', { name: 'favorite' });
await fireEvent.click(favorite);
expect(starredEntitiesApi.toggleStarred).toHaveBeenCalledWith(
stringifyEntityRef(mockTemplate),
);
});
it('should render the name of the entity', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<CardHeader
template={{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
}}
/>
</TestApiProvider>,
);
expect(getByText('bob')).toBeInTheDocument();
});
it('should render the title of the entity in favor of the name if it is provided', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<CardHeader
template={{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', title: 'Iamtitle' },
spec: {
steps: [],
type: 'service',
},
}}
/>
</TestApiProvider>,
);
expect(getByText('Iamtitle')).toBeInTheDocument();
});
});
@@ -0,0 +1,76 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { makeStyles, useTheme } from '@material-ui/core';
import { ItemCardHeader } from '@backstage/core-components';
import { BackstageTheme } from '@backstage/theme';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { FavoriteEntity } from '@backstage/plugin-catalog-react';
const useStyles = makeStyles<BackstageTheme, { cardBackgroundImage: string }>(
() => ({
header: {
backgroundImage: ({ cardBackgroundImage }) => cardBackgroundImage,
},
subtitleWrapper: {
display: 'flex',
justifyContent: 'space-between',
},
}),
);
/**
* Props for the CardHeader component
*/
export interface CardHeaderProps {
template: TemplateEntityV1beta3;
}
/**
* The Card Header with the background for the TemplateCard.
*/
export const CardHeader = (props: CardHeaderProps) => {
const {
template: {
metadata: { title, name },
spec: { type },
},
} = props;
const { getPageTheme } = useTheme<BackstageTheme>();
const themeForType = getPageTheme({ themeId: type });
const styles = useStyles({
cardBackgroundImage: themeForType.backgroundImage,
});
const SubtitleComponent = (
<div className={styles.subtitleWrapper}>
<div>{type}</div>
<div>
<FavoriteEntity entity={props.template} style={{ padding: 0 }} />
</div>
</div>
);
return (
<ItemCardHeader
title={title ?? name}
subtitle={SubtitleComponent}
classes={{ root: styles.header }}
/>
);
};
@@ -0,0 +1,239 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DefaultStarredEntitiesApi } from '@backstage/plugin-catalog';
import {
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import {
MockStorageApi,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { TemplateCard } from './TemplateCard';
import React from 'react';
import { rootRouteRef } from '../../../routes';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
describe('TemplateCard', () => {
it('should render the card title', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByText('bob')).toBeInTheDocument();
});
it('should render the description as markdown', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', description: 'hello **test**' },
spec: {
steps: [],
type: 'service',
},
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
const description = getByText('hello');
expect(description.querySelector('strong')).toBeInTheDocument();
});
it('should render no descroption if none is provided through the template', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByText('No description')).toBeInTheDocument();
});
it('should render the tags', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', tags: ['cpp', 'react'] },
spec: {
steps: [],
type: 'service',
},
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
for (const tag of mockTemplate.metadata.tags!) {
expect(getByText(tag)).toBeInTheDocument();
}
});
it('should render a link to the owner', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', tags: ['cpp', 'react'] },
spec: {
steps: [],
type: 'service',
},
relations: [
{
targetRef: 'group:default/my-test-user',
type: RELATION_OWNED_BY,
},
],
};
const { getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{
mountedRoutes: {
'/': rootRouteRef,
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
);
expect(getByRole('link', { name: 'my-test-user' })).toBeInTheDocument();
expect(getByRole('link', { name: 'my-test-user' })).toHaveAttribute(
'href',
'/catalog/group/default/my-test-user',
);
});
it('should render the choose button to navigate to the selected template', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', tags: ['cpp', 'react'] },
spec: {
steps: [],
type: 'service',
},
};
const { getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{
mountedRoutes: {
'/': rootRouteRef,
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
);
expect(getByRole('button', { name: 'Choose' })).toBeInTheDocument();
expect(getByRole('button', { name: 'Choose' })).toHaveAttribute(
'href',
'/templates/bob',
);
});
});
@@ -0,0 +1,136 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import {
Box,
Card,
CardActions,
CardContent,
Chip,
Divider,
makeStyles,
} from '@material-ui/core';
import { CardHeader } from './CardHeader';
import { MarkdownContent, UserIcon, Button } from '@backstage/core-components';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
EntityRefLinks,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import { useRouteRef } from '@backstage/core-plugin-api';
import { selectedTemplateRouteRef } from '../../../routes';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>(theme => ({
box: {
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
'-webkit-line-clamp': 10,
'-webkit-box-orient': 'vertical',
/** to make the styles for React Markdown not leak into the description */
'& p:first-child': {
marginTop: 0,
marginBottom: theme.spacing(2),
},
},
label: {
color: theme.palette.text.secondary,
textTransform: 'uppercase',
fontWeight: 'bold',
letterSpacing: 0.5,
lineHeight: 1,
fontSize: '0.75rem',
},
margin: {
marginBottom: theme.spacing(2),
},
footer: {
display: 'flex',
justifyContent: 'space-between',
flex: 1,
alignItems: 'center',
},
ownedBy: {
display: 'flex',
alignItems: 'center',
flex: 1,
color: theme.palette.link,
},
}));
/**
* The Props for the Template Card component
* @alpha
*/
export interface TemplateCardProps {
template: TemplateEntityV1beta3;
deprecated?: boolean;
}
/**
* The Template Card component that is rendered in a list for each template
* @alpha
*/
export const TemplateCard = (props: TemplateCardProps) => {
const { template } = props;
const styles = useStyles();
const ownedByRelations = getEntityRelations(template, RELATION_OWNED_BY);
const templateRoute = useRouteRef(selectedTemplateRouteRef);
const href = templateRoute({ templateName: template.metadata.name });
return (
<Card>
<CardHeader template={template} />
<CardContent>
<Box className={styles.box}>
<MarkdownContent
content={template.metadata.description ?? 'No description'}
/>
</Box>
{(template.metadata.tags?.length ?? 0) > 0 && (
<>
<Divider className={styles.margin} />
<Box>
{template.metadata.tags?.map(tag => (
<Chip size="small" label={tag} key={tag} />
))}
</Box>
</>
)}
</CardContent>
<CardActions>
<div className={styles.footer}>
<div className={styles.ownedBy}>
{ownedByRelations.length > 0 && (
<>
<UserIcon />
<EntityRefLinks
entityRefs={ownedByRelations}
defaultKind="Group"
/>
</>
)}
</div>
<Button size="small" variant="outlined" color="primary" to={href}>
Choose
</Button>
</div>
</CardActions>
</Card>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TemplateCard } from './TemplateCard';
export type { TemplateCardProps } from './TemplateCard';
@@ -0,0 +1,143 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('./TemplateCard', () => ({ TemplateCard: jest.fn(() => null) }));
import React from 'react';
import { TemplateGroup } from './TemplateGroup';
import { render } from '@testing-library/react';
import { TemplateCard } from './TemplateCard';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
describe('TemplateGroup', () => {
it('should return a message when no templates are passed in', async () => {
const { getByText } = render(<TemplateGroup title="Test" templates={[]} />);
expect(
getByText(/No templates found that match your filter/),
).toBeInTheDocument();
});
it('should render a card for each template with the template being passed as a prop', () => {
const mockTemplates: TemplateEntityV1beta3[] = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test' },
spec: {
parameters: [],
steps: [],
type: 'website',
},
},
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test2' },
spec: {
parameters: [],
steps: [],
type: 'service',
},
},
];
render(<TemplateGroup title="Test" templates={mockTemplates} />);
expect(TemplateCard).toHaveBeenCalledTimes(2);
for (const template of mockTemplates) {
expect(TemplateCard).toHaveBeenCalledWith(
expect.objectContaining({ template }),
{},
);
}
});
it('should use the passed in TemplateCard prop to render the template card', () => {
const mockTemplateCardComponent = jest.fn(() => null);
const mockTemplates: TemplateEntityV1beta3[] = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test' },
spec: {
parameters: [],
steps: [],
type: 'website',
},
},
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test2' },
spec: {
parameters: [],
steps: [],
type: 'service',
},
},
];
render(
<TemplateGroup
title="Test"
templates={mockTemplates}
components={{ CardComponent: mockTemplateCardComponent }}
/>,
);
expect(mockTemplateCardComponent).toHaveBeenCalledTimes(2);
for (const template of mockTemplates) {
expect(mockTemplateCardComponent).toHaveBeenCalledWith(
expect.objectContaining({ template }),
{},
);
}
});
it('should render the title when no templates passed', () => {
const { getByText } = render(<TemplateGroup title="Test" templates={[]} />);
expect(getByText('Test')).toBeInTheDocument();
});
it('should render the title when there are templates in the list', () => {
const mockTemplates: TemplateEntityV1beta3[] = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test' },
spec: { parameters: [], steps: [], type: 'website' },
},
];
const { getByText } = render(
<TemplateGroup title="Test" templates={mockTemplates} />,
);
expect(getByText('Test')).toBeInTheDocument();
});
it('should allow for passing through a user given title component', () => {
const TitleComponent = <p>Im a custom header</p>;
const { getByText } = render(
<TemplateGroup templates={[]} title={TitleComponent} />,
);
expect(getByText('Im a custom header')).toBeInTheDocument();
});
});
@@ -0,0 +1,68 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import React from 'react';
import {
Content,
ContentHeader,
ItemCardGrid,
Link,
} from '@backstage/core-components';
import { Typography } from '@material-ui/core';
import { TemplateCard, TemplateCardProps } from './TemplateCard';
import { stringifyEntityRef } from '@backstage/catalog-model';
export interface TemplateGroupProps {
templates: TemplateEntityV1beta3[];
title: React.ReactNode;
components?: {
CardComponent?: React.ComponentType<TemplateCardProps>;
};
}
export const TemplateGroup = (props: TemplateGroupProps) => {
const { templates, title, components: { CardComponent } = {} } = props;
const titleComponent =
typeof title === 'string' ? <ContentHeader title={title} /> : title;
if (templates.length === 0) {
return (
<Content>
{titleComponent}
<Typography variant="body2">
No templates found that match your filter. Learn more about{' '}
<Link to="https://backstage.io/docs/features/software-templates/adding-templates">
adding templates
</Link>
.
</Typography>
</Content>
);
}
const Card = CardComponent || TemplateCard;
return (
<Content>
{titleComponent}
<ItemCardGrid>
{templates.map(template => (
<Card key={stringifyEntityRef(template)} template={template} />
))}
</ItemCardGrid>
</Content>
);
};
@@ -0,0 +1,172 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntityList: jest.fn(),
}));
jest.mock('./TemplateGroup', () => ({
TemplateGroup: jest.fn(() => null),
}));
import React from 'react';
import { render } from '@testing-library/react';
import { useEntityList } from '@backstage/plugin-catalog-react';
import { TemplateGroups } from './TemplateGroups';
import { TestApiProvider } from '@backstage/test-utils';
import { errorApiRef } from '@backstage/core-plugin-api';
import { TemplateGroup } from './TemplateGroup';
describe('TemplateGroups', () => {
it('should return progress if the hook is loading', async () => {
(useEntityList as jest.Mock).mockReturnValue({ loading: true });
const { findByTestId } = render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
);
expect(await findByTestId('progress')).toBeInTheDocument();
});
it('should use the error api if there is an error with the retrieval of entitylist', async () => {
const mockError = new Error('tings went poop');
(useEntityList as jest.Mock).mockReturnValue({
error: mockError,
});
const errorApi = {
post: jest.fn(),
};
render(
<TestApiProvider apis={[[errorApiRef, errorApi]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
);
expect(errorApi.post).toHaveBeenCalledWith(mockError);
});
it('should return a no templates message if entities is unset', async () => {
(useEntityList as jest.Mock).mockReturnValue({
entities: null,
loading: false,
error: null,
});
const { findByText } = render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
);
expect(await findByText(/No templates found/)).toBeInTheDocument();
});
it('should return a no templates message if entities has no values in it', async () => {
(useEntityList as jest.Mock).mockReturnValue({
entities: [],
loading: false,
error: null,
});
const { findByText } = render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
);
expect(await findByText(/No templates found/)).toBeInTheDocument();
});
it('should call the template group with the components', async () => {
const mockEntities = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 't1',
},
spec: {},
},
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 't2',
},
spec: {},
},
];
(useEntityList as jest.Mock).mockReturnValue({
entities: mockEntities,
loading: false,
error: null,
});
render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[{ title: 'all', filter: () => true }]} />
</TestApiProvider>,
);
expect(TemplateGroup).toHaveBeenCalledWith(
expect.objectContaining({ templates: mockEntities }),
{},
);
});
it('should apply the filter for each group', async () => {
const mockEntities = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 't1',
},
spec: {},
},
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 't2',
},
spec: {},
},
];
(useEntityList as jest.Mock).mockReturnValue({
entities: mockEntities,
loading: false,
error: null,
});
render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups
groups={[{ title: 'all', filter: e => e.metadata.name === 't1' }]}
/>
</TestApiProvider>,
);
expect(TemplateGroup).toHaveBeenCalledWith(
expect.objectContaining({ templates: [mockEntities[0]] }),
{},
);
});
});
@@ -0,0 +1,80 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TemplateGroup } from './TemplateGroup';
import { Entity } from '@backstage/catalog-model';
import { useEntityList } from '@backstage/plugin-catalog-react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { Progress, Link } from '@backstage/core-components';
import { Typography } from '@material-ui/core';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
/**
* @alpha
*/
export type TemplateGroupFilter = {
title?: React.ReactNode;
filter: (entity: Entity) => boolean;
};
export interface TemplateGroupsProps {
groups: TemplateGroupFilter[];
TemplateCardComponent?: React.ComponentType<{
template: TemplateEntityV1beta3;
}>;
}
export const TemplateGroups = (props: TemplateGroupsProps) => {
const { loading, error, entities } = useEntityList();
const { groups, TemplateCardComponent } = props;
const errorApi = useApi(errorApiRef);
if (loading) {
return <Progress />;
}
if (error) {
errorApi.post(error);
return null;
}
if (!entities || !entities.length) {
return (
<Typography variant="body2">
No templates found that match your filter. Learn more about{' '}
<Link to="https://backstage.io/docs/features/software-templates/adding-templates">
adding templates
</Link>
.
</Typography>
);
}
return (
<>
{groups.map(({ title, filter }, index) => (
<TemplateGroup
key={index}
templates={entities.filter((e): e is TemplateEntityV1beta3 =>
filter(e),
)}
title={title}
components={{ CardComponent: TemplateCardComponent }}
/>
))}
</>
);
};
@@ -0,0 +1,138 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DefaultStarredEntitiesApi } from '@backstage/plugin-catalog';
import {
catalogApiRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import {
MockStorageApi,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import React from 'react';
import { rootRouteRef } from '../../routes';
import { TemplateListPage } from './TemplateListPage';
describe('TemplateListPage', () => {
const mockCatalogApi = {
getEntities: async () => ({
items: [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'blob', tags: ['blob'] },
spec: {
type: 'service',
},
},
],
}),
getEntityFacets: async () => ({
facets: { 'spec.type': [{ value: 'service', count: 1 }] },
}),
};
it('should render the search bar for templates', async () => {
const { getByPlaceholderText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
[permissionApiRef, {}],
]}
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByPlaceholderText('Search')).toBeInTheDocument();
});
it('should render the all and starred filters', async () => {
const { getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
[permissionApiRef, {}],
]}
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByRole('menuitem', { name: 'All' })).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'Starred' })).toBeInTheDocument();
});
it('should render the category picker', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
[permissionApiRef, {}],
]}
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByText('Categories')).toBeInTheDocument();
});
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should render the EntityTag picker', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
[permissionApiRef, {}],
]}
>
<TemplateListPage />
</TestApiProvider>,
);
expect(getByText('Tags')).toBeInTheDocument();
});
});
@@ -0,0 +1,100 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import {
Content,
ContentHeader,
Header,
Page,
SupportButton,
} from '@backstage/core-components';
import {
EntityKindPicker,
EntityListProvider,
EntitySearchBar,
EntityTagPicker,
CatalogFilterLayout,
UserListPicker,
} from '@backstage/plugin-catalog-react';
import { CategoryPicker } from './CategoryPicker';
import { RegisterExistingButton } from './RegisterExistingButton';
import { useRouteRef } from '@backstage/core-plugin-api';
import { registerComponentRouteRef } from '../../routes';
import { TemplateGroupFilter, TemplateGroups } from './TemplateGroups';
export type TemplateListPageProps = {
TemplateCardComponent?: React.ComponentType<{
template: TemplateEntityV1beta3;
}>;
groups?: TemplateGroupFilter[];
};
const defaultGroup: TemplateGroupFilter = {
title: 'All Templates',
filter: () => true,
};
export const TemplateListPage = (props: TemplateListPageProps) => {
const registerComponentLink = useRouteRef(registerComponentRouteRef);
const { TemplateCardComponent, groups = [defaultGroup] } = props;
return (
<EntityListProvider>
<Page themeId="home">
<Header
pageTitleOverride="Create a New Component"
title="Create a New Component"
subtitle="Create new software components using standard templates"
/>
<Content>
<ContentHeader title="Available Templates">
<RegisterExistingButton
title="Register Existing Component"
to={registerComponentLink && registerComponentLink()}
/>
<SupportButton>
Create new software components using standard templates. Different
templates create different kinds of components (services,
websites, documentation, ...).
</SupportButton>
</ContentHeader>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<EntitySearchBar />
<EntityKindPicker initialFilter="template" hidden />
<UserListPicker
initialFilter="all"
availableFilters={['all', 'starred']}
/>
<CategoryPicker />
<EntityTagPicker />
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<TemplateGroups
groups={groups}
TemplateCardComponent={TemplateCardComponent}
/>
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
</Content>
</Page>
</EntityListProvider>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TemplateListPage } from './TemplateListPage';
export type { TemplateGroupFilter } from './TemplateGroups';
@@ -0,0 +1,43 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
useTemplateSecrets,
SecretsContextProvider,
SecretsContext,
} from './SecretsContext';
import { renderHook, act } from '@testing-library/react-hooks';
describe('SecretsContext', () => {
it('should allow the setting of secrets in the context', async () => {
const { result } = renderHook(
() => ({
hook: useTemplateSecrets(),
context: useContext(SecretsContext),
}),
{
wrapper: ({ children }) => (
<SecretsContextProvider>{children}</SecretsContextProvider>
),
},
);
expect(result.current.context?.secrets.foo).toEqual(undefined);
act(() => result.current.hook.setSecret({ foo: 'bar' }));
expect(result.current.context?.secrets.foo).toEqual('bar');
});
});
@@ -0,0 +1,73 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
useState,
useCallback,
useContext,
createContext,
PropsWithChildren,
} from 'react';
type SecretsContextContents = {
secrets: Record<string, string>;
setSecrets: React.Dispatch<React.SetStateAction<Record<string, string>>>;
};
/**
* The actual context object.
*/
export const SecretsContext = createContext<SecretsContextContents | undefined>(
undefined,
);
/**
* The Context Provider that holds the state for the secrets.
*
* @alpha
*/
export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => {
const [secrets, setSecrets] = useState<Record<string, string>>({});
return (
<SecretsContext.Provider value={{ secrets, setSecrets }}>
{children}
</SecretsContext.Provider>
);
};
/**
* Hook to access the secrets context.
* @alpha
*/
export const useTemplateSecrets = () => {
const value = useContext(SecretsContext);
if (!value) {
throw new Error(
'useTemplateSecrets must be used within a SecretsContextProvider',
);
}
const { setSecrets } = value;
const setSecret = useCallback(
(input: Record<string, string>) => {
setSecrets(currentSecrets => ({ ...currentSecrets, ...input }));
},
[setSecrets],
);
return { setSecret };
};
@@ -0,0 +1,20 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
useTemplateSecrets,
SecretsContext,
SecretsContextProvider,
} from './SecretsContext';
@@ -0,0 +1,25 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FieldExtensionOptions } from '../../extensions';
export interface TemplateWizardPageProps {
customFieldExtensions: FieldExtensionOptions<any, any>[];
}
export const TemplateWizardPage = (_props: TemplateWizardPageProps) => {
return null;
};
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TemplateWizardPage } from './TemplateWizardPage';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './Router';
export * from './TemplateListPage';
export * from './TemplateWizardPage';
+12
View File
@@ -150,3 +150,15 @@ export const EntityTagsPickerFieldExtension = scaffolderPlugin.provide(
name: 'EntityTagsPicker',
}),
);
/**
* @alpha
* The Router and main entrypoint to the Alpha Scaffolder plugin.
*/
export const NextScaffolderPage = scaffolderPlugin.provide(
createRoutableExtension({
name: 'NextScaffolderPage',
component: () => import('./next/Router').then(m => m.Router),
mountPoint: rootRouteRef,
}),
);