Add autocomplete to GitlabRepoUrlPicker

Signed-off-by: Severin Wischmann <severinwischmann@nianticlabs.com>
This commit is contained in:
Severin Wischmann
2024-10-25 16:04:51 +02:00
parent 13180c5260
commit be6de37e4d
9 changed files with 305 additions and 70 deletions
@@ -25,7 +25,7 @@ export async function handleAutocompleteRequest({
resource: string;
token: string;
context: Record<string, string>;
}): Promise<{ results: { title: string }[] }> {
}): Promise<{ results: { title: string; id?: string }[] }> {
const client = BitbucketCloudClient.fromConfig({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
@@ -34,10 +34,13 @@ export async function handleAutocompleteRequest({
switch (resource) {
case 'workspaces': {
const results: { title: string }[] = [];
const results: { title: string; id: string }[] = [];
for await (const page of client.listWorkspaces().iteratePages()) {
const slugs = [...page.values!].map(p => ({ title: p.slug! }));
const slugs = [...page.values!].map(p => ({
title: p.slug!,
id: p.uuid!,
}));
results.push(...slugs);
}
@@ -47,12 +50,15 @@ export async function handleAutocompleteRequest({
if (!context.workspace)
throw new InputError('Missing workspace context parameter');
const results: { title: string }[] = [];
const results: { title: string; id: string }[] = [];
for await (const page of client
.listProjectsByWorkspace(context.workspace)
.iteratePages()) {
const keys = [...page.values!].map(p => ({ title: p.key! }));
const keys = [...page.values!].map(p => ({
title: p.key!,
id: p.uuid!,
}));
results.push(...keys);
}
@@ -64,14 +70,17 @@ export async function handleAutocompleteRequest({
'Missing workspace and/or project context parameter',
);
const results: { title: string }[] = [];
const results: { title: string; id: string }[] = [];
for await (const page of client
.listRepositoriesByWorkspace(context.workspace, {
q: `project.key="${context.project}"`,
})
.iteratePages()) {
const slugs = [...page.values!].map(p => ({ title: p.slug! }));
const slugs = [...page.values!].map(p => ({
title: p.slug!,
id: p.uuid!,
}));
results.push(...slugs);
}
@@ -0,0 +1,108 @@
/*
* Copyright 2024 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 { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { getClient } from '../util';
export function createHandleAutocompleteRequest(options: {
integrations: ScmIntegrationRegistry;
}) {
return async function handleAutocompleteRequest({
resource,
token,
context,
}: {
resource: string;
token: string;
context: Record<string, string>;
}): Promise<{
results: {
title: string;
id?: string;
}[];
}> {
const { integrations } = options;
const client = getClient({
host: context.host ?? 'gitlab.com',
integrations,
token,
});
switch (resource) {
case 'groups': {
let groups: any[] = [];
let page = 1;
const perPage = 100;
let response = [];
let continueFetch = true;
while (continueFetch) {
response = await client.Groups.all({
pagination: 'offset',
page,
perPage,
});
groups = groups.concat(response);
if (response.length < perPage) continueFetch = false;
page++;
}
const result: {
results: {
title: string;
id: string;
}[];
} = {
results: groups.map(group => ({
title: group.full_path,
id: group.id.toString(),
})),
};
// append also user context
const user = await client.Users.showCurrentUser();
result.results.push({
title: user.username,
id: user.id.toString(),
});
return result;
}
case 'repositories': {
if (!context.id)
throw new InputError('Missing groupId and userId context parameter');
let response;
if (
context.id === (await client.Users.showCurrentUser())?.id.toString()
) {
response = await client.Users.allProjects(context.id);
} else {
response = await client.Groups.allProjects(context.id);
}
return {
results: response.map(project => ({
title: project.name.trim(),
id: project.id.toString(),
})),
};
}
default:
throw new InputError(`Invalid resource: ${resource}`);
}
};
}
@@ -18,7 +18,10 @@ import {
createBackendModule,
} from '@backstage/backend-plugin-api';
import { ScmIntegrations } from '@backstage/integration';
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
import {
scaffolderActionsExtensionPoint,
scaffolderAutocompleteExtensionPoint,
} from '@backstage/plugin-scaffolder-node/alpha';
import {
createGitlabGroupEnsureExistsAction,
createGitlabIssueAction,
@@ -31,6 +34,7 @@ import {
createTriggerGitlabPipelineAction,
editGitlabIssueAction,
} from './actions';
import { createHandleAutocompleteRequest } from './autocomplete/autocomplete';
/**
* @public
@@ -43,9 +47,10 @@ export const gitlabModule = createBackendModule({
registerInit({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
autocomplete: scaffolderAutocompleteExtensionPoint,
config: coreServices.rootConfig,
},
async init({ scaffolder, config }) {
async init({ scaffolder, autocomplete, config }) {
const integrations = ScmIntegrations.fromConfig(config);
scaffolder.addActions(
@@ -60,6 +65,11 @@ export const gitlabModule = createBackendModule({
createPublishGitlabMergeRequestAction({ integrations }),
createTriggerGitlabPipelineAction({ integrations }),
);
autocomplete.addAutocompleteProvider({
id: 'gitlab',
handler: createHandleAutocompleteRequest({ integrations }),
});
},
});
},
+2 -2
View File
@@ -16,10 +16,10 @@
import { createExtensionPoint } from '@backstage/backend-plugin-api';
import {
TaskBroker,
TemplateAction,
TemplateFilter,
TemplateGlobal,
TaskBroker,
} from '@backstage/plugin-scaffolder-node';
export * from './tasks/alpha';
@@ -95,7 +95,7 @@ export type AutocompleteHandler = ({
resource: string;
token: string;
context: Record<string, string>;
}) => Promise<{ results: { title: string }[] }>;
}) => Promise<{ results: { title: string; id?: string }[] }>;
/**
* Extension point for adding autocomplete handler providers
+1 -1
View File
@@ -245,5 +245,5 @@ export interface ScaffolderApi {
provider: string;
resource: string;
context?: Record<string, string>;
}): Promise<{ results: { title: string }[] }>;
}): Promise<{ results: { title: string; id?: string }[] }>;
}
@@ -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`
@@ -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,97 @@
* 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 => r.title) });
})
.catch(() => {
onChange({ availableRepos: [] });
});
}, [scaffolderApi, accessToken, host, owner, onChange, availableGroups]);
useDebounce(updateAvailableRepositories, 500, [updateAvailableRepositories]);
return (
<>
@@ -64,12 +133,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' && (