Add GitLabRepoOwnerPicker and tests

Signed-off-by: asheen1234 <sergeantnumnumz@gmail.com>
This commit is contained in:
asheen1234
2026-03-30 14:34:07 -04:00
parent f48e496e65
commit 3e5acb5e97
4 changed files with 284 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Added to field extension 'RepoOwnerPicker' for retrieving GitLab repository owners.
@@ -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 { GitLabRepoOwnerPicker } from './GitLabRepoOwnerPicker';
import { act, fireEvent, waitFor, screen } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
describe('GitLabRepoOwnerPicker', () => {
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]]}>
<GitLabRepoOwnerPicker
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]]}>
<GitLabRepoOwnerPicker
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]]}>
<GitLabRepoOwnerPicker
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]]}>
<GitLabRepoOwnerPicker
onChange={onChange}
state={{
host: 'gitlab.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]]}>
<GitLabRepoOwnerPicker
onChange={onChange}
state={{
host: 'gitlab.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 `GitLabRepoOwnerPicker`
* field extension.
*
* @public
*
*/
export const GitLabRepoOwnerPicker = ({
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: 'groups',
provider: 'gitlab',
context: { host },
})
.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>
);
};
@@ -27,6 +27,7 @@ import { RepoOwnerPickerProps } from './schema';
import { RepoOwnerPickerState } from './types';
import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker';
import { GitHubRepoOwnerPicker } from './GitHubRepoOwnerPicker';
import { GitLabRepoOwnerPicker } from './GitLabRepoOwnerPicker';
/**
* The underlying component that is rendered in the form for the `RepoOwnerPicker`
@@ -120,6 +121,22 @@ export const RepoOwnerPicker = (props: RepoOwnerPickerProps) => {
excludedOwners={excludedOwners}
/>
);
case 'gitlab':
return (
<GitLabRepoOwnerPicker
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