diff --git a/.changeset/hot-rice-sin.md b/.changeset/hot-rice-sin.md new file mode 100644 index 0000000000..5a3d6fdb7a --- /dev/null +++ b/.changeset/hot-rice-sin.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Add `allowedRepos` `ui:option` to `RepoUrlPicker` component, and move `repoName` field to own component diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 40b735764c..81367b19c5 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -320,6 +320,30 @@ The `allowedHosts` part should be set to where you wish to enable this template to publish to. And it can be any host that is listed in your `integrations` config in `app-config.yaml`. +Besides specifying `allowedHosts` you can also restrict the template to publish to +repositories owned by specific users/groups/namespaces by setting the `allowedOwners` +option. With the `allowedRepos` option you are able to narrow it down further to a +specific set of repository names. A full example could look like this: + +```yaml +- title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com + allowedOwners: + - backstage + - someGithubUser + allowedRepos: + - backstage +``` + The `RepoUrlPicker` is a custom field that we provide part of the `plugin-scaffolder`. You can provide your own custom fields by [writing your own Custom Field Extensions](./writing-custom-field-extensions.md) diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index c20cd0eb67..3fa623b5b9 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -195,6 +195,8 @@ export interface RepoUrlPickerUiOptions { // (undocumented) allowedOwners?: string[]; // (undocumented) + allowedRepos?: string[]; + // (undocumented) requestUserCredentials?: { secretsKey: string; additionalScopes?: { diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.test.tsx index ffcf2920c1..eb77240d33 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.test.tsx @@ -19,14 +19,14 @@ import { AzureRepoPicker } from './AzureRepoPicker'; import { render, fireEvent } from '@testing-library/react'; describe('AzureRepoPicker', () => { - it('renders the three input fields', async () => { + it('renders the two input fields', async () => { const { getAllByRole } = render( , ); const allInputs = getAllByRole('textbox'); - expect(allInputs).toHaveLength(3); + expect(allInputs).toHaveLength(2); }); describe('org field', () => { @@ -58,19 +58,4 @@ describe('AzureRepoPicker', () => { expect(onChange).toHaveBeenCalledWith({ owner: 'owner' }); }); }); - - describe('repoName field', () => { - it('calls onChange when the repoName changes', () => { - const onChange = jest.fn(); - const { getAllByRole } = render( - , - ); - - const repoNameInput = getAllByRole('textbox')[2]; - - fireEvent.change(repoNameInput, { target: { value: 'repoName' } }); - - expect(onChange).toHaveBeenCalledWith({ repoName: 'repoName' }); - }); - }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx index 0efadf4017..0246c67801 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx @@ -27,7 +27,7 @@ export const AzureRepoPicker = (props: { rawErrors: string[]; }) => { const { rawErrors, state, onChange } = props; - const { organization, repoName, owner } = state; + const { organization, owner } = state; return ( <> The Owner that this repo will belong to - 0 && !repoName} - > - Repository - onChange({ repoName: e.target.value })} - value={repoName} - /> - The name of the repository - ); }; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx index 17fe656006..0ced19a596 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -26,7 +26,7 @@ describe('BitbucketRepoPicker', () => { , ); - expect(getAllByRole('textbox')).toHaveLength(3); + expect(getAllByRole('textbox')).toHaveLength(2); expect(getAllByRole('textbox')[0]).toHaveValue('lolsWorkspace'); }); @@ -39,7 +39,7 @@ describe('BitbucketRepoPicker', () => { , ); - expect(getAllByRole('textbox')).toHaveLength(2); + expect(getAllByRole('textbox')).toHaveLength(1); }); describe('workspace field', () => { it('calls onChange when the workspace changes', () => { @@ -78,23 +78,4 @@ describe('BitbucketRepoPicker', () => { expect(onChange).toHaveBeenCalledWith({ project: 'test-project' }); }); }); - - describe('repoName field', () => { - it('calls onChange when the repoName changes', () => { - const onChange = jest.fn(); - const { getAllByRole } = render( - , - ); - - const repoNameInput = getAllByRole('textbox')[2]; - - fireEvent.change(repoNameInput, { target: { value: 'test-repo' } }); - - expect(onChange).toHaveBeenCalledWith({ repoName: 'test-repo' }); - }); - }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 64eb4b8dac..fc59398d94 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -26,7 +26,7 @@ export const BitbucketRepoPicker = (props: { rawErrors: string[]; }) => { const { onChange, rawErrors, state } = props; - const { host, workspace, project, repoName } = state; + const { host, workspace, project } = state; return ( <> {host === 'bitbucket.org' && ( @@ -61,19 +61,6 @@ export const BitbucketRepoPicker = (props: { The Project that this repo will belong to - 0 && !repoName} - > - Repository - onChange({ repoName: e.target.value })} - value={repoName} - /> - The name of the repository - ); }; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.test.tsx index edc6732588..66f0a234be 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.test.tsx @@ -56,23 +56,4 @@ describe('BitbucketRepoPicker', () => { expect(onChange).toHaveBeenCalledWith({ workspace: 'test-parent' }); }); }); - - describe('repoName field', () => { - it('calls onChange when the repoName changes', () => { - const onChange = jest.fn(); - const { getAllByRole } = render( - , - ); - - const repoNameInput = getAllByRole('textbox')[2]; - - fireEvent.change(repoNameInput, { target: { value: 'test-repo' } }); - - expect(onChange).toHaveBeenCalledWith({ repoName: 'test-repo' }); - }); - }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.tsx index 4cbc0859e8..f021948532 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.tsx @@ -26,7 +26,7 @@ export const GerritRepoPicker = (props: { rawErrors: string[]; }) => { const { onChange, rawErrors, state } = props; - const { workspace, repoName, owner } = state; + const { workspace, owner } = state; return ( <> - 0 && !repoName} - > - Repository - onChange({ repoName: e.target.value })} - value={repoName} - /> - The name of the repository - ); }; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx index 2c80c40c22..63bc5b8013 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx @@ -84,24 +84,4 @@ describe('GithubRepoPicker', () => { expect(onChange).toHaveBeenCalledWith({ owner: 'my-mock-owner' }); }); }); - - describe('repo name', () => { - it('should render free text field for input of repo name', () => { - const onChange = jest.fn(); - const { getAllByRole } = render( - , - ); - - const repoNameField = getAllByRole('textbox')[1]; - fireEvent.change(repoNameField, { - target: { value: 'my-mock-repo-name' }, - }); - - expect(onChange).toHaveBeenCalledWith({ repoName: 'my-mock-repo-name' }); - }); - }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx index 13b57cb449..ec59d89ef8 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx @@ -32,7 +32,7 @@ export const GithubRepoPicker = (props: { ? allowedOwners.map(i => ({ label: i, value: i })) : [{ label: 'Loading...', value: 'loading' }]; - const { owner, repoName } = state; + const { owner } = state; return ( <> @@ -66,19 +66,6 @@ export const GithubRepoPicker = (props: { The organization, user or project that this repo will belong to - 0 && !repoName} - > - Repository - onChange({ repoName: e.target.value })} - value={repoName} - /> - The name of the repository - ); }; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx index 49a91c116e..ad5c7f765e 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx @@ -84,24 +84,4 @@ describe('GitlabRepoPicker', () => { expect(onChange).toHaveBeenCalledWith({ owner: 'my-mock-owner' }); }); }); - - describe('repo name', () => { - it('should render free text field for input of repo name', () => { - const onChange = jest.fn(); - const { getAllByRole } = render( - , - ); - - const repoNameField = getAllByRole('textbox')[1]; - fireEvent.change(repoNameField, { - target: { value: 'my-mock-repo-name' }, - }); - - expect(onChange).toHaveBeenCalledWith({ repoName: 'my-mock-repo-name' }); - }); - }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx index e2052b792f..c628b364b9 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx @@ -23,16 +23,17 @@ import { RepoUrlPickerState } from './types'; export const GitlabRepoPicker = (props: { allowedOwners?: string[]; + allowedRepos?: string[]; state: RepoUrlPickerState; onChange: (state: RepoUrlPickerState) => void; rawErrors: string[]; }) => { - const { allowedOwners = [], rawErrors, state, onChange } = props; + const { allowedOwners = [], state, onChange, rawErrors } = props; const ownerItems: SelectItem[] = allowedOwners ? allowedOwners.map(i => ({ label: i, value: i })) : [{ label: 'Loading...', value: 'loading' }]; - const { owner, repoName } = state; + const { owner } = state; return ( <> @@ -69,19 +70,6 @@ export const GitlabRepoPicker = (props: { namespaces in gitlab), that this repo will belong to - 0 && !repoName} - > - Repository - onChange({ repoName: e.target.value })} - value={repoName} - /> - The name of the repository - ); }; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 6f762933b6..b36b332d9e 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -26,6 +26,7 @@ import { BitbucketRepoPicker } from './BitbucketRepoPicker'; import { GerritRepoPicker } from './GerritRepoPicker'; import { FieldExtensionComponentProps } from '../../../extensions'; import { RepoUrlPickerHost } from './RepoUrlPickerHost'; +import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName'; import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils'; import { RepoUrlPickerState } from './types'; import useDebounce from 'react-use/lib/useDebounce'; @@ -40,6 +41,7 @@ import { useTemplateSecrets } from '../../secrets'; export interface RepoUrlPickerUiOptions { allowedHosts?: string[]; allowedOwners?: string[]; + allowedRepos?: string[]; requestUserCredentials?: { secretsKey: string; additionalScopes?: { @@ -76,6 +78,10 @@ export const RepoUrlPicker = ( () => uiSchema?.['ui:options']?.allowedOwners ?? [], [uiSchema], ); + const allowedRepos = useMemo( + () => uiSchema?.['ui:options']?.allowedRepos ?? [], + [uiSchema], + ); useEffect(() => { onChange(serializeRepoPickerUrl(state)); @@ -87,6 +93,11 @@ export const RepoUrlPicker = ( setState(prevState => ({ ...prevState, owner: allowedOwners[0] })); } }, [setState, allowedOwners]); + useEffect(() => { + if (allowedRepos.length === 1) { + setState(prevState => ({ ...prevState, repoName: allowedRepos[0] })); + } + }, [setState, allowedRepos]); const updateLocalState = useCallback( (newState: RepoUrlPickerState) => { @@ -179,6 +190,14 @@ export const RepoUrlPicker = ( onChange={updateLocalState} /> )} + + setState(prevState => ({ ...prevState, repoName })) + } + rawErrors={rawErrors} + /> ); }; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx new file mode 100644 index 0000000000..4c392d646c --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.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 { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName'; +import { render, fireEvent } from '@testing-library/react'; + +describe('RepoUrlPickerRepoName', () => { + it('should call onChange with the first allowed repo if there is none set already', async () => { + const onChange = jest.fn(); + + render( + , + ); + + expect(onChange).toHaveBeenCalledWith('foo'); + }); + + it('should render a dropdown of all the options', async () => { + const allowedRepos = ['foo', 'bar']; + + const onChange = jest.fn(); + + const { getByRole } = render( + , + ); + + const select = getByRole('combobox'); + await fireEvent.click(select); + + for (const option of allowedRepos) { + const element = getByRole('option', { name: option }); + expect(element).toBeVisible(); + } + }); + + it('should render a normal text area when no options are passed', async () => { + const onChange = jest.fn(); + + const { getByRole } = render( + , + ); + + const textArea = getByRole('textbox'); + + expect(textArea).toBeVisible(); + + fireEvent.change(textArea, { target: { value: 'foo' } }); + + expect(onChange).toHaveBeenCalledWith('foo'); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx new file mode 100644 index 0000000000..1393b1902e --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx @@ -0,0 +1,77 @@ +/* + * 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, { useEffect } from 'react'; +import { Select, SelectItem } from '@backstage/core-components'; +import FormControl from '@material-ui/core/FormControl'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import Input from '@material-ui/core/Input'; +import InputLabel from '@material-ui/core/InputLabel'; + +export const RepoUrlPickerRepoName = (props: { + repoName?: string; + allowedRepos?: string[]; + onChange: (host: string) => void; + rawErrors: string[]; +}) => { + const { repoName, allowedRepos, onChange, rawErrors } = props; + + useEffect(() => { + // If there is no repoName chosen currently + if (!repoName) { + // Set the first of the allowedRepos option if that available + if (allowedRepos?.length) { + onChange(allowedRepos[0]); + } + } + }, [allowedRepos, repoName, onChange]); + + const repoItems: SelectItem[] = allowedRepos + ? allowedRepos.map(i => ({ label: i, value: i })) + : [{ label: 'Loading...', value: 'loading' }]; + + return ( + <> + 0 && !repoName} + > + {allowedRepos?.length ? ( + onChange(String(e.target.value))} + value={repoName} + /> + + )} + The name of the repository + + + ); +};