diff --git a/.changeset/rich-maps-hear.md b/.changeset/rich-maps-hear.md new file mode 100644 index 0000000000..390da8b6bb --- /dev/null +++ b/.changeset/rich-maps-hear.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-scaffolder': patch +--- + +Added a new `NextScaffolderRouter` which will eventually replace the exiting router diff --git a/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.tsx b/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.tsx index fdb07658d0..f1278c6f60 100644 --- a/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.tsx +++ b/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.tsx @@ -69,6 +69,7 @@ export const EntitySearchBar = () => { { ); return ( toggleStarredEntity()} diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index d73cb391fc..f1ab23c320 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -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, +) => 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; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 91f67b4669..974c6ce3fc 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -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" ] } diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index bdca2a0952..fc132f644c 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/next/Router/Router.test.tsx b/plugins/scaffolder/src/next/Router/Router.test.tsx new file mode 100644 index 0000000000..5094c786e0 --- /dev/null +++ b/plugins/scaffolder/src/next/Router/Router.test.tsx @@ -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(); + + expect(TemplateListPage).toHaveBeenCalled(); + }); + }); + + describe('/templates/:templateName', () => { + it('should render the TemplateWizard page', async () => { + await renderInTestApp(, { 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( + + + + + , + { 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 }), + ]), + ); + }); + }); +}); diff --git a/plugins/scaffolder/src/next/Router/Router.tsx b/plugins/scaffolder/src/next/Router/Router.tsx new file mode 100644 index 0000000000..580d41d27b --- /dev/null +++ b/plugins/scaffolder/src/next/Router/Router.tsx @@ -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) => { + const { components: { TemplateCardComponent } = {} } = props; + + const outlet = useOutlet() || props.children; + + const customFieldExtensions = useElementFilter(outlet, elements => + elements + .selectByComponentData({ + key: FIELD_EXTENSION_WRAPPER_KEY, + }) + .findComponentData({ + key: FIELD_EXTENSION_KEY, + }), + ); + + const fieldExtensions = [ + ...customFieldExtensions, + ...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter( + ({ name }) => + !customFieldExtensions.some( + customFieldExtension => customFieldExtension.name === name, + ), + ), + ]; + + return ( + + + } + /> + + + + + } + /> + + ); +}; diff --git a/plugins/scaffolder/src/next/Router/index.ts b/plugins/scaffolder/src/next/Router/index.ts new file mode 100644 index 0000000000..dac1db7b3a --- /dev/null +++ b/plugins/scaffolder/src/next/Router/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/next/TemplateListPage/CategoryPicker.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/CategoryPicker.test.tsx new file mode 100644 index 0000000000..f02ca51354 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/CategoryPicker.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + const openButton = getByRole('button', { name: 'Open' }); + await fireEvent(openButton, new MouseEvent('click', { bubbles: true })); + + const fooCheckbox = getByRole('checkbox', { name: 'Foo' }); + expect(fooCheckbox).toBeChecked(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateListPage/CategoryPicker.tsx b/plugins/scaffolder/src/next/TemplateListPage/CategoryPicker.tsx new file mode 100644 index 0000000000..483751f2e2 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/CategoryPicker.tsx @@ -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 = ; +const checkedIcon = ; + +/** + * 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 ; + + if (error) { + alertApi.post({ + message: `Failed to load entity types with error: ${error}`, + severity: 'error', + }); + return null; + } + + if (!availableTypes) return null; + + return ( + + Categories + setSelectedTypes(value)} + renderOption={(option, { selected }) => ( + + } + label={capitalize(option)} + /> + )} + size="small" + popupIcon={} + renderInput={params => } + /> + + ); +}; diff --git a/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.test.tsx new file mode 100644 index 0000000000..e6022d16fb --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(await queryByText('Pick me')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.tsx b/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.tsx new file mode 100644 index 0000000000..8269602703 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.tsx @@ -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>; + +/** + * 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(theme => + theme.breakpoints.down('xs'), + ); + + if (!to || !allowed) { + return null; + } + + return isXSScreen ? ( + + + + ) : ( + + ); +}; diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/CardHeader.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/CardHeader.test.tsx new file mode 100644 index 0000000000..287d813ca7 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/CardHeader.test.tsx @@ -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( + + + + + , + ); + + expect(mockTheme.getPageTheme).toHaveBeenCalledWith({ themeId: 'service' }); + }); + + it('should render the type', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect(getByText('Iamtitle')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/CardHeader.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/CardHeader.tsx new file mode 100644 index 0000000000..3a5785a4f6 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/CardHeader.tsx @@ -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( + () => ({ + 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(); + const themeForType = getPageTheme({ themeId: type }); + + const styles = useStyles({ + cardBackgroundImage: themeForType.backgroundImage, + }); + + const SubtitleComponent = ( +
+
{type}
+
+ +
+
+ ); + + return ( + + ); +}; diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.test.tsx new file mode 100644 index 0000000000..45f4dc6317 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.test.tsx @@ -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( + + + , + { 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( + + + , + { 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( + + + , + { 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( + + + , + { 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( + + + , + { + 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( + + + , + { + mountedRoutes: { + '/': rootRouteRef, + '/catalog/:kind/:namespace/:name': entityRouteRef, + }, + }, + ); + + expect(getByRole('button', { name: 'Choose' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Choose' })).toHaveAttribute( + 'href', + '/templates/bob', + ); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.tsx new file mode 100644 index 0000000000..0e67ecd82a --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.tsx @@ -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(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 ( + + + + + + + {(template.metadata.tags?.length ?? 0) > 0 && ( + <> + + + {template.metadata.tags?.map(tag => ( + + ))} + + + )} + + +
+
+ {ownedByRelations.length > 0 && ( + <> + + + + )} +
+ +
+
+
+ ); +}; diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/index.ts b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/index.ts new file mode 100644 index 0000000000..99af0cbb50 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateGroup.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateGroup.test.tsx new file mode 100644 index 0000000000..59c6989c0d --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateGroup.test.tsx @@ -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(); + + 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(); + + 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( + , + ); + + 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(); + 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( + , + ); + + expect(getByText('Test')).toBeInTheDocument(); + }); + + it('should allow for passing through a user given title component', () => { + const TitleComponent =

Im a custom header

; + const { getByText } = render( + , + ); + + expect(getByText('Im a custom header')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateGroup.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateGroup.tsx new file mode 100644 index 0000000000..4344c195ae --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateGroup.tsx @@ -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; + }; +} + +export const TemplateGroup = (props: TemplateGroupProps) => { + const { templates, title, components: { CardComponent } = {} } = props; + const titleComponent = + typeof title === 'string' ? : title; + + if (templates.length === 0) { + return ( + + {titleComponent} + + No templates found that match your filter. Learn more about{' '} + + adding templates + + . + + + ); + } + + const Card = CardComponent || TemplateCard; + + return ( + + {titleComponent} + + {templates.map(template => ( + + ))} + + + ); +}; diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateGroups.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateGroups.test.tsx new file mode 100644 index 0000000000..2d8cdc3150 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateGroups.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + true }]} /> + , + ); + + 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( + + e.metadata.name === 't1' }]} + /> + , + ); + + expect(TemplateGroup).toHaveBeenCalledWith( + expect.objectContaining({ templates: [mockEntities[0]] }), + {}, + ); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateGroups.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateGroups.tsx new file mode 100644 index 0000000000..f402c0ad19 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateGroups.tsx @@ -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 ; + } + + if (error) { + errorApi.post(error); + return null; + } + + if (!entities || !entities.length) { + return ( + + No templates found that match your filter. Learn more about{' '} + + adding templates + + . + + ); + } + + return ( + <> + {groups.map(({ title, filter }, index) => ( + + filter(e), + )} + title={title} + components={{ CardComponent: TemplateCardComponent }} + /> + ))} + + ); +}; diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.test.tsx new file mode 100644 index 0000000000..e8037e8f98 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.test.tsx @@ -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( + + + , + { mountedRoutes: { '/': rootRouteRef } }, + ); + + expect(getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + it('should render the all and starred filters', async () => { + const { getByRole } = await renderInTestApp( + + + , + { 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( + + + , + { 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( + + + , + ); + + expect(getByText('Tags')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.tsx new file mode 100644 index 0000000000..1b18dce805 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.tsx @@ -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 ( + + +
+ + + + + Create new software components using standard templates. Different + templates create different kinds of components (services, + websites, documentation, ...). + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/scaffolder/src/next/TemplateListPage/index.ts b/plugins/scaffolder/src/next/TemplateListPage/index.ts new file mode 100644 index 0000000000..0436cfeeeb --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateListPage/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.test.tsx new file mode 100644 index 0000000000..37d35c9015 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.test.tsx @@ -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 }) => ( + {children} + ), + }, + ); + expect(result.current.context?.secrets.foo).toEqual(undefined); + + act(() => result.current.hook.setSecret({ foo: 'bar' })); + + expect(result.current.context?.secrets.foo).toEqual('bar'); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.tsx new file mode 100644 index 0000000000..d6ca47d41c --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.tsx @@ -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; + setSecrets: React.Dispatch>>; +}; + +/** + * The actual context object. + */ +export const SecretsContext = createContext( + undefined, +); + +/** + * The Context Provider that holds the state for the secrets. + * + * @alpha + */ +export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => { + const [secrets, setSecrets] = useState>({}); + + return ( + + {children} + + ); +}; + +/** + * 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) => { + setSecrets(currentSecrets => ({ ...currentSecrets, ...input })); + }, + [setSecrets], + ); + + return { setSecret }; +}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/index.ts b/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/index.ts new file mode 100644 index 0000000000..65b530dea1 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx new file mode 100644 index 0000000000..4d160548ca --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx @@ -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[]; +} + +export const TemplateWizardPage = (_props: TemplateWizardPageProps) => { + return null; +}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/index.ts b/plugins/scaffolder/src/next/TemplateWizardPage/index.ts new file mode 100644 index 0000000000..146a7a9afc --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/next/index.ts b/plugins/scaffolder/src/next/index.ts new file mode 100644 index 0000000000..089c268b0b --- /dev/null +++ b/plugins/scaffolder/src/next/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index ffa0efaaa0..9bbb52a71f 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -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, + }), +);