Merge pull request #10061 from backstage/blam/next-scaffolder-frontend
Introduce the `next` scaffolder frontend
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user