feat: use autocomplete endpoint in frontend

Signed-off-by: Benjamin Janssens <benji.janssens@gmail.com>
This commit is contained in:
Benjamin Janssens
2024-06-13 14:58:38 +02:00
parent 28c991d6e4
commit 8a03156d91
8 changed files with 175 additions and 173 deletions
@@ -229,4 +229,11 @@ export interface ScaffolderApi {
streamLogs(options: ScaffolderStreamLogsOptions): Observable<LogEvent>;
dryRun?(options: ScaffolderDryRunOptions): Promise<ScaffolderDryRunResponse>;
autocomplete(
token: string,
provider: string,
resource: string,
params?: Record<string, string>,
): Promise<string[]>;
}
@@ -36,6 +36,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
};
const catalogApiMock: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
-1
View File
@@ -67,7 +67,6 @@
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-bitbucket-cloud-common": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/plugin-permission-react": "workspace:^",
+24
View File
@@ -337,4 +337,28 @@ export class ScaffolderClient implements ScaffolderApi {
return await response.json();
}
async autocomplete(
token: string,
provider: string,
resource: string,
params?: Record<string, string>,
): Promise<string[]> {
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const query = new URLSearchParams({
...params,
token,
});
const url = `${baseUrl}/v2/autocomplete/${provider}/${resource}?${query}`;
const response = await this.fetchApi.fetch(url, {
method: 'GET',
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return await response.json();
}
}
@@ -17,23 +17,33 @@
import React from 'react';
import { BitbucketRepoPicker } from './BitbucketRepoPicker';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { Models } from '@backstage/plugin-bitbucket-cloud-common';
import userEvent from '@testing-library/user-event';
const server = setupServer();
import { TestApiProvider } from '@backstage/test-utils';
import {
ScaffolderApi,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
describe('BitbucketRepoPicker', () => {
const scaffolderApiMock: Partial<ScaffolderApi> = {
autocomplete: jest
.fn()
.mockImplementation((_token, _provider, resource) => [
`${resource}_example`,
]),
};
it('renders a select if there is a list of allowed owners', async () => {
const allowedOwners = ['owner1', 'owner2'];
const { findByText } = render(
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedOwners={allowedOwners}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedOwners={allowedOwners}
/>
</TestApiProvider>,
);
expect(await findByText('owner1')).toBeInTheDocument();
@@ -44,7 +54,13 @@ describe('BitbucketRepoPicker', () => {
const state = { host: 'bitbucket.org', workspace: 'lolsWorkspace' };
const { getAllByRole } = render(
<BitbucketRepoPicker onChange={jest.fn()} rawErrors={[]} state={state} />,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={state}
/>
</TestApiProvider>,
);
expect(getAllByRole('textbox')).toHaveLength(2);
@@ -57,7 +73,13 @@ describe('BitbucketRepoPicker', () => {
};
const { getAllByRole } = render(
<BitbucketRepoPicker onChange={jest.fn()} rawErrors={[]} state={state} />,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={state}
/>
</TestApiProvider>,
);
expect(getAllByRole('textbox')).toHaveLength(1);
@@ -67,11 +89,13 @@ describe('BitbucketRepoPicker', () => {
it('calls onChange when the workspace changes', () => {
const onChange = jest.fn();
const { getAllByRole } = render(
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
/>
</TestApiProvider>,
);
const workspaceInput = getAllByRole('textbox')[0];
@@ -86,11 +110,13 @@ describe('BitbucketRepoPicker', () => {
it('calls onChange when the project changes', () => {
const onChange = jest.fn();
const { getAllByRole } = render(
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
/>
</TestApiProvider>,
);
const projectInput = getAllByRole('textbox')[1];
@@ -102,11 +128,13 @@ describe('BitbucketRepoPicker', () => {
it('Does not render a select if the list of allowed projects does not exist', async () => {
const { getAllByRole } = render(
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
/>
</TestApiProvider>,
);
expect(getAllByRole('textbox')).toHaveLength(2);
@@ -115,12 +143,14 @@ describe('BitbucketRepoPicker', () => {
it('Does not render a select if the list of allowed projects is empty', async () => {
const { getAllByRole } = render(
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedProjects={[]}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedProjects={[]}
/>
</TestApiProvider>,
);
expect(getAllByRole('textbox')).toHaveLength(2);
@@ -130,12 +160,14 @@ describe('BitbucketRepoPicker', () => {
it('Does render a select if there is a list of allowed projects', async () => {
const allowedProjects = ['project1', 'project2'];
const { findByText } = render(
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedProjects={allowedProjects}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedProjects={allowedProjects}
/>
</TestApiProvider>,
);
expect(await findByText('project1')).toBeInTheDocument();
@@ -144,71 +176,17 @@ describe('BitbucketRepoPicker', () => {
});
describe('autocompletion', () => {
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeEach(() => {
// BitbucketCloudClient.listWorkspaces()
server.use(
rest.get('https://api.bitbucket.org/2.0/workspaces', (_, res, ctx) => {
const response = {
values: [
{
type: 'workspace',
slug: 'workspace1',
} as Models.Workspace,
],
};
return res(ctx.json(response));
}),
);
// BitbucketCloudClient.listProjectsByWorkspace()
server.use(
rest.get(
'https://api.bitbucket.org/2.0/workspaces/workspace1/projects',
(_, res, ctx) => {
const response = {
values: [
{
type: 'project',
key: 'project1',
} as Models.Project,
],
};
return res(ctx.json(response));
},
),
);
// BitbucketCloudClient.listRepositoriesByWorkspace()
server.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/workspace1',
(_, res, ctx) => {
const response = {
values: [
{
type: 'repository',
slug: 'repo1',
} as Models.Repository,
],
};
return res(ctx.json(response));
},
),
);
});
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
it('should populate workspaces if host is set and accessToken is provided', async () => {
const onChange = jest.fn();
const { getAllByRole, getByText } = render(
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
accessToken="foo"
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
accessToken="foo"
/>
</TestApiProvider>,
);
// Open the Autcomplete dropdown
@@ -216,22 +194,28 @@ describe('BitbucketRepoPicker', () => {
await userEvent.click(workspaceInput);
// Verify that the available workspaces are shown
await waitFor(() => expect(getByText('workspace1')).toBeInTheDocument());
await waitFor(() =>
expect(getByText('workspaces_example')).toBeInTheDocument(),
);
// Verify that selecting an option calls onChange
await userEvent.click(getByText('workspace1'));
expect(onChange).toHaveBeenCalledWith({ workspace: 'workspace1' });
await userEvent.click(getByText('workspaces_example'));
expect(onChange).toHaveBeenCalledWith({
workspace: 'workspaces_example',
});
});
it('should populate projects if host and workspace are set and accessToken is provided', async () => {
const onChange = jest.fn();
const { getAllByRole, getByText } = render(
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org', workspace: 'workspace1' }}
accessToken="foo"
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org', workspace: 'workspace1' }}
accessToken="foo"
/>
</TestApiProvider>,
);
// Open the Autcomplete dropdown
@@ -239,31 +223,37 @@ describe('BitbucketRepoPicker', () => {
await userEvent.click(projectInput);
// Verify that the available projects are shown
await waitFor(() => expect(getByText('project1')).toBeInTheDocument());
await waitFor(() =>
expect(getByText('projects_example')).toBeInTheDocument(),
);
// Verify that selecting an option calls onChange
await userEvent.click(getByText('project1'));
expect(onChange).toHaveBeenCalledWith({ project: 'project1' });
await userEvent.click(getByText('projects_example'));
expect(onChange).toHaveBeenCalledWith({ project: 'projects_example' });
});
it('should populate repositories if host, workspace and project are set and accessToken is provided', async () => {
const onChange = jest.fn();
render(
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{
host: 'bitbucket.org',
workspace: 'workspace1',
project: 'project1',
}}
accessToken="foo"
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{
host: 'bitbucket.org',
workspace: 'workspace1',
project: 'project1',
}}
accessToken="foo"
/>
</TestApiProvider>,
);
// Verify that the available repos are updated
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({ availableRepos: ['repo1'] }),
expect(onChange).toHaveBeenCalledWith({
availableRepos: ['repositories_example'],
}),
);
});
});
@@ -18,10 +18,11 @@ import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import { Select, SelectItem } from '@backstage/core-components';
import { RepoUrlPickerState } from './types';
import { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
import useDebounce from 'react-use/esm/useDebounce';
import { useApi } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
/**
* The underlying component that is rendered in the form for the `BitbucketRepoPicker`
@@ -62,35 +63,21 @@ export const BitbucketRepoPicker = (props: {
}
}, [allowedOwners, host, onChange]);
const [client, setClient] = useState<BitbucketCloudClient>();
const scaffolderApi = useApi(scaffolderApiRef);
const [availableWorkspaces, setAvailableWorkspaces] = useState<string[]>([]);
const [availableProjects, setAvailableProjects] = useState<string[]>([]);
useEffect(() => {
if (host === 'bitbucket.org' && accessToken) {
setClient(
BitbucketCloudClient.fromConfig({
host,
apiBaseUrl: 'https://api.bitbucket.org/2.0',
accessToken,
}),
);
} else {
setClient(undefined);
}
}, [host, accessToken]);
// Update available workspaces when client is available
useDebounce(
() => {
const updateAvailableWorkspaces = async () => {
if (client) {
const result: string[] = [];
for await (const page of client.listWorkspaces().iteratePages()) {
const keys = [...page.values!].map(p => p.slug!);
result.push(...keys);
}
if (host === 'bitbucket.org' && accessToken) {
const result = await scaffolderApi.autocomplete(
accessToken,
'bitbucketCloud',
'workspaces',
);
setAvailableWorkspaces(result);
} else {
@@ -101,22 +88,20 @@ export const BitbucketRepoPicker = (props: {
updateAvailableWorkspaces().catch(() => setAvailableWorkspaces([]));
},
500,
[client],
[host, accessToken],
);
// Update available projects when client is available and workspace changes
useDebounce(
() => {
const updateAvailableProjects = async () => {
if (client && workspace) {
const result: string[] = [];
for await (const page of client
.listProjectsByWorkspace(workspace)
.iteratePages()) {
const keys = [...page.values!].map(p => p.key!);
result.push(...keys);
}
if (host === 'bitbucket.org' && accessToken && workspace) {
const result = await scaffolderApi.autocomplete(
accessToken,
'bitbucketCloud',
'projects',
{ workspace },
);
setAvailableProjects(result);
} else {
@@ -127,24 +112,20 @@ export const BitbucketRepoPicker = (props: {
updateAvailableProjects().catch(() => setAvailableProjects([]));
},
500,
[client, workspace],
[host, accessToken, workspace],
);
// Update available repositories when client is available and workspace or project changes
useDebounce(
() => {
const updateAvailableRepositories = async () => {
if (client && workspace && project) {
const availableRepos: string[] = [];
for await (const page of client
.listRepositoriesByWorkspace(workspace, {
q: `project.key="${project}"`,
})
.iteratePages()) {
const keys = [...page.values!].map(p => p.slug!);
availableRepos.push(...keys);
}
if (host === 'bitbucket.org' && accessToken && workspace && project) {
const availableRepos = await scaffolderApi.autocomplete(
accessToken,
'bitbucketCloud',
'repositories',
{ workspace, project },
);
onChange({ availableRepos });
} else {
@@ -157,7 +138,7 @@ export const BitbucketRepoPicker = (props: {
);
},
500,
[client, workspace, project, onChange],
[host, accessToken, workspace, project],
);
return (
@@ -51,6 +51,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
};
const catalogApiMock: jest.Mocked<CatalogApi> = {
-1
View File
@@ -7269,7 +7269,6 @@ __metadata:
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/integration-react": "workspace:^"
"@backstage/plugin-bitbucket-cloud-common": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@backstage/plugin-catalog-react": "workspace:^"