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 ? (
+
+ >
+ );
+};