feat(scaffolder): disable the submit button on creating

close #29054

Signed-off-by: JounQin <admin@1stg.me>
This commit is contained in:
JounQin
2025-03-05 16:27:07 +08:00
parent 8a17c4b351
commit 3db64ba50f
5 changed files with 64 additions and 28 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-scaffolder-react': patch
---
Disable the submit button on creating
@@ -15,17 +15,16 @@
*/
import { renderInTestApp } from '@backstage/test-utils';
import { JsonValue } from '@backstage/types';
import type { RJSFValidationError } from '@rjsf/utils';
import { act, fireEvent, waitFor } from '@testing-library/react';
import React, { useEffect } from 'react';
import { FieldExtensionComponentProps } from '../../../extensions';
import { LayoutTemplate } from '../../../layouts';
import { SecretsContextProvider } from '../../../secrets';
import { TemplateParameterSchema } from '../../../types';
import { Stepper } from './Stepper';
import type { RJSFValidationError } from '@rjsf/utils';
import { FieldExtensionComponentProps } from '../../../extensions';
describe('Stepper', () => {
it('should render the step titles for each step of the manifest', async () => {
const manifest: TemplateParameterSchema = {
@@ -762,7 +761,10 @@ describe('Stepper', () => {
],
};
const onCreate = jest.fn();
// `onCreate` must be async to mock the submit button disabled behavior
const onCreate = jest.fn(
() => new Promise<void>(resolve => setTimeout(resolve, 0)),
);
const { getByRole } = await renderInTestApp(
<SecretsContextProvider>
@@ -783,10 +785,18 @@ describe('Stepper', () => {
fireEvent.click(getByRole('button', { name: 'Review' }));
});
fireEvent.click(getByRole('button', { name: 'Create' }));
await waitFor(() =>
expect(getByRole('button', { name: 'Create' })).toBeDisabled(),
);
await act(async () => {
fireEvent.click(getByRole('button', { name: 'Create' }));
});
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({
thing: { repoOrg: 'backstage' },
@@ -115,7 +115,7 @@ export type StepperProps = {
*/
export const Stepper = (stepperProps: StepperProps) => {
const { t } = useTranslationRef(scaffolderReactTranslationRef);
const { layouts = [], components = {}, ...props } = stepperProps;
const { layouts = [], components = {}, onCreate, ...props } = stepperProps;
const {
ReviewStateComponent = ReviewState,
ReviewStepComponent,
@@ -220,10 +220,17 @@ export const Stepper = (stepperProps: StepperProps) => {
const mergedUiSchema = merge({}, propUiSchema, currentStep?.uiSchema);
const handleCreate = useCallback(() => {
props.onCreate(stepsState);
const [isCreating, setIsCreating] = useState(false);
const handleCreate = useCallback(async () => {
setIsCreating(true);
analytics.captureEvent('click', `${createLabel}`);
}, [props, stepsState, analytics, createLabel]);
try {
await onCreate(stepsState);
} finally {
setIsCreating(false);
}
}, [analytics, createLabel, onCreate, stepsState]);
return (
<>
@@ -318,6 +325,7 @@ export const Stepper = (stepperProps: StepperProps) => {
{backLabel}
</Button>
<Button
disabled={isCreating}
variant="contained"
color="primary"
onClick={handleCreate}
@@ -95,7 +95,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
const workflowOnCreate = useCallback(
async (formState: Record<string, JsonValue>) => {
onCreate(formState);
await onCreate(formState);
const name =
typeof formState.name === 'string' ? formState.name : undefined;
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import useAsync from 'react-use/esm/useAsync';
import {
@@ -98,29 +98,41 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
return data?.metadata.annotations?.[ANNOTATION_EDIT_URL];
}, [templateRef, catalogApi]);
const onCreate = async (initialValues: Record<string, JsonValue>) => {
if (isCreating) {
return;
}
const onCreate = useCallback(
async (initialValues: Record<string, JsonValue>) => {
if (isCreating) {
return;
}
setIsCreating(true);
setIsCreating(true);
const { formState: values, secrets } = await decorators.run({
formState: initialValues,
secrets: contextSecrets,
const { formState: values, secrets } = await decorators.run({
formState: initialValues,
secrets: contextSecrets,
manifest,
});
const { taskId } = await scaffolderApi.scaffold({
templateRef,
values,
secrets,
});
navigate(taskRoute({ taskId }));
},
[
contextSecrets,
decorators,
isCreating,
manifest,
});
const { taskId } = await scaffolderApi.scaffold({
navigate,
scaffolderApi,
taskRoute,
templateRef,
values,
secrets,
});
],
);
navigate(taskRoute({ taskId }));
};
const onError = () => <Navigate to={rootRef()} />;
const onError = useCallback(() => <Navigate to={rootRef()} />, [rootRef]);
return (
<AnalyticsContext attributes={{ entityRef: templateRef }}>