diff --git a/.changeset/legal-results-kneel.md b/.changeset/legal-results-kneel.md new file mode 100644 index 0000000000..52a9af46ef --- /dev/null +++ b/.changeset/legal-results-kneel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Extended the `RepoOwnerPicker` implementation with a custom variant for GitLab. diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitLabRepoOwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitLabRepoOwnerPicker.test.tsx new file mode 100644 index 0000000000..fe60e1b376 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitLabRepoOwnerPicker.test.tsx @@ -0,0 +1,166 @@ +/* + * 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 = { + autocomplete: jest.fn().mockResolvedValue({ + results: [{ title: 'owner1' }, { title: 'owner2' }], + }), + }; + + it('renders an input field', async () => { + const { getByRole } = await renderInTestApp( + + + , + ); + + expect(getByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox')).toHaveValue('owner1'); + }); + + it('input field disabled', async () => { + await renderInTestApp( + + + , + ); + + 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( + + + , + ); + + 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 () => { + jest.useFakeTimers(); + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + const onChange = jest.fn(); + + try { + const { getByRole } = await renderInTestApp( + + + , + ); + + // Open the Autocomplete dropdown + const input = getByRole('textbox'); + await user.click(input); + + // Flush the component debounce and any pending async updates + act(() => { + jest.advanceTimersByTime(500); + }); + await act(async () => { + await Promise.resolve(); + }); + + // Verify that the available owners are shown + expect(await screen.findByText('owner1')).toBeInTheDocument(); + + // Verify that selecting an option calls onChange + await user.click(screen.getByText('owner1')); + expect(onChange).toHaveBeenCalledWith({ + owner: 'owner1', + }); + } finally { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }); + + it('should filter out excluded owners', async () => { + const onChange = jest.fn(); + + const { getByRole, getByText } = await renderInTestApp( + + + , + ); + + // 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(); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitLabRepoOwnerPicker.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitLabRepoOwnerPicker.tsx new file mode 100644 index 0000000000..1ebb2567be --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitLabRepoOwnerPicker.tsx @@ -0,0 +1,141 @@ +/* + * 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 MuiTextField from '@material-ui/core/TextField'; +import MuiAutocomplete 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'; +import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha'; +import { Autocomplete as BuiAutocomplete } from '../Autocomplete'; +import type { Key } from 'react-aria-components'; + +/** + * 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 theme = useScaffolderTheme(); + const { host, owner } = state; + + const [availableOwners, setAvailableOwners] = useState([]); + + 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.title!) + .filter(title => !excludedOwners.includes(title)); + + setAvailableOwners(owners); + }) + .catch(() => { + setAvailableOwners([]); + }); + }, [host, accessToken, scaffolderApi, excludedOwners]); + + useDebounce(updateAvailableOwners, 500, [updateAvailableOwners]); + + if (theme === 'bui') { + const options = availableOwners.map(o => ({ label: o, value: o })); + + return ( + onChange({ owner: value })} + onSelectionChange={(key: Key | null) => { + if (key !== null) { + onChange({ owner: String(key) }); + } + }} + options={options} + isDisabled={isDisabled} + isRequired={required} + isInvalid={rawErrors?.length > 0 && !owner} + /> + ); + } + + return ( + 0 && !owner} + > + { + onChange({ owner: newValue || '' }); + }} + disabled={isDisabled} + options={availableOwners} + renderInput={params => ( + + )} + freeSolo + autoSelect + /> + + {schema?.description ?? t('fields.repoOwnerPicker.description')} + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx index c51d74416b..d7f8d2f71d 100644 --- a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx @@ -29,6 +29,7 @@ import { scaffolderApiRef, useTemplateSecrets, ScaffolderRJSFField, + ScaffolderApi, } from '@backstage/plugin-scaffolder-react'; import { act, fireEvent, screen } from '@testing-library/react'; import { RepoOwnerPicker } from './RepoOwnerPicker'; @@ -38,6 +39,10 @@ describe('RepoOwnerPicker', () => { byHost: () => ({ type: 'github' }), }; + const mockIntegrationsApiGitLab: Partial = { + byHost: () => ({ type: 'gitlab' }), + }; + let mockScmAuthApi: Partial; beforeEach(() => { @@ -305,4 +310,204 @@ describe('RepoOwnerPicker', () => { expect(getByText('abc123')).toBeInTheDocument(); }); }); + + describe('requestUserCredentialsGitLab', () => { + it('should call the scmAuthApi with the correct params', async () => { + const secretsKey = 'testKey'; + + const SecretsComponent = () => { + const { secrets } = useTemplateSecrets(); + const secret = secrets[secretsKey]; + return secret ?
{secret}
: null; + }; + + const { getByText } = await renderInTestApp( + + +
, + }} + /> + + + , + ); + + 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.com', + additionalScope: { + repoWrite: true, + customScopes: { + gitlab: ['workflow'], + }, + }, + }); + + expect(getByText('abc123')).toBeInTheDocument(); + }); + + it('should call the scmAuthApi with the correct params if workspace is nested', async () => { + await renderInTestApp( + + + , + }} + /> + + , + ); + + 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.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 ?
{secret}
: null; + }; + + const { getByText } = await renderInTestApp( + + + , + }} + /> + + + , + ); + + 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(); + }); + + it('should route to GitLabRepoOwnerPicker and call autocomplete with gitlab provider and groups resource', async () => { + const secretsKey = 'testKey'; + const mockScaffolderApi: Partial = { + autocomplete: jest.fn().mockResolvedValue({ results: [] }), + }; + + await renderInTestApp( + + + , + }} + /> + + , + ); + + await act(async () => { + // wait for both the RepoOwnerPicker and GitLabRepoOwnerPicker debounces + await new Promise(resolve => setTimeout(resolve, 600)); + }); + + expect(mockScaffolderApi.autocomplete).toHaveBeenCalledWith({ + token: 'abc123', + resource: 'groups', + provider: 'gitlab', + context: { host: 'gitlab.com' }, + }); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx index 42c5251d1f..024f7f03a1 100644 --- a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx @@ -27,6 +27,8 @@ 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` * field extension. @@ -118,6 +120,22 @@ export const RepoOwnerPicker = (props: RepoOwnerPickerProps) => { excludedOwners={excludedOwners} /> ); + case 'gitlab': + return ( + + ); default: return (