Merge pull request #15162 from dagda1/workflow-execution-poc

Embed Scaffolder create flow into Catalog entity page
This commit is contained in:
Ben Lambert
2023-01-18 14:43:39 +01:00
committed by GitHub
16 changed files with 440 additions and 103 deletions
+33 -2
View File
@@ -21,6 +21,7 @@ import { JsonValue } from '@backstage/types';
import { Observable } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { SetStateAction } from 'react';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import { TaskStep } from '@backstage/plugin-scaffolder-common';
@@ -79,6 +80,9 @@ export type CustomFieldValidator<TFieldReturnValue> = (
},
) => void | Promise<void>;
// @alpha (undocumented)
export const EmbeddableWorkflow: (props: WorkflowProps) => JSX.Element;
// @alpha
export const extractSchemaFromStep: (inputStep: JsonObject) => {
uiSchema: UiSchema;
@@ -342,7 +346,7 @@ export const SecretsContextProvider: ({
}: PropsWithChildren<{}>) => JSX.Element;
// @alpha
export const Stepper: (props: StepperProps) => JSX.Element;
export const Stepper: (stepperProps: StepperProps) => JSX.Element;
// @alpha
export type StepperProps = {
@@ -351,7 +355,12 @@ export type StepperProps = {
templateName?: string;
FormProps?: FormProps;
initialState?: Record<string, JsonValue>;
onComplete: (values: Record<string, JsonValue>) => Promise<void>;
onCreate: (values: Record<string, JsonValue>) => Promise<void>;
components?: {
ReviewStateComponent?: (props: ReviewStateProps) => JSX.Element;
createButtonText?: ReactNode;
reviewButtonText?: ReactNode;
};
};
// @alpha
@@ -418,6 +427,13 @@ export const useFormDataFromQuery: (
initialState?: Record<string, JsonValue>,
) => [Record<string, any>, Dispatch<SetStateAction<Record<string, any>>>];
// @alpha (undocumented)
export const useTemplateParameterSchema: (templateRef: string) => {
manifest: TemplateParameterSchema | undefined;
loading: boolean;
error: Error | undefined;
};
// @alpha
export const useTemplateSchema: (manifest: TemplateParameterSchema) => {
steps: ParsedTemplateSchema[];
@@ -426,5 +442,20 @@ export const useTemplateSchema: (manifest: TemplateParameterSchema) => {
// @public
export const useTemplateSecrets: () => ScaffolderUseTemplateSecrets;
// @alpha (undocumented)
export const Workflow: (workflowProps: WorkflowProps) => JSX.Element | null;
// @alpha (undocumented)
export type WorkflowProps = {
title?: string;
description?: string;
namespace: string;
templateName: string;
onError(error: Error | undefined): JSX.Element | null;
} & Pick<
StepperProps,
'extensions' | 'FormProps' | 'components' | 'onCreate' | 'initialState'
>;
// (No @packageDocumentation comment for this package)
```
+1
View File
@@ -58,6 +58,7 @@
"json-schema-library": "^7.3.9",
"lodash": "^4.17.21",
"qs": "^6.9.4",
"react-use": "^17.2.4",
"zen-observable": "^0.10.0",
"zod": "~3.18.0",
"zod-to-json-schema": "~3.18.0"
+1
View File
@@ -21,3 +21,4 @@ export * from './api';
export * from './hooks';
export * from './next';
export type { FormProps } from './next';
@@ -33,7 +33,7 @@ describe('Stepper', () => {
};
const { getByText } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onComplete={jest.fn()} />,
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />,
);
for (const step of manifest.steps) {
@@ -51,7 +51,7 @@ describe('Stepper', () => {
};
const { getByRole } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onComplete={jest.fn()} />,
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />,
);
expect(getByRole('button', { name: 'Next' })).toBeInTheDocument();
@@ -91,7 +91,7 @@ describe('Stepper', () => {
};
const { getByRole } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onComplete={jest.fn()} />,
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />,
);
await fireEvent.change(getByRole('textbox', { name: 'name' }), {
@@ -162,7 +162,7 @@ describe('Stepper', () => {
title: 'React JSON Schema Form Test',
};
const onComplete = jest.fn(async (values: Record<string, JsonValue>) => {
const onCreate = jest.fn(async (values: Record<string, JsonValue>) => {
expect(values).toEqual({
first: { repository: 'Repo' },
second: { owner: 'Owner' },
@@ -172,7 +172,7 @@ describe('Stepper', () => {
const { getByRole } = await renderInTestApp(
<Stepper
manifest={manifest}
onComplete={onComplete}
onCreate={onCreate}
extensions={[
{ name: 'Repo', component: Repo },
{ name: 'Owner', component: Owner },
@@ -200,7 +200,7 @@ describe('Stepper', () => {
await fireEvent.click(getByRole('button', { name: 'Create' }));
});
expect(onComplete).toHaveBeenCalled();
expect(onCreate).toHaveBeenCalled();
});
it('should render custom field extensions properly', async () => {
@@ -229,7 +229,7 @@ describe('Stepper', () => {
<Stepper
manifest={manifest}
extensions={[{ name: 'Mock', component: MockComponent }]}
onComplete={jest.fn()}
onCreate={jest.fn()}
/>,
);
@@ -266,7 +266,7 @@ describe('Stepper', () => {
<Stepper
manifest={manifest}
extensions={[]}
onComplete={jest.fn()}
onCreate={jest.fn()}
FormProps={{ transformErrors }}
/>,
);
@@ -308,7 +308,7 @@ describe('Stepper', () => {
});
const { getByRole } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onComplete={jest.fn()} />,
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />,
);
expect(getByRole('textbox', { name: 'firstName' })).toHaveValue('John');
@@ -331,12 +331,12 @@ describe('Stepper', () => {
title: 'initialize formData',
};
const onComplete = jest.fn(async (values: Record<string, JsonValue>) => {
const onCreate = jest.fn(async (values: Record<string, JsonValue>) => {
expect(values).toHaveProperty('firstName');
});
const { getByRole } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onComplete={onComplete} />,
<Stepper manifest={manifest} extensions={[]} onCreate={onCreate} />,
);
await act(async () => {
@@ -352,4 +352,44 @@ describe('Stepper', () => {
// flush promises
return new Promise(process.nextTick);
});
it('should override the Create and Review button text', async () => {
const manifest: TemplateParameterSchema = {
title: 'Custom Fields',
steps: [
{
title: 'Test',
schema: {
properties: {
name: {
type: 'string',
},
},
},
},
],
};
const { getByRole } = await renderInTestApp(
<Stepper
manifest={manifest}
onCreate={jest.fn()}
extensions={[]}
components={{
createButtonText: <b>Make</b>,
reviewButtonText: <i>Inspect</i>,
}}
/>,
);
await act(async () => {
await fireEvent.click(getByRole('button', { name: 'Inspect' }));
});
expect(getByRole('button', { name: 'Make' })).toBeInTheDocument();
await act(async () => {
await fireEvent.click(getByRole('button', { name: 'Make' }));
});
});
});
@@ -24,17 +24,16 @@ import {
} from '@material-ui/core';
import { type IChangeEvent, withTheme } from '@rjsf/core-v5';
import { ErrorSchema, FieldValidation } from '@rjsf/utils';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState, type ReactNode } from 'react';
import { NextFieldExtensionOptions } from '../../extensions';
import { TemplateParameterSchema } from '../../../types';
import { createAsyncValidators } from './createAsyncValidators';
import type { FormProps } from '../../types';
import { ReviewState, type ReviewStateProps } from '../ReviewState';
import { useTemplateSchema } from '../../hooks/useTemplateSchema';
import { ReviewState } from '../ReviewState';
import { useFormDataFromQuery } from '../../hooks/useFormDataFromQuery';
import validator from '@rjsf/validator-ajv6';
import { useFormDataFromQuery } from '../../hooks';
import { FormProps } from '../../types';
const useStyles = makeStyles(theme => ({
backButton: {
marginRight: theme.spacing(1),
@@ -60,8 +59,12 @@ export type StepperProps = {
templateName?: string;
FormProps?: FormProps;
initialState?: Record<string, JsonValue>;
onComplete: (values: Record<string, JsonValue>) => Promise<void>;
onCreate: (values: Record<string, JsonValue>) => Promise<void>;
components?: {
ReviewStateComponent?: (props: ReviewStateProps) => JSX.Element;
createButtonText?: ReactNode;
reviewButtonText?: ReactNode;
};
};
// TODO(blam): We require here, as the types in this package depend on @rjsf/core explicitly
@@ -73,7 +76,15 @@ const Form = withTheme(require('@rjsf/material-ui-v5').Theme);
* The `Stepper` component is the Wizard that is rendered when a user selects a template
* @alpha
*/
export const Stepper = (props: StepperProps) => {
export const Stepper = (stepperProps: StepperProps) => {
const { components = {}, ...props } = stepperProps;
const {
ReviewStateComponent = ReviewState,
createButtonText = 'Create',
reviewButtonText = 'Review',
} = components;
const analytics = useAnalytics();
const { steps } = useTemplateSchema(props.manifest);
const apiHolder = useApiHolder();
@@ -177,13 +188,13 @@ export const Stepper = (props: StepperProps) => {
Back
</Button>
<Button variant="contained" color="primary" type="submit">
{activeStep === steps.length - 1 ? 'Review' : 'Next'}
{activeStep === steps.length - 1 ? reviewButtonText : 'Next'}
</Button>
</div>
</Form>
) : (
<>
<ReviewState formState={formState} schemas={steps} />
<ReviewStateComponent formState={formState} schemas={steps} />
<div className={styles.footer}>
<Button
onClick={handleBack}
@@ -195,7 +206,7 @@ export const Stepper = (props: StepperProps) => {
<Button
variant="contained"
onClick={() => {
props.onComplete(formState);
props.onCreate(formState);
const name =
typeof formState.name === 'string'
? formState.name
@@ -206,7 +217,7 @@ export const Stepper = (props: StepperProps) => {
);
}}
>
Create
{createButtonText}
</Button>
</div>
</>
@@ -0,0 +1,144 @@
/*
* 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 { ApiProvider } from '@backstage/core-app-api';
import {
MockAnalyticsApi,
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import React from 'react';
import { Workflow } from './Workflow';
import { analyticsApiRef } from '@backstage/core-plugin-api';
import { ScaffolderApi } from '../../../api/types';
import { scaffolderApiRef } from '../../../api/ref';
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
};
const analyticsMock = new MockAnalyticsApi();
const apis = TestApiRegistry.from(
[scaffolderApiRef, scaffolderApiMock],
[analyticsApiRef, analyticsMock],
);
describe('<Workflow />', () => {
it('should complete a workflow', async () => {
const onCreate = jest.fn();
const onError = jest.fn();
scaffolderApiMock.scaffold.mockResolvedValue({ taskId: 'xyz' });
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
steps: [
{
title: 'Step 1',
schema: {
properties: {
name: {
type: 'string',
},
},
},
},
{
title: 'Step 2',
schema: {
properties: {
age: {
type: 'string',
},
},
},
},
],
title: 'React JSON Schema Form Test',
});
const { getByRole, getAllByRole, getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<Workflow
title="Different title than template"
description={`
## This is markdown
- overriding the template description
`}
onCreate={onCreate}
onError={onError}
namespace="default"
templateName="docs-template"
initialState={{
name: 'prefilled-name',
age: '53',
}}
components={{
ReviewStateComponent: () => (
<h1>This is a different wrapper for the review page</h1>
),
reviewButtonText: <i>Onwards</i>,
createButtonText: <b>Make</b>,
}}
extensions={[]}
/>
</ApiProvider>,
);
// Test template title is overriden
expect(getByRole('heading', { level: 2 }).innerHTML).toBe(
'Different title than template',
);
const nameInput = getByRole('textbox', {
name: 'name',
}) as HTMLInputElement;
expect(nameInput).toBeInTheDocument();
expect(nameInput.value).toBe('prefilled-name');
await act(async () => {
fireEvent.click(getByRole('button', { name: 'Next' }));
});
const ageInput = getByRole('textbox', { name: 'age' }) as HTMLInputElement;
expect(ageInput.value).toBe('53');
await act(async () => {
fireEvent.click(getByRole('button', { name: 'Onwards' }));
});
expect(
getByText('This is a different wrapper for the review page'),
).toBeDefined();
await act(async () => {
fireEvent.click(getAllByRole('button')[1] as HTMLButtonElement);
});
expect(onCreate).toHaveBeenCalledWith({
name: 'prefilled-name',
age: '53',
});
});
});
@@ -0,0 +1,114 @@
/*
* 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, { useEffect } from 'react';
import {
Content,
InfoCard,
MarkdownContent,
Progress,
} from '@backstage/core-components';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema';
import { Stepper, type StepperProps } from '../Stepper/Stepper';
import { SecretsContextProvider } from '../../../secrets/SecretsContext';
const useStyles = makeStyles<BackstageTheme>(() => ({
markdown: {
/** to make the styles for React Markdown not leak into the description */
'& :first-child': {
marginTop: 0,
},
'& :last-child': {
marginBottom: 0,
},
},
}));
/**
* @alpha
*/
export type WorkflowProps = {
title?: string;
description?: string;
namespace: string;
templateName: string;
onError(error: Error | undefined): JSX.Element | null;
} & Pick<
StepperProps,
'extensions' | 'FormProps' | 'components' | 'onCreate' | 'initialState'
>;
/**
* @alpha
*/
export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
const { title, description, namespace, templateName, ...props } =
workflowProps;
const styles = useStyles();
const templateRef = stringifyEntityRef({
kind: 'Template',
namespace: namespace,
name: templateName,
});
const errorApi = useApi(errorApiRef);
const { loading, manifest, error } = useTemplateParameterSchema(templateRef);
useEffect(() => {
if (error) {
errorApi.post(new Error(`Failed to load template, ${error}`));
}
}, [error, errorApi]);
if (error) {
return props.onError(error);
}
return (
<Content>
{loading && <Progress />}
{manifest && (
<InfoCard
title={title ?? manifest.title}
subheader={
<MarkdownContent
className={styles.markdown}
content={description ?? manifest.description ?? 'No description'}
/>
}
noPadding
titleTypographyProps={{ component: 'h2' }}
>
<Stepper manifest={manifest} {...props} />
</InfoCard>
)}
</Content>
);
};
/**
* @alpha
*/
export const EmbeddableWorkflow = (props: WorkflowProps) => (
<SecretsContextProvider>
<Workflow {...props} />
</SecretsContextProvider>
);
@@ -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 { Workflow, EmbeddableWorkflow, type WorkflowProps } from './Workflow';
@@ -17,3 +17,4 @@ export * from './Stepper';
export * from './TemplateCard';
export * from './ReviewState';
export * from './TemplateGroup';
export * from './Workflow';
@@ -18,3 +18,4 @@ export {
useTemplateSchema,
type ParsedTemplateSchema,
} from './useTemplateSchema';
export { useTemplateParameterSchema } from './useTemplateParameterSchema';
@@ -0,0 +1,32 @@
import { useApi } from '@backstage/core-plugin-api';
/*
* Copyright 2023 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 useAsync from 'react-use/lib/useAsync';
import { scaffolderApiRef } from '../../api/ref';
/**
* @alpha
*/
export const useTemplateParameterSchema = (templateRef: string) => {
const scaffolderApi = useApi(scaffolderApiRef);
const { value, loading, error } = useAsync(
() => scaffolderApi.getTemplateParameterSchema(templateRef),
[scaffolderApi, templateRef],
);
return { manifest: value, loading, error };
};
+1 -1
View File
@@ -15,6 +15,6 @@
*/
export * from './components';
export * from './extensions';
export * from './types';
export type { FormProps } from './types';
export * from './lib';
export * from './hooks';