Merge branch 'master' of github.com:backstage/backstage into holding

This commit is contained in:
Brian Fletcher
2022-02-03 14:04:20 +00:00
388 changed files with 6957 additions and 2108 deletions
+6 -1
View File
@@ -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';
+1
View File
@@ -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';