Merge pull request #32105 from benjidotsh/scaffolder/repo-owner-picker
feat(scaffolder): add RepoOwnerPicker
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-github': patch
|
||||
---
|
||||
|
||||
Added autocompletion resource `owners` for retrieving GitHub repository owners.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Added field extension `RepoOwnerPicker` for retrieving GitHub repository owners.
|
||||
@@ -468,3 +468,59 @@ repoUrl:
|
||||
The supported `additionalScopes` values are `gerrit`, `github`, `gitlab`, `bitbucket`, and `azure`.
|
||||
|
||||
If you're also using the `RepoUrlPicker` field extension, you should simply duplicate this part from there.
|
||||
|
||||
## RepoOwnerPicker
|
||||
|
||||
The input props that can be specified under `ui:options` for the `RepoOwnerPicker` field extension.
|
||||
|
||||
### `host`
|
||||
|
||||
The SCM integration host that owners should be fetched from for autocompletion.
|
||||
|
||||
- Fetch owners from `github.com`
|
||||
|
||||
```yaml
|
||||
repoUrl:
|
||||
title: Repository Owner
|
||||
type: string
|
||||
ui:field: RepoOwnerPicker
|
||||
ui:options:
|
||||
host: github.com
|
||||
```
|
||||
|
||||
### `excludedOwners`
|
||||
|
||||
List of owners that should be excluded from autocompletion.
|
||||
|
||||
- Exclude owner `owner_1` from autocompletion
|
||||
|
||||
```yaml
|
||||
repoUrl:
|
||||
title: Repository Owner
|
||||
type: string
|
||||
ui:field: RepoOwnerPicker
|
||||
ui:options:
|
||||
excludedOwners:
|
||||
- owner_1
|
||||
```
|
||||
|
||||
### `requestUserCredentials`
|
||||
|
||||
If defined will request user credentials to auth against the given SCM platform.
|
||||
|
||||
```yaml
|
||||
repoUrl:
|
||||
title: Repository Owner
|
||||
type: string
|
||||
ui:field: RepoOwnerPicker
|
||||
ui:options:
|
||||
requestUserCredentials:
|
||||
secretsKey: USER_OAUTH_TOKEN
|
||||
additionalScopes:
|
||||
github:
|
||||
- workflow:write
|
||||
```
|
||||
|
||||
`secretsKey` is the key used within the template secrets context to store the credential and `additionalScopes` is any additional permission scopes to request.
|
||||
|
||||
The supported `additionalScopes` values are `gerrit`, `github`, `gitlab`, `bitbucket`, and `azure`.
|
||||
|
||||
@@ -555,6 +555,35 @@ The `RepoBranchPicker` 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)
|
||||
|
||||
### The Repository Owner Picker
|
||||
|
||||
Similar to the repository picker, there is a picker for owners to support autocompletion. A full example could look like this:
|
||||
|
||||
```yaml
|
||||
- title: Choose an owner
|
||||
required:
|
||||
- repoOwner
|
||||
properties:
|
||||
repoOwner:
|
||||
title: Repository Owner
|
||||
type: string
|
||||
ui:field: RepoOwnerPicker
|
||||
ui:options:
|
||||
host: github.com
|
||||
excludedOwners:
|
||||
- backstage
|
||||
requestUserCredentials:
|
||||
secretsKey: USER_OAUTH_TOKEN
|
||||
```
|
||||
|
||||
Passing the `requestUserCredentials` and `host` properties is required for autocompletion to work. For more information regarding the `requestUserCredentials` object, please refer to the [Using the Users `oauth` token](#using-the-users-oauth-token) section under [The Repository Picker](#the-repository-picker).
|
||||
|
||||
For a list of all possible `ui:options` input props for `RepoOwnerPicker`, please visit [here](./ui-options-examples.md#repoownerpicker).
|
||||
|
||||
The `RepoOwnerPicker` 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)
|
||||
|
||||
### Accessing the signed-in users details
|
||||
|
||||
Sometimes when authoring templates, you'll want to access the user that is running the template, and get details from the profile or the users `Entity` in the Catalog.
|
||||
|
||||
@@ -31,6 +31,9 @@ const mockOctokit = {
|
||||
listForAuthenticatedUser: jest.fn(),
|
||||
listBranches: jest.fn(),
|
||||
},
|
||||
orgs: {
|
||||
listForAuthenticatedUser: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.mock('octokit', () => ({
|
||||
@@ -95,6 +98,30 @@ describe('handleAutocompleteRequest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return owners', async () => {
|
||||
const handleAutocompleteRequest = createHandleAutocompleteRequest({
|
||||
integrations: mockIntegrations,
|
||||
});
|
||||
|
||||
mockOctokit.rest.orgs.listForAuthenticatedUser.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
login: 'backstage',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await handleAutocompleteRequest({
|
||||
resource: 'owners',
|
||||
token: 'token',
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [{ id: 'backstage' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error for invalid resource', async () => {
|
||||
const handleAutocompleteRequest = createHandleAutocompleteRequest({
|
||||
integrations: mockIntegrations,
|
||||
|
||||
@@ -64,6 +64,15 @@ export function createHandleAutocompleteRequest(options: {
|
||||
|
||||
return { results };
|
||||
}
|
||||
case 'owners': {
|
||||
const orgs = await client.paginate(
|
||||
client.rest.orgs.listForAuthenticatedUser,
|
||||
);
|
||||
|
||||
const results = orgs.map(r => ({ id: r.login }));
|
||||
|
||||
return { results };
|
||||
}
|
||||
default:
|
||||
throw new InputError(`Invalid resource: ${resource}`);
|
||||
}
|
||||
|
||||
@@ -350,6 +350,21 @@ const _default: OverridableFrontendPlugin<
|
||||
field: () => Promise<FormField>;
|
||||
};
|
||||
}>;
|
||||
'scaffolder-form-field:scaffolder/repo-owner-picker': OverridableExtensionDefinition<{
|
||||
kind: 'scaffolder-form-field';
|
||||
name: 'repo-owner-picker';
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<
|
||||
() => Promise<FormField>,
|
||||
'scaffolder.form-field-loader',
|
||||
{}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
field: () => Promise<FormField>;
|
||||
};
|
||||
}>;
|
||||
'scaffolder-form-field:scaffolder/repo-url-picker': OverridableExtensionDefinition<{
|
||||
kind: 'scaffolder-form-field';
|
||||
name: 'repo-url-picker';
|
||||
@@ -499,6 +514,8 @@ export const scaffolderTranslationRef: TranslationRef<
|
||||
readonly 'fields.repoUrlPicker.repository.title': 'Repositories Available';
|
||||
readonly 'fields.repoUrlPicker.repository.description': 'The name of the repository';
|
||||
readonly 'fields.repoUrlPicker.repository.inputTitle': 'Repository';
|
||||
readonly 'fields.repoOwnerPicker.title': 'Owner';
|
||||
readonly 'fields.repoOwnerPicker.description': 'The owner of the repository';
|
||||
readonly 'aboutCard.launchTemplate': 'Launch Template';
|
||||
readonly 'actionsPage.content.emptyState.title': 'No information to display';
|
||||
readonly 'actionsPage.content.emptyState.description': 'There are no actions installed or there was an issue communicating with backend.';
|
||||
|
||||
@@ -391,6 +391,30 @@ export const RepoBranchPickerFieldExtension: FieldExtensionComponent_2<
|
||||
}
|
||||
>;
|
||||
|
||||
// @public
|
||||
export const RepoOwnerPickerFieldExtension: FieldExtensionComponent_2<
|
||||
string,
|
||||
{
|
||||
host?: string | undefined;
|
||||
requestUserCredentials?:
|
||||
| {
|
||||
secretsKey: string;
|
||||
additionalScopes?:
|
||||
| {
|
||||
azure?: string[] | undefined;
|
||||
github?: string[] | undefined;
|
||||
gitlab?: string[] | undefined;
|
||||
bitbucket?: string[] | undefined;
|
||||
gerrit?: string[] | undefined;
|
||||
gitea?: string[] | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
| undefined;
|
||||
excludedOwners?: string[] | undefined;
|
||||
}
|
||||
>;
|
||||
|
||||
// @public
|
||||
export const repoPickerValidation: (
|
||||
value: string,
|
||||
|
||||
@@ -127,6 +127,14 @@ export const repoBranchPickerFormField = FormFieldBlueprint.make({
|
||||
},
|
||||
});
|
||||
|
||||
export const repoOwnerPickerFormField = FormFieldBlueprint.make({
|
||||
name: 'repo-owner-picker',
|
||||
params: {
|
||||
field: () =>
|
||||
import('./fields/RepoOwnerPicker').then(m => m.RepoOwnerPicker),
|
||||
},
|
||||
});
|
||||
|
||||
export const scaffolderApi = ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2025 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 { createFormField } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import {
|
||||
RepoOwnerPicker as Component,
|
||||
RepoOwnerPickerFieldSchema,
|
||||
} from '../../components/fields/RepoOwnerPicker';
|
||||
|
||||
export const RepoOwnerPicker = createFormField({
|
||||
component: Component,
|
||||
name: 'RepoOwnerPicker',
|
||||
schema: RepoOwnerPickerFieldSchema,
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ownedEntityPickerFormField,
|
||||
ownerPickerFormField,
|
||||
repoBranchPickerFormField,
|
||||
repoOwnerPickerFormField,
|
||||
repoUrlPickerFormField,
|
||||
scaffolderApi,
|
||||
scaffolderNavItem,
|
||||
@@ -88,5 +89,6 @@ export default createFrontendPlugin({
|
||||
myGroupsPickerFormField,
|
||||
ownedEntityPickerFormField,
|
||||
repoBranchPickerFormField,
|
||||
repoOwnerPickerFormField,
|
||||
],
|
||||
});
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2025 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 { fireEvent, screen } from '@testing-library/react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker';
|
||||
|
||||
describe('DefaultRepoOwnerPicker', () => {
|
||||
it('renders an input field', async () => {
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<DefaultRepoOwnerPicker
|
||||
onChange={jest.fn()}
|
||||
state={{ owner: 'owner1' }}
|
||||
rawErrors={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByRole('textbox')).toBeInTheDocument();
|
||||
expect(getByRole('textbox')).toHaveValue('owner1');
|
||||
});
|
||||
|
||||
it('input field disabled', async () => {
|
||||
await renderInTestApp(
|
||||
<DefaultRepoOwnerPicker
|
||||
onChange={jest.fn()}
|
||||
isDisabled
|
||||
state={{ owner: 'owner1' }}
|
||||
rawErrors={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
// Expect input to be disabled
|
||||
expect(input).toBeDisabled();
|
||||
expect(input).toHaveValue('owner1');
|
||||
});
|
||||
|
||||
it('calls onChange when the input field changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<DefaultRepoOwnerPicker
|
||||
onChange={onChange}
|
||||
state={{ owner: 'owner1' }}
|
||||
rawErrors={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByRole('textbox');
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'owner2' },
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ owner: 'owner2' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2025 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 FormControl from '@material-ui/core/FormControl';
|
||||
import FormHelperText from '@material-ui/core/FormHelperText';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
|
||||
import { BaseRepoOwnerPickerProps } from './types';
|
||||
import { scaffolderTranslationRef } from '../../../translation';
|
||||
|
||||
/**
|
||||
* The underlying component that is rendered in the form for the `DefaultRepoOwnerPicker`
|
||||
* field extension.
|
||||
*
|
||||
* @public
|
||||
*
|
||||
*/
|
||||
export const DefaultRepoOwnerPicker = ({
|
||||
onChange,
|
||||
state,
|
||||
rawErrors,
|
||||
isDisabled,
|
||||
required,
|
||||
schema,
|
||||
}: BaseRepoOwnerPickerProps) => {
|
||||
const { owner } = state;
|
||||
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
margin="normal"
|
||||
required={required}
|
||||
error={rawErrors?.length > 0 && !owner}
|
||||
>
|
||||
<TextField
|
||||
id="ownerInput"
|
||||
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
|
||||
disabled={isDisabled}
|
||||
onChange={e => onChange({ owner: e.target.value })}
|
||||
value={owner}
|
||||
/>
|
||||
<FormHelperText>
|
||||
{schema?.description ?? t('fields.repoOwnerPicker.description')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright 2025 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 {
|
||||
ScaffolderApi,
|
||||
scaffolderApiRef,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { GitHubRepoOwnerPicker } from './GitHubRepoOwnerPicker';
|
||||
import { act, fireEvent, waitFor, screen } from '@testing-library/react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('GitHubRepoOwnerPicker', () => {
|
||||
const scaffolderApiMock: Partial<ScaffolderApi> = {
|
||||
autocomplete: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ results: [{ id: 'owner1' }, { id: 'owner2' }] }),
|
||||
};
|
||||
|
||||
it('renders an input field', async () => {
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
|
||||
<GitHubRepoOwnerPicker
|
||||
onChange={jest.fn()}
|
||||
state={{ owner: 'owner1' }}
|
||||
rawErrors={[]}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(getByRole('textbox')).toBeInTheDocument();
|
||||
expect(getByRole('textbox')).toHaveValue('owner1');
|
||||
});
|
||||
|
||||
it('input field disabled', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
|
||||
<GitHubRepoOwnerPicker
|
||||
onChange={jest.fn()}
|
||||
isDisabled
|
||||
state={{ owner: 'owner1' }}
|
||||
rawErrors={[]}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
// Expect input to be disabled
|
||||
expect(input).toBeDisabled();
|
||||
expect(input).toHaveValue('owner1');
|
||||
});
|
||||
|
||||
it('calls onChange when the input field changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
|
||||
<GitHubRepoOwnerPicker
|
||||
onChange={onChange}
|
||||
state={{ owner: 'owner1' }}
|
||||
rawErrors={[]}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const input = getByRole('textbox');
|
||||
|
||||
act(() => {
|
||||
input.focus();
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'owner2' },
|
||||
});
|
||||
input.blur();
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ owner: 'owner2' });
|
||||
});
|
||||
|
||||
it('should populate owners', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { getByRole, getByText } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
|
||||
<GitHubRepoOwnerPicker
|
||||
onChange={onChange}
|
||||
state={{
|
||||
host: 'github.com',
|
||||
owner: 'foo',
|
||||
}}
|
||||
rawErrors={[]}
|
||||
accessToken="token"
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
// Open the Autocomplete dropdown
|
||||
const input = getByRole('textbox');
|
||||
await userEvent.click(input);
|
||||
|
||||
// Verify that the available owners are shown
|
||||
await waitFor(() => expect(getByText('owner1')).toBeInTheDocument());
|
||||
|
||||
// Verify that selecting an option calls onChange
|
||||
await userEvent.click(getByText('owner1'));
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
owner: 'owner1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out excluded owners', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { getByRole, getByText } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
|
||||
<GitHubRepoOwnerPicker
|
||||
onChange={onChange}
|
||||
state={{
|
||||
host: 'github.com',
|
||||
}}
|
||||
rawErrors={[]}
|
||||
accessToken="token"
|
||||
excludedOwners={['owner1']}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
// Open the Autocomplete dropdown
|
||||
const input = getByRole('textbox');
|
||||
await userEvent.click(input);
|
||||
|
||||
// Verify that the excluded owners are not shown
|
||||
await waitFor(() => expect(getByText('owner2')).toBeInTheDocument());
|
||||
expect(screen.queryByText('owner1')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2025 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
import FormHelperText from '@material-ui/core/FormHelperText';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Autocomplete from '@material-ui/lab/Autocomplete';
|
||||
import { useCallback, useState } from 'react';
|
||||
import useDebounce from 'react-use/esm/useDebounce';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
|
||||
import { BaseRepoOwnerPickerProps } from './types';
|
||||
import { scaffolderTranslationRef } from '../../../translation';
|
||||
|
||||
/**
|
||||
* The underlying component that is rendered in the form for the `GitHubRepoOwnerPicker`
|
||||
* field extension.
|
||||
*
|
||||
* @public
|
||||
*
|
||||
*/
|
||||
export const GitHubRepoOwnerPicker = ({
|
||||
onChange,
|
||||
state,
|
||||
rawErrors,
|
||||
accessToken,
|
||||
isDisabled,
|
||||
required,
|
||||
schema,
|
||||
excludedOwners = [],
|
||||
}: BaseRepoOwnerPickerProps<{
|
||||
accessToken?: string;
|
||||
excludedOwners?: string[];
|
||||
}>) => {
|
||||
const { host, owner } = state;
|
||||
|
||||
const [availableOwners, setAvailableOwners] = useState<string[]>([]);
|
||||
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
const updateAvailableOwners = useCallback(() => {
|
||||
if (!scaffolderApi.autocomplete || !accessToken || !host) {
|
||||
setAvailableOwners([]);
|
||||
return;
|
||||
}
|
||||
|
||||
scaffolderApi
|
||||
.autocomplete({
|
||||
token: accessToken,
|
||||
resource: 'owners',
|
||||
context: { host },
|
||||
provider: 'github',
|
||||
})
|
||||
.then(({ results }) => {
|
||||
const owners = results
|
||||
.map(r => r.id)
|
||||
.filter(id => !excludedOwners.includes(id));
|
||||
|
||||
setAvailableOwners(owners);
|
||||
})
|
||||
.catch(() => {
|
||||
setAvailableOwners([]);
|
||||
});
|
||||
}, [host, accessToken, scaffolderApi, excludedOwners]);
|
||||
|
||||
useDebounce(updateAvailableOwners, 500, [updateAvailableOwners]);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
margin="normal"
|
||||
required={required}
|
||||
error={rawErrors?.length > 0 && !owner}
|
||||
>
|
||||
<Autocomplete
|
||||
value={owner}
|
||||
onChange={(_, newValue) => {
|
||||
onChange({ owner: newValue || '' });
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
options={availableOwners}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
|
||||
disabled={isDisabled}
|
||||
required={required}
|
||||
/>
|
||||
)}
|
||||
freeSolo
|
||||
autoSelect
|
||||
/>
|
||||
<FormHelperText>
|
||||
{schema?.description ?? t('fields.repoOwnerPicker.description')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* Copyright 2025 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 { Form } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import {
|
||||
scmIntegrationsApiRef,
|
||||
ScmIntegrationsApi,
|
||||
scmAuthApiRef,
|
||||
ScmAuthApi,
|
||||
} from '@backstage/integration-react';
|
||||
|
||||
import {
|
||||
SecretsContextProvider,
|
||||
scaffolderApiRef,
|
||||
useTemplateSecrets,
|
||||
ScaffolderRJSFField,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { act, fireEvent, screen } from '@testing-library/react';
|
||||
import { RepoOwnerPicker } from './RepoOwnerPicker';
|
||||
|
||||
describe('RepoOwnerPicker', () => {
|
||||
const mockIntegrationsApi: Partial<ScmIntegrationsApi> = {
|
||||
byHost: () => ({ type: 'github' }),
|
||||
};
|
||||
|
||||
let mockScmAuthApi: Partial<ScmAuthApi>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockScmAuthApi = {
|
||||
getCredentials: jest.fn().mockResolvedValue({ token: 'abc123' }),
|
||||
};
|
||||
});
|
||||
|
||||
describe('happy path rendering', () => {
|
||||
it('should render the repo owner picker with minimal props', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
const { getByRole, getByText } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, {}],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{ 'ui:field': 'RepoOwnerPicker' }}
|
||||
fields={{
|
||||
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const input = getByRole('textbox');
|
||||
const submitButton = getByRole('button');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'owner1' } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: 'owner1',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
expect(getByText('Owner')).toBeInTheDocument();
|
||||
expect(getByText('The owner of the repository')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable the picker when ui:disabled', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, {}],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{ 'ui:field': 'RepoOwnerPicker', 'ui:disabled': true }}
|
||||
fields={{
|
||||
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render properly with title and description', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, {}],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{
|
||||
type: 'string',
|
||||
title: 'test title',
|
||||
description: 'test description',
|
||||
}}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoOwnerPicker',
|
||||
}}
|
||||
fields={{
|
||||
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(getByText('test title')).toBeInTheDocument();
|
||||
expect(getByText('test description')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestUserCredentials', () => {
|
||||
it('should call the scmAuthApi with the correct params', async () => {
|
||||
const secretsKey = 'testKey';
|
||||
|
||||
const SecretsComponent = () => {
|
||||
const { secrets } = useTemplateSecrets();
|
||||
const secret = secrets[secretsKey];
|
||||
return secret ? <div>{secret}</div> : null;
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, mockScmAuthApi],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoOwnerPicker',
|
||||
'ui:options': {
|
||||
host: 'github.com',
|
||||
requestUserCredentials: {
|
||||
secretsKey,
|
||||
additionalScopes: { github: ['workflow'] },
|
||||
},
|
||||
},
|
||||
}}
|
||||
fields={{
|
||||
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
/>
|
||||
<SecretsComponent />
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
// need to wait for the debounce to finish
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({
|
||||
url: 'https://github.com',
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
customScopes: {
|
||||
github: ['workflow'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('abc123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call the scmAuthApi with the correct params if workspace is nested', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, mockScmAuthApi],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoOwnerPicker',
|
||||
'ui:options': {
|
||||
host: 'github.com',
|
||||
requestUserCredentials: {
|
||||
secretsKey: 'testKey',
|
||||
},
|
||||
},
|
||||
}}
|
||||
fields={{
|
||||
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
// need to wait for the debounce to finish
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({
|
||||
url: 'https://github.com',
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call the scmAuthApi if secret is available in the state', async () => {
|
||||
const secretsKey = 'testKey';
|
||||
|
||||
const SecretsComponent = () => {
|
||||
const { secrets } = useTemplateSecrets();
|
||||
const secret = secrets[secretsKey];
|
||||
return secret ? <div>{secret}</div> : null;
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, mockScmAuthApi],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider initialSecrets={{ [secretsKey]: 'abc123' }}>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoOwnerPicker',
|
||||
'ui:options': {
|
||||
requestUserCredentials: {
|
||||
secretsKey,
|
||||
additionalScopes: { github: ['workflow'] },
|
||||
},
|
||||
},
|
||||
}}
|
||||
fields={{
|
||||
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
/>
|
||||
<SecretsComponent />
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
// need to wait for the debounce to finish
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
// as we already have a secret in the state, getCredentials should not be called again.
|
||||
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledTimes(0);
|
||||
|
||||
expect(getByText('abc123')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright 2025 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
scmIntegrationsApiRef,
|
||||
scmAuthApiRef,
|
||||
} from '@backstage/integration-react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import useDebounce from 'react-use/esm/useDebounce';
|
||||
import { useTemplateSecrets } from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
import { RepoOwnerPickerProps } from './schema';
|
||||
import { RepoOwnerPickerState } from './types';
|
||||
import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker';
|
||||
import { GitHubRepoOwnerPicker } from './GitHubRepoOwnerPicker';
|
||||
|
||||
/**
|
||||
* The underlying component that is rendered in the form for the `RepoOwnerPicker`
|
||||
* field extension.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const RepoOwnerPicker = (props: RepoOwnerPickerProps) => {
|
||||
const { uiSchema, onChange, rawErrors, formData, schema, required } = props;
|
||||
const [state, setState] = useState<RepoOwnerPickerState>({
|
||||
owner: formData || '',
|
||||
});
|
||||
const excludedOwners = useMemo(
|
||||
() => uiSchema?.['ui:options']?.excludedOwners ?? [],
|
||||
[uiSchema],
|
||||
);
|
||||
const { host, owner } = state;
|
||||
|
||||
const integrationApi = useApi(scmIntegrationsApiRef);
|
||||
const scmAuthApi = useApi(scmAuthApiRef);
|
||||
|
||||
const { secrets, setSecrets } = useTemplateSecrets();
|
||||
|
||||
useDebounce(
|
||||
async () => {
|
||||
const { requestUserCredentials } = uiSchema?.['ui:options'] ?? {};
|
||||
|
||||
if (!requestUserCredentials || !host) {
|
||||
return;
|
||||
}
|
||||
|
||||
// don't show login prompt if secret value is already in state
|
||||
if (secrets[requestUserCredentials.secretsKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// user has requested that we use the users credentials
|
||||
// so lets grab them using the scmAuthApi and pass through
|
||||
// any additional scopes from the ui:options
|
||||
const { token } = await scmAuthApi.getCredentials({
|
||||
url: `https://${host}`,
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
customScopes: requestUserCredentials.additionalScopes,
|
||||
},
|
||||
});
|
||||
|
||||
// set the secret using the key provided in the ui:options for use
|
||||
// in the templating the manifest with ${{ secrets[secretsKey] }}
|
||||
setSecrets({ [requestUserCredentials.secretsKey]: token });
|
||||
},
|
||||
500,
|
||||
[host, uiSchema],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (uiSchema?.['ui:options']?.host) {
|
||||
const hostUiOption = uiSchema['ui:options'].host;
|
||||
setState(prevState => ({ ...prevState, host: hostUiOption }));
|
||||
}
|
||||
}, [uiSchema]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(owner);
|
||||
}, [owner, onChange]);
|
||||
|
||||
const updateLocalState = useCallback(
|
||||
(newState: RepoOwnerPickerState) => {
|
||||
setState(prevState => ({ ...prevState, ...newState }));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const hostType = (host && integrationApi.byHost(host)?.type) ?? null;
|
||||
|
||||
const renderRepoOwnerPicker = () => {
|
||||
switch (hostType) {
|
||||
case 'github':
|
||||
return (
|
||||
<GitHubRepoOwnerPicker
|
||||
onChange={updateLocalState}
|
||||
state={state}
|
||||
rawErrors={rawErrors}
|
||||
accessToken={
|
||||
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
|
||||
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
|
||||
}
|
||||
isDisabled={uiSchema?.['ui:disabled'] ?? false}
|
||||
required={required}
|
||||
schema={schema}
|
||||
excludedOwners={excludedOwners}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<DefaultRepoOwnerPicker
|
||||
onChange={updateLocalState}
|
||||
state={state}
|
||||
rawErrors={rawErrors}
|
||||
isDisabled={uiSchema?.['ui:disabled'] ?? false}
|
||||
required={required}
|
||||
schema={schema}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return renderRepoOwnerPicker();
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 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 { RepoOwnerPicker } from './RepoOwnerPicker';
|
||||
export {
|
||||
RepoOwnerPickerFieldSchema,
|
||||
type RepoOwnerPickerUiOptions,
|
||||
RepoOwnerPickerSchema,
|
||||
} from './schema';
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2025 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 { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
export const RepoOwnerPickerFieldSchema = makeFieldSchema({
|
||||
output: z => z.string(),
|
||||
uiOptions: z =>
|
||||
z.object({
|
||||
host: z.string().optional(),
|
||||
excludedOwners: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('List of excluded owners'),
|
||||
requestUserCredentials: z
|
||||
.object({
|
||||
secretsKey: z
|
||||
.string()
|
||||
.describe(
|
||||
'Key used within the template secrets context to store the credential',
|
||||
),
|
||||
additionalScopes: z
|
||||
.object({
|
||||
gitea: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional Gitea scopes to request'),
|
||||
gerrit: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional Gerrit scopes to request'),
|
||||
github: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional GitHub scopes to request'),
|
||||
gitlab: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional GitLab scopes to request'),
|
||||
bitbucket: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional BitBucket scopes to request'),
|
||||
azure: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional Azure scopes to request'),
|
||||
})
|
||||
.optional()
|
||||
.describe('Additional permission scopes to request'),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
'If defined will request user credentials to auth against the given SCM platform',
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The input props that can be specified under `ui:options` for the
|
||||
* `RepoOwnerPicker` field extension.
|
||||
*/
|
||||
export type RepoOwnerPickerUiOptions = NonNullable<
|
||||
(typeof RepoOwnerPickerFieldSchema.TProps.uiSchema)['ui:options']
|
||||
>;
|
||||
|
||||
export type RepoOwnerPickerProps = typeof RepoOwnerPickerFieldSchema.TProps;
|
||||
|
||||
// This has been duplicated from /plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts
|
||||
// NOTE: There is a bug with this failing validation in the custom field explorer due
|
||||
// to https://github.com/rjsf-team/react-jsonschema-form/issues/675 even if
|
||||
// requestUserCredentials is not defined
|
||||
export const RepoOwnerPickerSchema = RepoOwnerPickerFieldSchema.schema;
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2025 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 { RJSFSchema } from '@rjsf/utils';
|
||||
|
||||
export interface RepoOwnerPickerState {
|
||||
host?: string;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
export type BaseRepoOwnerPickerProps<T extends {} = {}> = T & {
|
||||
onChange: (state: RepoOwnerPickerState) => void;
|
||||
state: RepoOwnerPickerState;
|
||||
rawErrors: string[];
|
||||
isDisabled?: boolean;
|
||||
required?: boolean;
|
||||
schema?: RJSFSchema;
|
||||
};
|
||||
@@ -52,6 +52,10 @@ import {
|
||||
} from '../components/fields/MultiEntityPicker/MultiEntityPicker';
|
||||
import { RepoBranchPicker } from '../components/fields/RepoBranchPicker/RepoBranchPicker';
|
||||
import { RepoBranchPickerSchema } from '../components/fields/RepoBranchPicker/schema';
|
||||
import {
|
||||
RepoOwnerPicker,
|
||||
RepoOwnerPickerSchema,
|
||||
} from '../components/fields/RepoOwnerPicker';
|
||||
|
||||
export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
|
||||
{
|
||||
@@ -106,4 +110,9 @@ export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
|
||||
name: 'RepoBranchPicker',
|
||||
schema: RepoBranchPickerSchema,
|
||||
},
|
||||
{
|
||||
component: RepoOwnerPicker,
|
||||
name: 'RepoOwnerPicker',
|
||||
schema: RepoOwnerPickerSchema,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -31,6 +31,7 @@ export {
|
||||
RepoUrlPickerFieldExtension,
|
||||
MultiEntityPickerFieldExtension,
|
||||
RepoBranchPickerFieldExtension,
|
||||
RepoOwnerPickerFieldExtension,
|
||||
ScaffolderPage,
|
||||
scaffolderPlugin,
|
||||
} from './plugin';
|
||||
|
||||
@@ -82,6 +82,10 @@ import { RepoBranchPickerSchema } from './components/fields/RepoBranchPicker/sch
|
||||
import { formDecoratorsApiRef } from './alpha/api/ref';
|
||||
import { DefaultScaffolderFormDecoratorsApi } from './alpha/api/FormDecoratorsApi';
|
||||
import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import {
|
||||
RepoOwnerPicker,
|
||||
RepoOwnerPickerSchema,
|
||||
} from './components/fields/RepoOwnerPicker';
|
||||
|
||||
/**
|
||||
* The main plugin export for the scaffolder.
|
||||
@@ -267,3 +271,16 @@ export const RepoBranchPickerFieldExtension = scaffolderPlugin.provide(
|
||||
schema: RepoBranchPickerSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* A field extension to select an owner from a repository.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const RepoOwnerPickerFieldExtension = scaffolderPlugin.provide(
|
||||
createScaffolderFieldExtension({
|
||||
component: RepoOwnerPicker,
|
||||
name: 'RepoOwnerPicker',
|
||||
schema: RepoOwnerPickerSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -137,6 +137,10 @@ export const scaffolderTranslationRef = createTranslationRef({
|
||||
description: 'The name of the repository',
|
||||
},
|
||||
},
|
||||
repoOwnerPicker: {
|
||||
title: 'Owner',
|
||||
description: 'The owner of the repository',
|
||||
},
|
||||
},
|
||||
listTaskPage: {
|
||||
title: 'List template tasks',
|
||||
|
||||
Reference in New Issue
Block a user