Merge pull request #25460 from benjidotsh/feat/scaffolder-bitbucket-autocomplete-branch
feat(scaffolder): add RepoBranchPicker field extension
This commit is contained in:
@@ -384,6 +384,28 @@ export const OwnerPickerFieldSchema: FieldSchema<
|
||||
// @public
|
||||
export type OwnerPickerUiOptions = typeof OwnerPickerFieldSchema.uiOptionsType;
|
||||
|
||||
// @public
|
||||
export const RepoBranchPickerFieldExtension: FieldExtensionComponent_2<
|
||||
string,
|
||||
{
|
||||
requestUserCredentials?:
|
||||
| {
|
||||
secretsKey: string;
|
||||
additionalScopes?:
|
||||
| {
|
||||
azure?: string[] | undefined;
|
||||
github?: string[] | undefined;
|
||||
gitlab?: string[] | undefined;
|
||||
bitbucket?: string[] | undefined;
|
||||
gerrit?: string[] | undefined;
|
||||
gitea?: string[] | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
>;
|
||||
|
||||
// @public
|
||||
export const repoPickerValidation: (
|
||||
value: string,
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import {
|
||||
ScaffolderApi,
|
||||
scaffolderApiRef,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { BitbucketRepoBranchPicker } from './BitbucketRepoBranchPicker';
|
||||
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('BitbucketRepoBranchPicker', () => {
|
||||
const scaffolderApiMock: Partial<ScaffolderApi> = {
|
||||
autocomplete: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ results: [{ title: 'branch1' }] }),
|
||||
};
|
||||
|
||||
it('renders an input field', () => {
|
||||
const { getByRole } = render(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
|
||||
<BitbucketRepoBranchPicker
|
||||
onChange={jest.fn()}
|
||||
state={{ branch: 'main' }}
|
||||
rawErrors={[]}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(getByRole('textbox')).toBeInTheDocument();
|
||||
expect(getByRole('textbox')).toHaveValue('main');
|
||||
});
|
||||
|
||||
it('calls onChange when the input field changes', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { getByRole } = render(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
|
||||
<BitbucketRepoBranchPicker
|
||||
onChange={onChange}
|
||||
state={{ branch: 'main' }}
|
||||
rawErrors={[]}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const input = getByRole('textbox');
|
||||
|
||||
act(() => {
|
||||
input.focus();
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'develop' },
|
||||
});
|
||||
input.blur();
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ branch: 'develop' });
|
||||
});
|
||||
|
||||
it('should populate branches', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { getByRole, getByText } = render(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
|
||||
<BitbucketRepoBranchPicker
|
||||
onChange={onChange}
|
||||
state={{
|
||||
branch: 'main',
|
||||
host: 'bitbucket.org',
|
||||
workspace: 'foo',
|
||||
repository: 'bar',
|
||||
}}
|
||||
rawErrors={[]}
|
||||
accessToken="token"
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
// Open the Autcomplete dropdown
|
||||
const input = getByRole('textbox');
|
||||
await userEvent.click(input);
|
||||
|
||||
// Verify that the available workspaces are shown
|
||||
await waitFor(() => expect(getByText('branch1')).toBeInTheDocument());
|
||||
|
||||
// Verify that selecting an option calls onChange
|
||||
await userEvent.click(getByText('branch1'));
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
branch: 'branch1',
|
||||
});
|
||||
});
|
||||
});
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Autocomplete from '@material-ui/lab/Autocomplete';
|
||||
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`
|
||||
* field extension.
|
||||
*
|
||||
* @public
|
||||
*
|
||||
*/
|
||||
export const BitbucketRepoBranchPicker = ({
|
||||
onChange,
|
||||
state,
|
||||
rawErrors,
|
||||
accessToken,
|
||||
required,
|
||||
}: BaseRepoBranchPickerProps<{
|
||||
accessToken?: string;
|
||||
}>) => {
|
||||
const { host, workspace, repository, branch } = state;
|
||||
|
||||
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
|
||||
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
|
||||
const updateAvailableBranches = useCallback(() => {
|
||||
if (
|
||||
!scaffolderApi.autocomplete ||
|
||||
!workspace ||
|
||||
!repository ||
|
||||
!accessToken ||
|
||||
host !== 'bitbucket.org'
|
||||
) {
|
||||
setAvailableBranches([]);
|
||||
return;
|
||||
}
|
||||
|
||||
scaffolderApi
|
||||
.autocomplete({
|
||||
token: accessToken,
|
||||
resource: 'branches',
|
||||
context: { workspace, repository },
|
||||
provider: 'bitbucket-cloud',
|
||||
})
|
||||
.then(({ results }) => {
|
||||
setAvailableBranches(results.map(r => r.title));
|
||||
})
|
||||
.catch(() => {
|
||||
setAvailableBranches([]);
|
||||
});
|
||||
}, [host, workspace, repository, accessToken, scaffolderApi]);
|
||||
|
||||
useDebounce(updateAvailableBranches, 500, [updateAvailableBranches]);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
margin="normal"
|
||||
required={required}
|
||||
error={rawErrors?.length > 0 && !branch}
|
||||
>
|
||||
<Autocomplete
|
||||
value={branch}
|
||||
onChange={(_, newValue) => {
|
||||
onChange({ branch: newValue || '' });
|
||||
}}
|
||||
options={availableBranches}
|
||||
renderInput={params => (
|
||||
<TextField {...params} label="Branch" required={required} />
|
||||
)}
|
||||
freeSolo
|
||||
autoSelect
|
||||
/>
|
||||
<FormHelperText>The branch of the repository</FormHelperText>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
|
||||
import { DefaultRepoBranchPicker } from './DefaultRepoBranchPicker';
|
||||
|
||||
describe('DefaultRepoBranchPicker', () => {
|
||||
it('renders an input field', () => {
|
||||
const { getByRole } = render(
|
||||
<DefaultRepoBranchPicker
|
||||
onChange={jest.fn()}
|
||||
state={{ branch: 'main' }}
|
||||
rawErrors={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByRole('textbox')).toBeInTheDocument();
|
||||
expect(getByRole('textbox')).toHaveValue('main');
|
||||
});
|
||||
|
||||
it('calls onChange when the input field changes', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { getByRole } = render(
|
||||
<DefaultRepoBranchPicker
|
||||
onChange={onChange}
|
||||
state={{ branch: 'main' }}
|
||||
rawErrors={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByRole('textbox');
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'develop' },
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ branch: 'develop' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 FormControl from '@material-ui/core/FormControl';
|
||||
import React from 'react';
|
||||
import FormHelperText from '@material-ui/core/FormHelperText';
|
||||
import Input from '@material-ui/core/Input';
|
||||
import InputLabel from '@material-ui/core/InputLabel';
|
||||
|
||||
import { BaseRepoBranchPickerProps } from './types';
|
||||
|
||||
/**
|
||||
* The underlying component that is rendered in the form for the `DefaultRepoBranchPicker`
|
||||
* field extension.
|
||||
*
|
||||
* @public
|
||||
*
|
||||
*/
|
||||
export const DefaultRepoBranchPicker = ({
|
||||
onChange,
|
||||
state,
|
||||
rawErrors,
|
||||
required,
|
||||
}: BaseRepoBranchPickerProps) => {
|
||||
const { branch } = state;
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
margin="normal"
|
||||
required={required}
|
||||
error={rawErrors?.length > 0 && !branch}
|
||||
>
|
||||
<InputLabel htmlFor="branchInput">Branch</InputLabel>
|
||||
<Input
|
||||
id="branchInput"
|
||||
onChange={e => onChange({ branch: e.target.value })}
|
||||
value={branch}
|
||||
/>
|
||||
<FormHelperText>The branch of the repository</FormHelperText>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { Form } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import {
|
||||
scmIntegrationsApiRef,
|
||||
ScmIntegrationsApi,
|
||||
scmAuthApiRef,
|
||||
ScmAuthApi,
|
||||
} from '@backstage/integration-react';
|
||||
|
||||
import {
|
||||
SecretsContextProvider,
|
||||
scaffolderApiRef,
|
||||
useTemplateSecrets,
|
||||
ScaffolderRJSFField,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { RepoBranchPicker } from './RepoBranchPicker';
|
||||
|
||||
describe('RepoBranchPicker', () => {
|
||||
const mockIntegrationsApi: Partial<ScmIntegrationsApi> = {
|
||||
byHost: () => ({ type: 'bitbucket' }),
|
||||
};
|
||||
|
||||
let mockScmAuthApi: Partial<ScmAuthApi>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockScmAuthApi = {
|
||||
getCredentials: jest.fn().mockResolvedValue({ token: 'abc123' }),
|
||||
};
|
||||
});
|
||||
|
||||
describe('happy path rendering', () => {
|
||||
it('should render the repo branch picker with minimal props', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, {}],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{ 'ui:field': 'RepoBranchPicker' }}
|
||||
fields={{
|
||||
RepoBranchPicker:
|
||||
RepoBranchPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
formContext={{
|
||||
formData: {},
|
||||
}}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const input = getByRole('textbox');
|
||||
const submitButton = getByRole('button');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'branch1' } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: 'branch1',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render properly with title and description', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, {}],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{
|
||||
type: 'string',
|
||||
title: 'test title',
|
||||
description: 'test description',
|
||||
}}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoBranchPicker',
|
||||
}}
|
||||
fields={{
|
||||
RepoBranchPicker:
|
||||
RepoBranchPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
formContext={{
|
||||
formData: {
|
||||
repoUrl: 'bitbucket.org',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(getByText('test title')).toBeInTheDocument();
|
||||
expect(getByText('test description')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestUserCredentials', () => {
|
||||
it('should call the scmAuthApi with the correct params', async () => {
|
||||
const secretsKey = 'testKey';
|
||||
|
||||
const SecretsComponent = () => {
|
||||
const { secrets } = useTemplateSecrets();
|
||||
const secret = secrets[secretsKey];
|
||||
return secret ? <div>{secret}</div> : null;
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, mockScmAuthApi],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoBranchPicker',
|
||||
'ui:options': {
|
||||
requestUserCredentials: {
|
||||
secretsKey,
|
||||
additionalScopes: { github: ['workflow'] },
|
||||
},
|
||||
},
|
||||
}}
|
||||
fields={{
|
||||
RepoBranchPicker:
|
||||
RepoBranchPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
formContext={{
|
||||
formData: {
|
||||
repoUrl: 'github.com',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<SecretsComponent />
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
// need to wait for the debounce to finish
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({
|
||||
url: 'https://github.com',
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
customScopes: {
|
||||
github: ['workflow'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('abc123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call the scmAuthApi with the correct params if workspace is nested', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, mockScmAuthApi],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoBranchPicker',
|
||||
'ui:options': {
|
||||
allowedHosts: ['gitlab.example.com'],
|
||||
requestUserCredentials: {
|
||||
secretsKey: 'testKey',
|
||||
},
|
||||
},
|
||||
}}
|
||||
fields={{
|
||||
RepoBranchPicker:
|
||||
RepoBranchPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
formContext={{
|
||||
formData: {
|
||||
repoUrl: 'gitlab.example.com',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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.example.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 ? <div>{secret}</div> : null;
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, mockScmAuthApi],
|
||||
[scaffolderApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider initialSecrets={{ [secretsKey]: 'abc123' }}>
|
||||
<Form
|
||||
validator={validator}
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoBranchPicker',
|
||||
'ui:options': {
|
||||
requestUserCredentials: {
|
||||
secretsKey,
|
||||
additionalScopes: { github: ['workflow'] },
|
||||
},
|
||||
},
|
||||
}}
|
||||
fields={{
|
||||
RepoBranchPicker:
|
||||
RepoBranchPicker as ScaffolderRJSFField<string>,
|
||||
}}
|
||||
formContext={{
|
||||
formData: {
|
||||
repoUrl: 'github.com',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<SecretsComponent />
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
scmIntegrationsApiRef,
|
||||
scmAuthApiRef,
|
||||
} from '@backstage/integration-react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
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 { RepoBranchPickerProps } from './schema';
|
||||
import { RepoBranchPickerState } from './types';
|
||||
import { BitbucketRepoBranchPicker } from './BitbucketRepoBranchPicker';
|
||||
import { DefaultRepoBranchPicker } from './DefaultRepoBranchPicker';
|
||||
|
||||
/**
|
||||
* The underlying component that is rendered in the form for the `RepoBranchPicker`
|
||||
* field extension.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
|
||||
const {
|
||||
uiSchema,
|
||||
onChange,
|
||||
rawErrors,
|
||||
formData,
|
||||
schema,
|
||||
formContext,
|
||||
required,
|
||||
} = props;
|
||||
const {
|
||||
formData: { repoUrl },
|
||||
} = formContext;
|
||||
|
||||
const [state, setState] = useState<RepoBranchPickerState>({
|
||||
branch: formData || '',
|
||||
});
|
||||
const { host, branch } = state;
|
||||
|
||||
const integrationApi = useApi(scmIntegrationsApiRef);
|
||||
const scmAuthApi = useApi(scmAuthApiRef);
|
||||
|
||||
const { secrets, setSecrets } = useTemplateSecrets();
|
||||
|
||||
useDebounce(
|
||||
async () => {
|
||||
const { requestUserCredentials } = uiSchema?.['ui:options'] ?? {};
|
||||
|
||||
if (!requestUserCredentials || !host) {
|
||||
return;
|
||||
}
|
||||
|
||||
// don't show login prompt if secret value is already in state
|
||||
if (secrets[requestUserCredentials.secretsKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// user has requested that we use the users credentials
|
||||
// so lets grab them using the scmAuthApi and pass through
|
||||
// any additional scopes from the ui:options
|
||||
const { token } = await scmAuthApi.getCredentials({
|
||||
url: `https://${host}`,
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
customScopes: requestUserCredentials.additionalScopes,
|
||||
},
|
||||
});
|
||||
|
||||
// set the secret using the key provided in the ui:options for use
|
||||
// in the templating the manifest with ${{ secrets[secretsKey] }}
|
||||
setSecrets({ [requestUserCredentials.secretsKey]: token });
|
||||
},
|
||||
500,
|
||||
[host, uiSchema],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (repoUrl) {
|
||||
const url = new URL(`https://${repoUrl}`);
|
||||
|
||||
setState(prevState => ({
|
||||
...prevState,
|
||||
host: url.host,
|
||||
workspace: url.searchParams.get('workspace') || '',
|
||||
repository: url.searchParams.get('repo') || '',
|
||||
}));
|
||||
}
|
||||
}, [repoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(branch);
|
||||
}, [branch, onChange]);
|
||||
|
||||
const updateLocalState = useCallback(
|
||||
(newState: RepoBranchPickerState) => {
|
||||
setState(prevState => ({ ...prevState, ...newState }));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const hostType = (host && integrationApi.byHost(host)?.type) ?? null;
|
||||
|
||||
const renderRepoBranchPicker = () => {
|
||||
switch (hostType) {
|
||||
case 'bitbucket':
|
||||
return (
|
||||
<BitbucketRepoBranchPicker
|
||||
onChange={updateLocalState}
|
||||
state={state}
|
||||
rawErrors={rawErrors}
|
||||
accessToken={
|
||||
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
|
||||
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
|
||||
}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<DefaultRepoBranchPicker
|
||||
onChange={updateLocalState}
|
||||
state={state}
|
||||
rawErrors={rawErrors}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{schema.title && (
|
||||
<Box my={1}>
|
||||
<Typography variant="h5">{schema.title}</Typography>
|
||||
<Divider />
|
||||
</Box>
|
||||
)}
|
||||
{schema.description && (
|
||||
<Typography variant="body1">{schema.description}</Typography>
|
||||
)}
|
||||
{renderRepoBranchPicker()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
RepoBranchPickerFieldSchema,
|
||||
type RepoBranchPickerUiOptions,
|
||||
} from './schema';
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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 { z } from 'zod';
|
||||
import { makeFieldSchemaFromZod } from '../utils';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const RepoBranchPickerFieldSchema = makeFieldSchemaFromZod(
|
||||
z.string(),
|
||||
z.object({
|
||||
requestUserCredentials: z
|
||||
.object({
|
||||
secretsKey: z
|
||||
.string()
|
||||
.describe(
|
||||
'Key used within the template secrets context to store the credential',
|
||||
),
|
||||
additionalScopes: z
|
||||
.object({
|
||||
gitea: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional Gitea scopes to request'),
|
||||
gerrit: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional Gerrit scopes to request'),
|
||||
github: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional GitHub scopes to request'),
|
||||
gitlab: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional GitLab scopes to request'),
|
||||
bitbucket: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional BitBucket scopes to request'),
|
||||
azure: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional Azure scopes to request'),
|
||||
})
|
||||
.optional()
|
||||
.describe('Additional permission scopes to request'),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
'If defined will request user credentials to auth against the given SCM platform',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* The input props that can be specified under `ui:options` for the
|
||||
* `RepoBranchPicker` field extension.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type RepoBranchPickerUiOptions =
|
||||
typeof RepoBranchPickerFieldSchema.uiOptionsType;
|
||||
|
||||
export type RepoBranchPickerProps = typeof RepoBranchPickerFieldSchema.type;
|
||||
|
||||
// This has been duplicated from /plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts
|
||||
// NOTE: There is a bug with this failing validation in the custom field explorer due
|
||||
// to https://github.com/rjsf-team/react-jsonschema-form/issues/675 even if
|
||||
// requestUserCredentials is not defined
|
||||
export const RepoBranchPickerSchema = RepoBranchPickerFieldSchema.schema;
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface RepoBranchPickerState {
|
||||
host?: string;
|
||||
workspace?: string;
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export type BaseRepoBranchPickerProps<T extends {} = {}> = T & {
|
||||
onChange: (state: RepoBranchPickerState) => void;
|
||||
state: RepoBranchPickerState;
|
||||
rawErrors: string[];
|
||||
required?: boolean;
|
||||
};
|
||||
@@ -97,6 +97,7 @@ export type RepoUrlPickerUiOptions =
|
||||
|
||||
export type RepoUrlPickerProps = typeof RepoUrlPickerFieldSchema.type;
|
||||
|
||||
// This has been duplicated to /plugins/scaffolder/src/components/fields/RepoBranchPicker/schema.ts
|
||||
// NOTE: There is a bug with this failing validation in the custom field explorer due
|
||||
// to https://github.com/rjsf-team/react-jsonschema-form/issues/675 even if
|
||||
// requestUserCredentials is not defined
|
||||
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
MultiEntityPickerSchema,
|
||||
validateMultiEntityPickerValidation,
|
||||
} from '../components/fields/MultiEntityPicker/MultiEntityPicker';
|
||||
import { RepoBranchPicker } from '../components/fields/RepoBranchPicker/RepoBranchPicker';
|
||||
import { RepoBranchPickerSchema } from '../components/fields/RepoBranchPicker/schema';
|
||||
|
||||
export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
|
||||
{
|
||||
@@ -99,4 +101,9 @@ export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
|
||||
schema: MultiEntityPickerSchema,
|
||||
validation: validateMultiEntityPickerValidation,
|
||||
},
|
||||
{
|
||||
component: RepoBranchPicker,
|
||||
name: 'RepoBranchPicker',
|
||||
schema: RepoBranchPickerSchema,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
MyGroupsPickerFieldExtension,
|
||||
RepoUrlPickerFieldExtension,
|
||||
MultiEntityPickerFieldExtension,
|
||||
RepoBranchPickerFieldExtension,
|
||||
ScaffolderPage,
|
||||
scaffolderPlugin,
|
||||
} from './plugin';
|
||||
|
||||
@@ -73,6 +73,8 @@ import {
|
||||
MyGroupsPicker,
|
||||
MyGroupsPickerSchema,
|
||||
} from './components/fields/MyGroupsPicker/MyGroupsPicker';
|
||||
import { RepoBranchPicker } from './components/fields/RepoBranchPicker/RepoBranchPicker';
|
||||
import { RepoBranchPickerSchema } from './components/fields/RepoBranchPicker/schema';
|
||||
|
||||
/**
|
||||
* The main plugin export for the scaffolder.
|
||||
@@ -231,3 +233,16 @@ export const EntityTagsPickerFieldExtension = scaffolderPlugin.provide(
|
||||
schema: EntityTagsPickerSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* A field extension to select a branch from a repository.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const RepoBranchPickerFieldExtension = scaffolderPlugin.provide(
|
||||
createScaffolderFieldExtension({
|
||||
component: RepoBranchPicker,
|
||||
name: 'RepoBranchPicker',
|
||||
schema: RepoBranchPickerSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user