Merge pull request #27367 from swnia/repopicker-gitlab-autocomplete

Add autocomplete to GitlabRepoUrlPicker
This commit is contained in:
Ben Lambert
2024-11-26 11:59:47 +01:00
committed by GitHub
23 changed files with 578 additions and 130 deletions
+2 -1
View File
@@ -531,7 +531,8 @@ export class ScaffolderClient implements ScaffolderApi_2 {
context?: Record<string, string>;
}): Promise<{
results: {
title: string;
title?: string;
id: string;
}[];
}>;
// (undocumented)
+4 -5
View File
@@ -22,10 +22,6 @@ import {
} from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { Observable } from '@backstage/types';
import qs from 'qs';
import queryString from 'qs';
import ObservableImpl from 'zen-observable';
import {
ListActionsResponse,
LogEvent,
@@ -40,10 +36,13 @@ import {
ScaffolderTask,
TemplateParameterSchema,
} from '@backstage/plugin-scaffolder-react';
import { Observable } from '@backstage/types';
import {
EventSourceMessage,
fetchEventSource,
} from '@microsoft/fetch-event-source';
import { default as qs, default as queryString } from 'qs';
import ObservableImpl from 'zen-observable';
/**
* An API to interact with the scaffolder backend.
@@ -365,7 +364,7 @@ export class ScaffolderClient implements ScaffolderApi {
provider: string;
resource: string;
context?: Record<string, string>;
}): Promise<{ results: { title: string }[] }> {
}): Promise<{ results: { title?: string; id: string }[] }> {
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const url = `${baseUrl}/v2/autocomplete/${provider}/${resource}`;
@@ -14,15 +14,15 @@
* 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 React, { useCallback, useState } from 'react';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import React, { useCallback, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { useApi } from '@backstage/core-plugin-api';
import { BaseRepoBranchPickerProps } from './types';
import FormHelperText from '@material-ui/core/FormHelperText';
/**
* The underlying component that is rendered in the form for the `BitbucketRepoBranchPicker`
@@ -66,7 +66,7 @@ export const BitbucketRepoBranchPicker = ({
provider: 'bitbucket-cloud',
})
.then(({ results }) => {
setAvailableBranches(results.map(r => r.title));
setAvailableBranches(results.map(r => r.title!));
})
.catch(() => {
setAvailableBranches([]);
@@ -14,22 +14,22 @@
* limitations under the License.
*/
import React from 'react';
import { BitbucketRepoPicker } from './BitbucketRepoPicker';
import { fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
ScaffolderApi,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { BitbucketRepoPicker } from './BitbucketRepoPicker';
describe('BitbucketRepoPicker', () => {
const scaffolderApiMock: Partial<ScaffolderApi> = {
autocomplete: jest.fn().mockImplementation(opts =>
Promise.resolve({
results: [{ title: `${opts.resource}_example` }],
results: [{ id: `${opts.resource}_example` }],
}),
),
};
@@ -266,7 +266,7 @@ describe('BitbucketRepoPicker', () => {
// Verify that the available repos are updated
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
availableRepos: ['repositories_example'],
availableRepos: [{ name: 'repositories_example' }],
}),
);
});
@@ -13,18 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Select, SelectItem } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import { Select, SelectItem } from '@backstage/core-components';
import { BaseRepoUrlPickerProps } from './types';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import React, { useCallback, useEffect, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { useApi } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { BaseRepoUrlPickerProps } from './types';
/**
* The underlying component that is rendered in the form for the `BitbucketRepoPicker`
@@ -89,7 +89,7 @@ export const BitbucketRepoPicker = (
provider: 'bitbucket-cloud',
})
.then(({ results }) => {
setAvailableWorkspaces(results.map(r => r.title));
setAvailableWorkspaces(results.map(r => r.id));
})
.catch(() => {
setAvailableWorkspaces([]);
@@ -118,7 +118,7 @@ export const BitbucketRepoPicker = (
provider: 'bitbucket-cloud',
})
.then(({ results }) => {
setAvailableProjects(results.map(r => r.title));
setAvailableProjects(results.map(r => r.id));
})
.catch(() => {
setAvailableProjects([]);
@@ -148,7 +148,11 @@ export const BitbucketRepoPicker = (
provider: 'bitbucket-cloud',
})
.then(({ results }) => {
onChange({ availableRepos: results.map(r => r.title) });
onChange({
availableRepos: results.map(r => {
return { name: r.id };
}),
});
})
.catch(() => {
onChange({ availableRepos: [] });
@@ -14,22 +14,36 @@
* limitations under the License.
*/
import {
ScaffolderApi,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { GitlabRepoPicker } from './GitlabRepoPicker';
import { fireEvent } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
describe('GitlabRepoPicker', () => {
const scaffolderApiMock: Partial<ScaffolderApi> = {
autocomplete: jest.fn().mockImplementation(opts =>
Promise.resolve({
results: [{ title: `${opts.resource}_example` }],
}),
),
};
describe('owner field', () => {
it('renders a select if there is a list of allowed owners', async () => {
const allowedOwners = ['owner1', 'owner2'];
const { findByText } = await renderInTestApp(
<GitlabRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ repoName: 'repo' }}
allowedOwners={allowedOwners}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitlabRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ repoName: 'repo' }}
allowedOwners={allowedOwners}
/>
,
</TestApiProvider>,
);
expect(await findByText('owner1')).toBeInTheDocument();
@@ -40,12 +54,15 @@ describe('GitlabRepoPicker', () => {
const onChange = jest.fn();
const allowedOwners = ['owner1', 'owner2'];
const { getByRole } = await renderInTestApp(
<GitlabRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ repoName: 'repo' }}
allowedOwners={allowedOwners}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitlabRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ repoName: 'repo' }}
allowedOwners={allowedOwners}
/>
,
</TestApiProvider>,
);
await fireEvent.change(getByRole('combobox'), {
@@ -59,12 +76,15 @@ describe('GitlabRepoPicker', () => {
const onChange = jest.fn();
const allowedOwners = ['owner1'];
const { getByRole } = await renderInTestApp(
<GitlabRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ repoName: 'repo' }}
allowedOwners={allowedOwners}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitlabRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ repoName: 'repo' }}
allowedOwners={allowedOwners}
/>
,
</TestApiProvider>,
);
expect(getByRole('combobox')).toBeDisabled();
@@ -73,14 +93,20 @@ describe('GitlabRepoPicker', () => {
it('should display free text if no allowed owners are passed', async () => {
const onChange = jest.fn();
const { getAllByRole } = await renderInTestApp(
<GitlabRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ repoName: 'repo' }}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitlabRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ repoName: 'repo' }}
/>
</TestApiProvider>,
);
const ownerField = getAllByRole('textbox')[0];
fireEvent.change(ownerField, { target: { value: 'my-mock-owner' } });
ownerField.focus();
fireEvent.change(ownerField, {
target: { value: 'my-mock-owner' },
});
ownerField.blur();
expect(onChange).toHaveBeenCalledWith({ owner: 'my-mock-owner' });
});
@@ -13,28 +13,101 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Select, SelectItem } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
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 { Select, SelectItem } from '@backstage/core-components';
import { BaseRepoUrlPickerProps } from './types';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import Autocomplete from '@material-ui/lab/Autocomplete';
import React, { useCallback, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { scaffolderTranslationRef } from '../../../translation';
import { BaseRepoUrlPickerProps } from './types';
export const GitlabRepoPicker = (
props: BaseRepoUrlPickerProps<{
allowedOwners?: string[];
allowedRepos?: string[];
accessToken?: string;
}>,
) => {
const { allowedOwners = [], state, onChange, rawErrors } = props;
const { allowedOwners = [], state, onChange, rawErrors, accessToken } = props;
const [availableGroups, setAvailableGroups] = useState<
{ title: string; id: string }[]
>([]);
const { t } = useTranslationRef(scaffolderTranslationRef);
const ownerItems: SelectItem[] = allowedOwners
? allowedOwners.map(i => ({ label: i, value: i }))
: [{ label: 'Loading...', value: 'loading' }];
const { owner } = state;
const { owner, host } = state;
const scaffolderApi = useApi(scaffolderApiRef);
const updateAvailableGroups = useCallback(() => {
if (!scaffolderApi.autocomplete || !accessToken || !host) {
setAvailableGroups([]);
return;
}
scaffolderApi
.autocomplete({
token: accessToken,
resource: 'groups',
provider: 'gitlab',
context: { host },
})
.then(({ results }) => {
setAvailableGroups(
results.map(r => {
return {
title: r.title!,
id: r.id,
};
}),
);
})
.catch(() => {
setAvailableGroups([]);
});
}, [scaffolderApi, accessToken, host]);
useDebounce(updateAvailableGroups, 500, [updateAvailableGroups]);
// Update available repositories when client is available and group changes
const updateAvailableRepositories = useCallback(() => {
if (!scaffolderApi.autocomplete || !accessToken || !host || !owner) {
onChange({ availableRepos: [] });
return;
}
const selectedGroup = availableGroups.find(group => group.title === owner);
scaffolderApi
.autocomplete({
token: accessToken,
resource: 'repositories',
context: {
id: selectedGroup?.id ?? '',
host,
},
provider: 'gitlab',
})
.then(({ results }) => {
onChange({
availableRepos: results.map(r => {
return { name: r.title!, id: r.id };
}),
});
})
.catch(() => {
onChange({ availableRepos: [] });
});
}, [scaffolderApi, accessToken, host, owner, onChange, availableGroups]);
useDebounce(updateAvailableRepositories, 500, [updateAvailableRepositories]);
return (
<>
@@ -64,12 +137,21 @@ export const GitlabRepoPicker = (
</FormHelperText>
</>
) : (
<TextField
id="ownerInput"
label={t('fields.gitlabRepoPicker.owner.inputTitle')}
onChange={e => onChange({ owner: e.target.value })}
helperText={t('fields.gitlabRepoPicker.owner.description')}
<Autocomplete
value={owner}
onChange={(_, newValue) => {
onChange({ owner: newValue || '' });
}}
options={availableGroups.map(group => group.title)}
renderInput={params => (
<TextField
{...params}
label={t('fields.gitlabRepoPicker.owner.title')}
required
/>
)}
freeSolo
autoSelect
/>
)}
</FormControl>
@@ -15,26 +15,26 @@
*/
import { useApi } from '@backstage/core-plugin-api';
import {
scmIntegrationsApiRef,
scmAuthApiRef,
scmIntegrationsApiRef,
} from '@backstage/integration-react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { GithubRepoPicker } from './GithubRepoPicker';
import { GiteaRepoPicker } from './GiteaRepoPicker';
import { GitlabRepoPicker } from './GitlabRepoPicker';
import { AzureRepoPicker } from './AzureRepoPicker';
import { BitbucketRepoPicker } from './BitbucketRepoPicker';
import { GerritRepoPicker } from './GerritRepoPicker';
import { RepoUrlPickerHost } from './RepoUrlPickerHost';
import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName';
import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils';
import { RepoUrlPickerFieldSchema } from './schema';
import { RepoUrlPickerState } from './types';
import useDebounce from 'react-use/esm/useDebounce';
import { useTemplateSecrets } from '@backstage/plugin-scaffolder-react';
import Box from '@material-ui/core/Box';
import Divider from '@material-ui/core/Divider';
import Typography from '@material-ui/core/Typography';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { AzureRepoPicker } from './AzureRepoPicker';
import { BitbucketRepoPicker } from './BitbucketRepoPicker';
import { GerritRepoPicker } from './GerritRepoPicker';
import { GiteaRepoPicker } from './GiteaRepoPicker';
import { GithubRepoPicker } from './GithubRepoPicker';
import { GitlabRepoPicker } from './GitlabRepoPicker';
import { RepoUrlPickerHost } from './RepoUrlPickerHost';
import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName';
import { RepoUrlPickerFieldSchema } from './schema';
import { RepoUrlPickerState } from './types';
import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils';
export { RepoUrlPickerSchema } from './schema';
@@ -203,6 +203,10 @@ export const RepoUrlPicker = (
rawErrors={rawErrors}
state={state}
onChange={updateLocalState}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
/>
)}
{hostType === 'bitbucket' && (
@@ -238,7 +242,11 @@ export const RepoUrlPicker = (
repoName={state.repoName}
allowedRepos={allowedRepos}
onChange={repo =>
setState(prevState => ({ ...prevState, repoName: repo }))
setState(prevState => ({
...prevState,
repoName: repo.name,
id: repo.id || '',
}))
}
rawErrors={rawErrors}
availableRepos={state.availableRepos}
@@ -13,12 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName';
describe('RepoUrlPickerRepoName', () => {
it('should call onChange with the first allowed repo if there is none set already', async () => {
@@ -32,7 +32,7 @@ describe('RepoUrlPickerRepoName', () => {
/>,
);
expect(onChange).toHaveBeenCalledWith('foo');
expect(onChange).toHaveBeenCalledWith({ name: 'foo' });
});
it('should render a dropdown of all the options', async () => {
@@ -78,11 +78,11 @@ describe('RepoUrlPickerRepoName', () => {
textArea.blur();
});
expect(onChange).toHaveBeenCalledWith('foo');
expect(onChange).toHaveBeenCalledWith({ name: 'foo' });
});
it('should autocomplete with provided availableRepos', async () => {
const availableRepos = ['foo', 'bar'];
const availableRepos = [{ name: 'foo' }, { name: 'bar' }];
const onChange = jest.fn();
@@ -100,11 +100,11 @@ describe('RepoUrlPickerRepoName', () => {
// Verify that available repos are shown
for (const repo of availableRepos) {
expect(getByText(repo)).toBeInTheDocument();
expect(getByText(repo.name)).toBeInTheDocument();
}
// Verify that selecting an option calls onChange
await userEvent.click(getByText(availableRepos[0]));
await userEvent.click(getByText(availableRepos[0].name));
expect(onChange).toHaveBeenCalledWith(availableRepos[0]);
});
});
@@ -13,21 +13,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect } from 'react';
import { Select, SelectItem } from '@backstage/core-components';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import Autocomplete from '@material-ui/lab/Autocomplete';
import React, { useEffect } from 'react';
import { scaffolderTranslationRef } from '../../../translation';
import { AvailableRepositories } from './types';
export const RepoUrlPickerRepoName = (props: {
repoName?: string;
allowedRepos?: string[];
onChange: (host: string) => void;
onChange: (chosenRepo: AvailableRepositories) => void;
rawErrors: string[];
availableRepos?: string[];
availableRepos?: AvailableRepositories[];
}) => {
const { repoName, allowedRepos, onChange, rawErrors, availableRepos } = props;
const { t } = useTranslationRef(scaffolderTranslationRef);
@@ -37,7 +38,7 @@ export const RepoUrlPickerRepoName = (props: {
if (!repoName) {
// Set the first of the allowedRepos option if that available
if (allowedRepos?.length) {
onChange(allowedRepos[0]);
onChange({ name: allowedRepos[0] });
}
}
}, [allowedRepos, repoName, onChange]);
@@ -58,7 +59,9 @@ export const RepoUrlPickerRepoName = (props: {
native
label={t('fields.repoUrlPicker.repository.title')}
onChange={selected =>
onChange(String(Array.isArray(selected) ? selected[0] : selected))
onChange({
name: String(Array.isArray(selected) ? selected[0] : selected),
})
}
disabled={allowedRepos.length === 1}
selected={repoName}
@@ -68,9 +71,12 @@ export const RepoUrlPickerRepoName = (props: {
<Autocomplete
value={repoName}
onInputChange={(_, newValue) => {
onChange(newValue || '');
const selectedRepo = availableRepos?.find(
r => r.name === newValue,
);
onChange(selectedRepo || { name: newValue || '' });
}}
options={availableRepos || []}
options={(availableRepos || []).map(r => r.name)}
renderInput={params => (
<TextField
{...params}
@@ -13,6 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface AvailableRepositories {
name: string;
id?: string;
}
export interface RepoUrlPickerState {
host?: string;
owner?: string;
@@ -20,7 +25,8 @@ export interface RepoUrlPickerState {
organization?: string;
workspace?: string;
project?: string;
availableRepos?: string[];
id?: string;
availableRepos?: AvailableRepositories[];
}
export type BaseRepoUrlPickerProps<T extends {} = {}> = T & {
@@ -53,9 +53,10 @@ describe('utils', () => {
organization: 'organization',
workspace: 'workspace',
project: 'backstage',
id: '1234',
}),
).toBe(
'github.com?owner=owner&repo=backstage&organization=organization&workspace=workspace&project=backstage',
'github.com?owner=owner&repo=backstage&organization=organization&workspace=workspace&project=backstage&id=1234',
);
});
});
@@ -64,7 +65,7 @@ describe('utils', () => {
it('should parse a complete string', () => {
expect(
parseRepoPickerUrl(
'github.com?owner=owner&repo=backstage&organization=organization&workspace=workspace&project=backstage',
'github.com?owner=owner&repo=backstage&organization=organization&workspace=workspace&project=backstage&id=1234',
),
).toEqual({
host: 'github.com',
@@ -73,6 +74,7 @@ describe('utils', () => {
organization: 'organization',
workspace: 'workspace',
project: 'backstage',
id: '1234',
});
});
});
@@ -37,6 +37,9 @@ export function serializeRepoPickerUrl(data: RepoUrlPickerState) {
if (data.project) {
params.set('project', data.project);
}
if (data.id) {
params.set('id', data.id);
}
return `${data.host}?${params.toString()}`;
}
@@ -50,6 +53,7 @@ export function parseRepoPickerUrl(
let organization = '';
let workspace = '';
let project = '';
let id = '';
try {
if (url) {
@@ -60,9 +64,10 @@ export function parseRepoPickerUrl(
organization = parsed.searchParams.get('organization') || '';
workspace = parsed.searchParams.get('workspace') || '';
project = parsed.searchParams.get('project') || '';
id = parsed.searchParams.get('id') || '';
}
} catch {
/* ok */
}
return { host, owner, repoName, organization, workspace, project };
return { host, owner, repoName, organization, workspace, project, id };
}