feat: add RepoOwnerPicker

Signed-off-by: Benjamin Janssens <benji.janssens@gmail.com>
This commit is contained in:
Benjamin Janssens
2025-12-10 17:10:12 +01:00
parent cecaa124bb
commit 6c5a34ebf8
17 changed files with 1118 additions and 0 deletions
@@ -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,
});
+2
View File
@@ -35,6 +35,7 @@ import {
ownedEntityPickerFormField,
ownerPickerFormField,
repoBranchPickerFormField,
repoOwnerPickerFormField,
repoUrlPickerFormField,
scaffolderApi,
scaffolderNavItem,
@@ -88,5 +89,6 @@ export default createFrontendPlugin({
myGroupsPickerFormField,
ownedEntityPickerFormField,
repoBranchPickerFormField,
repoOwnerPickerFormField,
],
});
@@ -0,0 +1,71 @@
/*
* 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, render, screen } from '@testing-library/react';
import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker';
describe('DefaultRepoOwnerPicker', () => {
it('renders an input field', () => {
const { getByRole } = render(
<DefaultRepoOwnerPicker
onChange={jest.fn()}
state={{ owner: 'owner1' }}
rawErrors={[]}
/>,
);
expect(getByRole('textbox')).toBeInTheDocument();
expect(getByRole('textbox')).toHaveValue('owner1');
});
it('input field disabled', () => {
render(
<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', () => {
const onChange = jest.fn();
const { getByRole } = render(
<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,58 @@
/*
* 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 { BaseRepoOwnerPickerProps } from './types';
/**
* 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;
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !owner}
>
<TextField
id="ownerInput"
label={schema?.title ?? 'Owner'}
disabled={isDisabled}
onChange={e => onChange({ owner: e.target.value })}
value={owner}
/>
<FormHelperText>
{schema?.description ?? 'The owner of the repository'}
</FormHelperText>
</FormControl>
);
};
@@ -0,0 +1,155 @@
/*
* 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,
render,
waitFor,
screen,
} from '@testing-library/react';
import { 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', () => {
const { getByRole } = render(
<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', () => {
render(
<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', () => {
const onChange = jest.fn();
const { getByRole } = render(
<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 } = render(
<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 } = render(
<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,109 @@
/*
* 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 { BaseRepoOwnerPickerProps } from './types';
/**
* 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 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 ?? 'Owner'}
disabled={isDisabled}
required={required}
/>
)}
freeSolo
autoSelect
/>
<FormHelperText>
{schema?.description ?? 'The owner of the repository'}
</FormHelperText>
</FormControl>
);
};
@@ -0,0 +1,329 @@
/*
* 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 } = 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}
formContext={{
formData: {},
}}
/>
</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(),
);
});
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}
formContext={{
formData: { repoUrl: 'github.com' },
}}
/>
</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>,
}}
formContext={{
formData: {
repoUrl: 'github.com',
},
}}
/>
</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': {
requestUserCredentials: {
secretsKey,
additionalScopes: { github: ['workflow'] },
},
},
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
formContext={{
formData: {
repoUrl: 'github.com',
},
}}
/>
<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': {
requestUserCredentials: {
secretsKey: 'testKey',
},
},
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
formContext={{
formData: {
repoUrl: 'gitlab.example.com',
},
}}
/>
</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://gitlab.example.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>,
}}
formContext={{
formData: {
repoUrl: 'github.com',
},
}}
/>
<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,157 @@
/*
* 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,
formContext,
required,
} = props;
const {
formData: { repoUrl },
} = formContext;
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 (repoUrl) {
const url = new URL(`https://${repoUrl}`);
setState(prevState => ({
...prevState,
host: url.host,
}));
} else if (uiSchema?.['ui:options']?.host) {
const hardcodedHost = uiSchema['ui:options'].host;
setState(prevState => ({ ...prevState, host: hardcodedHost }));
}
}, [repoUrl, 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,
},
];
+1
View File
@@ -31,6 +31,7 @@ export {
RepoUrlPickerFieldExtension,
MultiEntityPickerFieldExtension,
RepoBranchPickerFieldExtension,
RepoOwnerPickerFieldExtension,
ScaffolderPage,
scaffolderPlugin,
} from './plugin';
+17
View File
@@ -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,
}),
);