Merge branch 'master' of github.com:backstage/backstage into holding
This commit is contained in:
@@ -21,6 +21,7 @@ import { ScaffolderPage } from './ScaffolderPage';
|
||||
import { TemplatePage } from './TemplatePage';
|
||||
import { TaskPage } from './TaskPage';
|
||||
import { ActionsPage } from './ActionsPage';
|
||||
import { SecretsContextProvider } from './secrets/SecretsContext';
|
||||
|
||||
import {
|
||||
FieldExtensionOptions,
|
||||
@@ -83,7 +84,11 @@ export const Router = ({
|
||||
/>
|
||||
<Route
|
||||
path="/templates/:templateName"
|
||||
element={<TemplatePage customFieldExtensions={fieldExtensions} />}
|
||||
element={
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage customFieldExtensions={fieldExtensions} />
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
/>
|
||||
<Route path="/tasks/:taskId" element={<TaskPageElement />} />
|
||||
<Route path="/actions" element={<ActionsPage />} />
|
||||
|
||||
@@ -37,6 +37,8 @@ import React, { ComponentType } from 'react';
|
||||
import { registerComponentRouteRef } from '../../routes';
|
||||
import { TemplateList } from '../TemplateList';
|
||||
import { TemplateTypePicker } from '../TemplateTypePicker';
|
||||
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -72,6 +74,8 @@ export const ScaffolderPageContents = ({
|
||||
},
|
||||
};
|
||||
|
||||
const { allowed } = usePermission(catalogEntityCreatePermission);
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
@@ -85,10 +89,12 @@ export const ScaffolderPageContents = ({
|
||||
/>
|
||||
<Content>
|
||||
<ContentHeader title="Available Templates">
|
||||
<CreateButton
|
||||
title="Register Existing Component"
|
||||
to={registerComponentLink && registerComponentLink()}
|
||||
/>
|
||||
{allowed && (
|
||||
<CreateButton
|
||||
title="Register Existing Component"
|
||||
to={registerComponentLink && registerComponentLink()}
|
||||
/>
|
||||
)}
|
||||
<SupportButton>
|
||||
Create new software components using standard templates. Different
|
||||
templates create different kinds of components (services, websites,
|
||||
|
||||
@@ -17,12 +17,13 @@ import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { LinearProgress } from '@material-ui/core';
|
||||
import { FormValidation, IChangeEvent } from '@rjsf/core';
|
||||
import qs from 'qs';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { generatePath, Navigate, useNavigate } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { scaffolderApiRef } from '../../api';
|
||||
import { CustomFieldValidator, FieldExtensionOptions } from '../../extensions';
|
||||
import { SecretsContext } from '../secrets/SecretsContext';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
|
||||
@@ -115,6 +116,7 @@ export const TemplatePage = ({
|
||||
customFieldExtensions?: FieldExtensionOptions[];
|
||||
}) => {
|
||||
const apiHolder = useApiHolder();
|
||||
const secretsContext = useContext(SecretsContext);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const { templateName } = useParams();
|
||||
@@ -135,7 +137,11 @@ export const TemplatePage = ({
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const id = await scaffolderApi.scaffold(templateName, formState);
|
||||
const id = await scaffolderApi.scaffold(
|
||||
templateName,
|
||||
formState,
|
||||
secretsContext?.secrets,
|
||||
);
|
||||
|
||||
const formParams = qs.stringify(
|
||||
{ formData: formState },
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright 2022 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, { useContext } from 'react';
|
||||
import { RepoUrlPicker } from './RepoUrlPicker';
|
||||
import Form from '@rjsf/core';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import {
|
||||
scmIntegrationsApiRef,
|
||||
ScmIntegrationsApi,
|
||||
scmAuthApiRef,
|
||||
ScmAuthApi,
|
||||
} from '@backstage/integration-react';
|
||||
import { scaffolderApiRef, ScaffolderApi } from '../../../api';
|
||||
import {
|
||||
SecretsContextProvider,
|
||||
SecretsContext,
|
||||
} from '../../secrets/SecretsContext';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
|
||||
describe('RepoUrlPicker', () => {
|
||||
const mockScaffolderApi: Partial<ScaffolderApi> = {
|
||||
getIntegrationsList: async () => [
|
||||
{ host: 'github.com', type: 'github', title: 'github.com' },
|
||||
{ host: 'dev.azure.com', type: 'azure', title: 'dev.azure.com' },
|
||||
],
|
||||
};
|
||||
|
||||
const mockIntegrationsApi: Partial<ScmIntegrationsApi> = {
|
||||
byHost: () => ({ type: 'github' }),
|
||||
};
|
||||
|
||||
const mockScmAuthApi: Partial<ScmAuthApi> = {
|
||||
getCredentials: jest.fn().mockResolvedValue({ token: 'abc123' }),
|
||||
};
|
||||
|
||||
describe('happy path rendering', () => {
|
||||
it('should render the repo url picker with minimal props', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
const { getAllByRole, getByRole } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, {}],
|
||||
[scaffolderApiRef, mockScaffolderApi],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{ 'ui:field': 'RepoUrlPicker' }}
|
||||
fields={{ RepoUrlPicker: RepoUrlPicker }}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const [ownerInput, repoInput] = getAllByRole('textbox');
|
||||
const submitButton = getByRole('button');
|
||||
|
||||
fireEvent.change(ownerInput, { target: { value: 'backstage' } });
|
||||
fireEvent.change(repoInput, { target: { value: 'repo123' } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: 'github.com?owner=backstage&repo=repo123',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render properly with allowedHosts', async () => {
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, {}],
|
||||
[scaffolderApiRef, mockScaffolderApi],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoUrlPicker',
|
||||
'ui:options': { allowedHosts: ['dev.azure.com'] },
|
||||
}}
|
||||
fields={{ RepoUrlPicker: RepoUrlPicker }}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByRole('option', { name: 'dev.azure.com' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestUserCredentials', () => {
|
||||
it('should call the scmAuthApi with the correct params', async () => {
|
||||
const SecretsComponent = () => {
|
||||
const value = useContext(SecretsContext);
|
||||
return <div data-testid="current-secrets">{JSON.stringify(value)}</div>;
|
||||
};
|
||||
const { getAllByRole, getByTestId } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, mockScmAuthApi],
|
||||
[scaffolderApiRef, mockScaffolderApi],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoUrlPicker',
|
||||
'ui:options': {
|
||||
requestUserCredentials: {
|
||||
secretsKey: 'testKey',
|
||||
additionalScopes: { github: ['workflow:write'] },
|
||||
},
|
||||
},
|
||||
}}
|
||||
fields={{ RepoUrlPicker: RepoUrlPicker }}
|
||||
/>
|
||||
<SecretsComponent />
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const [ownerInput, repoInput] = getAllByRole('textbox');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(ownerInput, { target: { value: 'backstage' } });
|
||||
fireEvent.change(repoInput, { target: { value: 'repo123' } });
|
||||
|
||||
// need to wait for the debounce to finish
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({
|
||||
url: 'https://github.com/backstage/repo123',
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
customScopes: {
|
||||
github: ['workflow:write'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const currentSecrets = JSON.parse(
|
||||
getByTestId('current-secrets').textContent!,
|
||||
);
|
||||
|
||||
expect(currentSecrets).toEqual({
|
||||
secrets: { testKey: 'abc123' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { scmIntegrationsApiRef } from '@backstage/integration-react';
|
||||
import {
|
||||
scmIntegrationsApiRef,
|
||||
scmAuthApiRef,
|
||||
} from '@backstage/integration-react';
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { GithubRepoPicker } from './GithubRepoPicker';
|
||||
import { GitlabRepoPicker } from './GitlabRepoPicker';
|
||||
@@ -24,10 +27,21 @@ import { FieldExtensionComponentProps } from '../../../extensions';
|
||||
import { RepoUrlPickerHost } from './RepoUrlPickerHost';
|
||||
import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils';
|
||||
import { RepoUrlPickerState } from './types';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import { useTemplateSecrets } from '../../secrets';
|
||||
|
||||
export interface RepoUrlPickerUiOptions {
|
||||
allowedHosts?: string[];
|
||||
allowedOwners?: string[];
|
||||
requestUserCredentials?: {
|
||||
secretsKey: string;
|
||||
additionalScopes?: {
|
||||
github?: string[];
|
||||
gitlab?: string[];
|
||||
bitbucket?: string[];
|
||||
azure?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const RepoUrlPicker = (
|
||||
@@ -38,7 +52,8 @@ export const RepoUrlPicker = (
|
||||
parseRepoPickerUrl(formData),
|
||||
);
|
||||
const integrationApi = useApi(scmIntegrationsApiRef);
|
||||
|
||||
const scmAuthApi = useApi(scmAuthApiRef);
|
||||
const { setSecret } = useTemplateSecrets();
|
||||
const allowedHosts = useMemo(
|
||||
() => uiSchema?.['ui:options']?.allowedHosts ?? [],
|
||||
[uiSchema],
|
||||
@@ -66,6 +81,42 @@ export const RepoUrlPicker = (
|
||||
[setState],
|
||||
);
|
||||
|
||||
useDebounce(
|
||||
async () => {
|
||||
const { requestUserCredentials } = uiSchema?.['ui:options'] ?? {};
|
||||
|
||||
if (
|
||||
!requestUserCredentials ||
|
||||
!(state.host && state.owner && state.repoName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [host, owner, repoName] = [
|
||||
state.host,
|
||||
state.owner,
|
||||
state.repoName,
|
||||
].map(encodeURIComponent);
|
||||
|
||||
// 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}/${owner}/${repoName}`,
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
customScopes: requestUserCredentials.additionalScopes,
|
||||
},
|
||||
});
|
||||
|
||||
// set the secret using the key provided in the the ui:options for use
|
||||
// in the templating the manifest with ${{ secrets[secretsKey] }}
|
||||
setSecret({ [requestUserCredentials.secretsKey]: token });
|
||||
},
|
||||
500,
|
||||
[state, uiSchema],
|
||||
);
|
||||
|
||||
const hostType =
|
||||
(state.host && integrationApi.byHost(state.host)?.type) ?? null;
|
||||
|
||||
|
||||
@@ -37,16 +37,23 @@ export const RepoUrlPickerHost = (props: {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hosts && !host) {
|
||||
// This is only hear to set the default as the first one in the hosts array
|
||||
// if the host is not set yet and there is a list of hosts.
|
||||
onChange(hosts[0]);
|
||||
// If there is no host chosen currently
|
||||
if (!host) {
|
||||
// Set the first of the allowedHosts option if that available
|
||||
if (hosts?.length) {
|
||||
onChange(hosts[0]);
|
||||
// if there's no hosts provided, fallback to using the first integration
|
||||
} else if (integrations?.length) {
|
||||
onChange(integrations[0].host);
|
||||
}
|
||||
}
|
||||
}, [hosts, host, onChange]);
|
||||
}, [hosts, host, onChange, integrations]);
|
||||
|
||||
// If there are no allowedHosts provided, then show all integrations. Otherwise, only show integrations
|
||||
// that are provided in the dropdown for the user to choose from.
|
||||
const hostsOptions: SelectItem[] = integrations
|
||||
? integrations
|
||||
.filter(i => hosts?.includes(i.host))
|
||||
.filter(i => (hosts?.length ? hosts?.includes(i.host) : true))
|
||||
.map(i => ({ label: i.title, value: i.host }))
|
||||
: [{ label: 'Loading...', value: 'loading' }];
|
||||
|
||||
|
||||
@@ -44,22 +44,22 @@ export function serializeRepoPickerUrl(data: RepoUrlPickerState) {
|
||||
export function parseRepoPickerUrl(
|
||||
url: string | undefined,
|
||||
): RepoUrlPickerState {
|
||||
let host = undefined;
|
||||
let owner = undefined;
|
||||
let repoName = undefined;
|
||||
let organization = undefined;
|
||||
let workspace = undefined;
|
||||
let project = undefined;
|
||||
let host = '';
|
||||
let owner = '';
|
||||
let repoName = '';
|
||||
let organization = '';
|
||||
let workspace = '';
|
||||
let project = '';
|
||||
|
||||
try {
|
||||
if (url) {
|
||||
const parsed = new URL(`https://${url}`);
|
||||
host = parsed.host;
|
||||
owner = parsed.searchParams.get('owner') || undefined;
|
||||
repoName = parsed.searchParams.get('repo') || undefined;
|
||||
organization = parsed.searchParams.get('organization') || undefined;
|
||||
workspace = parsed.searchParams.get('workspace') || undefined;
|
||||
project = parsed.searchParams.get('project') || undefined;
|
||||
owner = parsed.searchParams.get('owner') || '';
|
||||
repoName = parsed.searchParams.get('repo') || '';
|
||||
organization = parsed.searchParams.get('organization') || '';
|
||||
workspace = parsed.searchParams.get('workspace') || '';
|
||||
project = parsed.searchParams.get('project') || '';
|
||||
}
|
||||
} catch {
|
||||
/* ok */
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2022 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, { useContext } from 'react';
|
||||
import {
|
||||
useTemplateSecrets,
|
||||
SecretsContextProvider,
|
||||
SecretsContext,
|
||||
} from './SecretsContext';
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
|
||||
describe('SecretsContext', () => {
|
||||
it('should allow the setting of secrets in the context', async () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
hook: useTemplateSecrets(),
|
||||
context: useContext(SecretsContext),
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<SecretsContextProvider>{children}</SecretsContextProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
expect(result.current.context?.secrets.foo).toEqual(undefined);
|
||||
|
||||
act(() => result.current.hook.setSecret({ foo: 'bar' }));
|
||||
|
||||
expect(result.current.context?.secrets.foo).toEqual('bar');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2022 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, {
|
||||
useState,
|
||||
useCallback,
|
||||
useContext,
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
|
||||
type SecretsContextContents = {
|
||||
secrets: Record<string, string>;
|
||||
setSecrets: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The actual context object.
|
||||
*/
|
||||
export const SecretsContext = createContext<SecretsContextContents | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
* The Context Provider that holds the state for the secrets.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const [secrets, setSecrets] = useState<Record<string, string>>({});
|
||||
|
||||
return (
|
||||
<SecretsContext.Provider value={{ secrets, setSecrets }}>
|
||||
{children}
|
||||
</SecretsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access the secrets context.
|
||||
* @public
|
||||
*/
|
||||
export const useTemplateSecrets = () => {
|
||||
const value = useContext(SecretsContext);
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
'useTemplateSecrets must be used within a SecretsContextProvider',
|
||||
);
|
||||
}
|
||||
|
||||
const { setSecrets } = value;
|
||||
|
||||
const setSecret = useCallback(
|
||||
(input: Record<string, string>) => {
|
||||
setSecrets(currentSecrets => ({ ...currentSecrets, ...input }));
|
||||
},
|
||||
[setSecrets],
|
||||
);
|
||||
|
||||
return { setSecret };
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2022 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 { useTemplateSecrets } from './SecretsContext';
|
||||
@@ -56,5 +56,6 @@ export { FavouriteTemplate } from './components/FavouriteTemplate';
|
||||
export { TemplateList } from './components/TemplateList';
|
||||
export type { TemplateListProps } from './components/TemplateList';
|
||||
export { TemplateTypePicker } from './components/TemplateTypePicker';
|
||||
export * from './components/secrets';
|
||||
export { TaskPage } from './components/TaskPage';
|
||||
export type { TaskPageProps } from './components/TaskPage';
|
||||
|
||||
Reference in New Issue
Block a user