diff --git a/.changeset/eighty-mice-turn.md b/.changeset/eighty-mice-turn.md new file mode 100644 index 0000000000..cec3835641 --- /dev/null +++ b/.changeset/eighty-mice-turn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Add tests for the `TemplateEditorToolbarTemplatesMenu` component. diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.test.tsx new file mode 100644 index 0000000000..5e6acc1712 --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.test.tsx @@ -0,0 +1,318 @@ +/* + * Copyright 2024 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 { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderInTestApp } from '@backstage/test-utils'; +import { + TemplateEditorToolbarTemplatesMenu, + TemplateOption, +} from './TemplateEditorToolbarTemplatesMenu'; + +describe('TemplateEditorToolbarTemplatesMenu', () => { + const options: TemplateOption[] = [ + { + label: 'Create React App Template', + value: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + namespace: 'default', + name: 'create-react-app-template', + title: 'Create React App Template', + }, + spec: { + parameters: [ + { + title: 'Provide some simple information', + required: ['component_id', 'owner'], + properties: { + component_id: { + title: 'Name', + type: 'string', + description: 'Unique name of the component', + 'ui:field': 'EntityNamePicker', + }, + description: { + title: 'Description', + type: 'string', + description: + 'Help others understand what this website is for.', + }, + owner: { + title: 'Owner', + type: 'string', + description: 'Owner of the component', + 'ui:field': 'OwnerPicker', + 'ui:options': { + allowedKinds: ['Group'], + }, + }, + }, + }, + { + title: 'Choose a location', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + 'ui:field': 'RepoUrlPicker', + 'ui:options': { + allowedHosts: ['github.com'], + }, + }, + }, + }, + ], + steps: [ + { + id: 'template', + name: 'Fetch Skeleton + Template', + action: 'fetch:template', + input: { + url: './skeleton', + copyWithoutRender: ['.github/workflows/*'], + values: { + component_id: '${{ parameters.component_id }}', + description: '${{ parameters.description }}', + destination: '${{ parameters.repoUrl | parseRepoUrl }}', + owner: '${{ parameters.owner }}', + }, + }, + }, + { + id: 'publish', + name: 'Publish', + action: 'publish:github', + input: { + allowedHosts: ['github.com'], + description: 'This is ${{ parameters.component_id }}', + repoUrl: '${{ parameters.repoUrl }}', + }, + }, + { + id: 'register', + name: 'Register', + action: 'catalog:register', + input: { + repoContentsUrl: '${{ steps.publish.output.repoContentsUrl }}', + catalogInfoPath: '/catalog-info.yaml', + }, + }, + ], + output: { + links: [ + { + title: 'Repository', + url: '${{ steps.publish.output.remoteUrl }}', + }, + { + title: 'Open in catalog', + icon: 'catalog', + entityRef: '${{ steps.register.output.entityRef }}', + }, + ], + }, + }, + }, + }, + { + label: 'React SSR Template', + value: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + namespace: 'default', + name: 'react-ssr-template', + title: 'React SSR Template', + }, + spec: { + parameters: [ + { + title: 'Provide some simple information', + required: ['component_id', 'owner'], + properties: { + component_id: { + title: 'Name', + type: 'string', + description: 'Unique name of the component', + 'ui:field': 'EntityNamePicker', + }, + description: { + title: 'Description', + type: 'string', + description: + 'Help others understand what this website is for.', + }, + owner: { + title: 'Owner', + type: 'string', + description: 'Owner of the component', + 'ui:field': 'OwnerPicker', + 'ui:options': { + allowedKinds: ['Group'], + }, + }, + }, + }, + { + title: 'Choose a location', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + 'ui:field': 'RepoUrlPicker', + 'ui:options': { + allowedHosts: ['github.com'], + }, + }, + }, + }, + ], + steps: [ + { + id: 'template', + name: 'Fetch Skeleton + Template', + action: 'fetch:template', + input: { + url: './skeleton', + copyWithoutRender: ['.github/workflows/*'], + values: { + component_id: '${{ parameters.component_id }}', + description: '${{ parameters.description }}', + destination: '${{ parameters.repoUrl | parseRepoUrl }}', + owner: '${{ parameters.owner }}', + }, + }, + }, + { + id: 'publish', + name: 'Publish', + action: 'publish:github', + input: { + allowedHosts: ['github.com'], + description: 'This is ${{ parameters.component_id }}', + repoUrl: '${{ parameters.repoUrl }}', + }, + }, + { + id: 'register', + name: 'Register', + action: 'catalog:register', + input: { + repoContentsUrl: '${{ steps.publish.output.repoContentsUrl }}', + catalogInfoPath: '/catalog-info.yaml', + }, + }, + ], + output: { + links: [ + { + title: 'Repository', + url: '${{ steps.publish.output.remoteUrl }}', + }, + { + title: 'Open in catalog', + icon: 'catalog', + entityRef: '${{ steps.register.output.entityRef }}', + }, + ], + }, + }, + }, + }, + ]; + + const onSelectOption = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render an item for each option', async () => { + await renderInTestApp( + , + ); + + for (const option of options) { + expect( + screen.queryByRole('menuitem', { name: option.label }), + ).not.toBeInTheDocument(); + } + + await userEvent.click(screen.getByRole('button', { name: 'Templates' })); + + for (const option of options) { + expect( + screen.getByRole('menuitem', { name: option.label }), + ).toBeInTheDocument(); + } + }); + + it('should call "onSelectOption" when a option is selected', async () => { + await renderInTestApp( + , + ); + + for (const option of options) { + expect( + screen.queryByRole('menuitem', { name: option.label }), + ).not.toBeInTheDocument(); + } + + await userEvent.click(screen.getByRole('button', { name: 'Templates' })); + + await userEvent.click( + screen.getByRole('menuitem', { name: options[0].label }), + ); + + expect(onSelectOption).toHaveBeenCalledTimes(1); + expect(onSelectOption).toHaveBeenCalledWith(options[0]); + }); + + it('should highlight the passed selected option', async () => { + const selectedOption = options[0]; + + await renderInTestApp( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Templates' })); + + expect( + screen.getByRole('menuitem', { name: selectedOption.label }), + ).toHaveAttribute('aria-selected', 'true'); + + for (const option of options.splice(1)) { + expect( + screen.getByRole('menuitem', { name: option.label }), + ).toHaveAttribute('aria-selected', 'false'); + } + }); +}); diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.tsx index 81ffac2c04..4a71aba5e5 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.tsx @@ -48,6 +48,13 @@ export function TemplateEditorToolbarTemplatesMenu(props: { const [anchorEl, setAnchorEl] = useState(null); const { t } = useTranslationRef(scaffolderTranslationRef); + const isSelectedOption = useCallback( + (option: TemplateOption) => { + return !!selectedOption && selectedOption.value === option.value; + }, + [selectedOption], + ); + const handleOpenMenu = useCallback( (event: MouseEvent) => { setAnchorEl(event.currentTarget); @@ -93,12 +100,12 @@ export function TemplateEditorToolbarTemplatesMenu(props: { PaperProps={{ className: classes.menu, }} - keepMounted > {options.map((option, index) => ( handleSelectOption(option)} > {option.label}