refactor: add generic RepoBranchPicker

Signed-off-by: Benjamin Janssens <benji.janssens@gmail.com>
This commit is contained in:
Benjamin Janssens
2024-06-18 11:42:20 +02:00
parent f6fea34173
commit 741837a560
8 changed files with 194 additions and 105 deletions
@@ -14,128 +14,71 @@
* limitations under the License.
*/
import {
scaffolderApiRef,
useTemplateSecrets,
} from '@backstage/plugin-scaffolder-react';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import FormControl from '@material-ui/core/FormControl';
import React, { useEffect, useMemo, useState } from 'react';
import React 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 { scmAuthApiRef } from '@backstage/integration-react';
import { BitbucketRepoBranchPickerProps } from './schema';
import { RepoBranchPickerState } from './types';
export const BitbucketRepoBranchPicker = ({
onChange,
state,
rawErrors,
required,
formData,
formContext: {
formData: { repoUrl },
},
uiSchema,
}: BitbucketRepoBranchPickerProps) => {
const { secrets, setSecrets } = useTemplateSecrets();
const accessToken = useMemo(
() =>
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey],
[secrets, uiSchema],
);
const [host, setHost] = useState<string>();
const [workspace, setWorkspace] = useState<string | null>(null);
const [repository, setRepository] = useState<string | null>(null);
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
const scmAuthApi = useApi(scmAuthApiRef);
accessToken,
}: {
onChange: (state: RepoBranchPickerState) => void;
state: RepoBranchPickerState;
rawErrors: string[];
accessToken?: string;
}) => {
const scaffolderApi = useApi(scaffolderApiRef);
useEffect(() => {
if (repoUrl) {
const url = new URL(`https://${repoUrl}`);
setHost(url.host);
setWorkspace(url.searchParams.get('workspace'));
setRepository(url.searchParams.get('repo'));
}
}, [repoUrl]);
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],
);
useDebounce(
() => {
const updateAvailableBranches = async () => {
if (
host === 'bitbucket.org' &&
state.host === 'bitbucket.org' &&
accessToken &&
workspace &&
repository
state.workspace &&
state.repository
) {
const result = await scaffolderApi.autocomplete(
accessToken,
'bitbucketCloud',
'branches',
{ workspace, repository },
{ workspace: state.workspace, repository: state.repository },
);
setAvailableBranches(result);
onChange({ availableBranches: result });
} else {
setAvailableBranches([]);
onChange({ availableBranches: [] });
}
};
updateAvailableBranches().catch(() => setAvailableBranches([]));
updateAvailableBranches().catch(() =>
onChange({ availableBranches: [] }),
);
},
500,
[host, accessToken, workspace, repository],
[state, accessToken],
);
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !formData}
required
error={rawErrors?.length > 0 && !state.branch}
>
<Autocomplete
onChange={(_, newValue) => {
onChange(newValue || '');
onChange({ branch: newValue || undefined });
}}
options={availableBranches}
options={state.availableBranches || []}
renderInput={params => (
<TextField {...params} label="Branch" required={required} />
<TextField {...params} label="Branch" required />
)}
freeSolo
autoSelect
@@ -0,0 +1,125 @@
/*
* 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 { RepoBranchPickerProps } from './schema';
import { RepoBranchPickerState } 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 { BitbucketRepoBranchPicker } from './BitbucketRepoBranchPicker';
/**
* The underlying component that is rendered in the form for the `RepoBranchPicker`
* field extension.
*
* @public
*/
export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
const { uiSchema, onChange, rawErrors, schema, formContext } = props;
const [state, setState] = useState<RepoBranchPickerState>({});
const integrationApi = useApi(scmIntegrationsApiRef);
const scmAuthApi = useApi(scmAuthApiRef);
const { secrets, setSecrets } = useTemplateSecrets();
useEffect(() => {
if (formContext.formData.repoUrl) {
const url = new URL(`https://${formContext.formData.repoUrl}`);
setState({
host: url.host,
workspace: url.searchParams.get('workspace') || undefined,
repository: url.searchParams.get('repo') || undefined,
});
}
}, [formContext]);
useEffect(() => {
onChange(state.branch);
}, [state, onChange]);
const updateLocalState = useCallback(
(newState: RepoBranchPickerState) => {
setState(prevState => ({ ...prevState, ...newState }));
},
[setState],
);
useDebounce(
async () => {
const { requestUserCredentials } = uiSchema?.['ui:options'] ?? {};
if (!requestUserCredentials || !state.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://${state.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,
[state, uiSchema],
);
const hostType =
(state.host && integrationApi.byHost(state.host)?.type) ?? null;
return (
<>
{schema.title && (
<Box my={1}>
<Typography variant="h5">{schema.title}</Typography>
<Divider />
</Box>
)}
{schema.description && (
<Typography variant="body1">{schema.description}</Typography>
)}
{hostType === 'bitbucket' && (
<BitbucketRepoBranchPicker
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
/>
)}
</>
);
};
@@ -15,6 +15,6 @@
*/
export {
BitbucketRepoBranchPickerFieldSchema,
type BitbucketRepoBranchPickerUiOptions,
RepoBranchPickerFieldSchema,
type RepoBranchPickerUiOptions,
} from './schema';
@@ -19,7 +19,7 @@ import { makeFieldSchemaFromZod } from '../utils';
/**
* @public
*/
export const BitbucketRepoBranchPickerFieldSchema = makeFieldSchemaFromZod(
export const RepoBranchPickerFieldSchema = makeFieldSchemaFromZod(
z.string(),
z.object({
requestUserCredentials: z
@@ -68,15 +68,13 @@ export const BitbucketRepoBranchPickerFieldSchema = makeFieldSchemaFromZod(
/**
* The input props that can be specified under `ui:options` for the
* `BitbucketRepoBranchPicker` field extension.
* `RepoBranchPicker` field extension.
*
* @public
*/
export type BitbucketRepoBranchPickerUiOptions =
typeof BitbucketRepoBranchPickerFieldSchema.uiOptionsType;
export type RepoBranchPickerUiOptions =
typeof RepoBranchPickerFieldSchema.uiOptionsType;
export type BitbucketRepoBranchPickerProps =
typeof BitbucketRepoBranchPickerFieldSchema.type;
export type RepoBranchPickerProps = typeof RepoBranchPickerFieldSchema.type;
export const BitbucketRepoBranchPickerSchema =
BitbucketRepoBranchPickerFieldSchema.schema;
export const RepoBranchPickerSchema = RepoBranchPickerFieldSchema.schema;
@@ -0,0 +1,23 @@
/*
* 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;
availableBranches?: string[];
}
+5 -5
View File
@@ -50,8 +50,8 @@ import {
MultiEntityPickerSchema,
validateMultiEntityPickerValidation,
} from '../components/fields/MultiEntityPicker/MultiEntityPicker';
import { BitbucketRepoBranchPicker } from '../components/fields/BitbucketRepoBranchPicker/BitbucketRepoBranchPicker';
import { BitbucketRepoBranchPickerSchema } from '../components/fields/BitbucketRepoBranchPicker/schema';
import { RepoBranchPicker } from '../components/fields/BitbucketRepoBranchPicker/RepoBranchPicker';
import { RepoBranchPickerSchema } from '../components/fields/BitbucketRepoBranchPicker/schema';
export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
{
@@ -102,8 +102,8 @@ export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
validation: validateMultiEntityPickerValidation,
},
{
component: BitbucketRepoBranchPicker,
name: 'BitbucketRepoBranchPicker',
schema: BitbucketRepoBranchPickerSchema,
component: RepoBranchPicker,
name: 'RepoBranchPicker',
schema: RepoBranchPickerSchema,
},
];
+1 -1
View File
@@ -30,7 +30,7 @@ export {
MyGroupsPickerFieldExtension,
RepoUrlPickerFieldExtension,
MultiEntityPickerFieldExtension,
BitbucketRepoBranchPickerFieldExtension,
RepoBranchPickerFieldExtension,
ScaffolderPage,
scaffolderPlugin,
} from './plugin';
+6 -6
View File
@@ -73,8 +73,8 @@ import {
MyGroupsPicker,
MyGroupsPickerSchema,
} from './components/fields/MyGroupsPicker/MyGroupsPicker';
import { BitbucketRepoBranchPicker } from './components/fields/BitbucketRepoBranchPicker/BitbucketRepoBranchPicker';
import { BitbucketRepoBranchPickerSchema } from './components/fields/BitbucketRepoBranchPicker/schema';
import { RepoBranchPicker } from './components/fields/BitbucketRepoBranchPicker/RepoBranchPicker';
import { RepoBranchPickerSchema } from './components/fields/BitbucketRepoBranchPicker/schema';
/**
* The main plugin export for the scaffolder.
@@ -239,10 +239,10 @@ export const EntityTagsPickerFieldExtension = scaffolderPlugin.provide(
*
* @public
*/
export const BitbucketRepoBranchPickerFieldExtension = scaffolderPlugin.provide(
export const RepoBranchPickerFieldExtension = scaffolderPlugin.provide(
createScaffolderFieldExtension({
component: BitbucketRepoBranchPicker,
name: 'BitbucketRepoBranchPicker',
schema: BitbucketRepoBranchPickerSchema,
component: RepoBranchPicker,
name: 'RepoBranchPicker',
schema: RepoBranchPickerSchema,
}),
);