Skip to content

Commit 149804f

Browse files
fix(orchestrator): Disable Next button when active widgets are fetching and processing data (#1672)
* Disable Next button when active widgets are fetching and processing data * Create useProcessingState custom hook to reduce code duplication across widgets * Rebased with main
1 parent 585a31b commit 149804f

File tree

14 files changed

+298
-102
lines changed

14 files changed

+298
-102
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': minor
3+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-api': minor
4+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': minor
5+
---
6+
7+
Disable Next button when active widgets are fetching and processing data
8+
9+
- Add isFetching state tracking to StepperContext using a counter to monitor multiple concurrent async operations
10+
- Update OrchestratorFormToolbar to disable Next button when isFetching is true (in addition to existing isValidating check)
11+
- Add handleFetchStarted and handleFetchEnded callbacks to OrchestratorFormContextProps to allow widgets to report their loading status
12+
- Update useFetchAndEvaluate to track complete loading state (fetch + template evaluation) and notify context
13+
- Create useProcessingState custom hook to reduce code duplication across widgets, providing a reusable pattern for tracking both fetch and processing states
14+
- Refactor SchemaUpdater, ActiveTextInput, ActiveDropdown, and ActiveMultiSelect to use useProcessingState hook
15+
- Track the complete loading lifecycle: fetch → process → ready, ensuring Next button is disabled until all async work completes
16+
- Prevents race conditions where Next button becomes enabled before widgets finish processing data

workspaces/orchestrator/plugins/orchestrator-form-api/report.api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export type OrchestratorFormContextProps = {
3838
setAuthTokenDescriptors: (authTokenDescriptors: AuthTokenDescriptor[]) => void;
3939
getIsChangedByUser: (id: string) => boolean;
4040
setIsChangedByUser: (id: string, isChangedByUser: boolean) => void;
41+
handleFetchStarted?: () => void;
42+
handleFetchEnded?: () => void;
4143
};
4244

4345
// @public
@@ -58,7 +60,7 @@ export const useOrchestratorFormApiOrDefault: () => OrchestratorFormApi;
5860

5961
// Warnings were encountered during analysis:
6062
//
61-
// src/api.d.ts:98:22 - (ae-undocumented) Missing documentation for "useOrchestratorFormApiOrDefault".
63+
// src/api.d.ts:100:22 - (ae-undocumented) Missing documentation for "useOrchestratorFormApiOrDefault".
6264

6365
// (No @packageDocumentation comment for this package)
6466

workspaces/orchestrator/plugins/orchestrator-form-api/src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export type OrchestratorFormContextProps = {
4444
) => void;
4545
getIsChangedByUser: (id: string) => boolean;
4646
setIsChangedByUser: (id: string, isChangedByUser: boolean) => void;
47+
handleFetchStarted?: () => void;
48+
handleFetchEnded?: () => void;
4749
};
4850

4951
/**

workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormStepper.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ const OrchestratorFormStepper = ({
9797

9898
export const OrchestratorFormToolbar = () => {
9999
const { t } = useTranslation();
100-
const { activeStep, handleBack, isValidating } = useStepperContext();
100+
const { activeStep, handleBack, isValidating, isFetching } =
101+
useStepperContext();
101102
const { classes } = useStyles();
102103

103104
return (
@@ -109,7 +110,9 @@ export const OrchestratorFormToolbar = () => {
109110
>
110111
{t('common.back')}
111112
</Button>
112-
<SubmitButton submitting={isValidating}>{t('common.next')}</SubmitButton>
113+
<SubmitButton submitting={isValidating || isFetching}>
114+
{t('common.next')}
115+
</SubmitButton>
113116
</div>
114117
);
115118
};

workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,23 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => {
147147

148148
const OrchestratorFormWrapper = (props: OrchestratorFormContextProps) => {
149149
const formApi = useOrchestratorFormApiOrDefault();
150+
const { handleFetchStarted, handleFetchEnded } = useStepperContext();
150151

151152
const NewComponent = useMemo(() => {
152153
const formDecorator = formApi.getFormDecorator();
153154
return formDecorator(FormComponent);
154155
}, [formApi]);
155156

156-
return <NewComponent {...props} />;
157+
const propsWithFetchHandlers = useMemo(
158+
() => ({
159+
...props,
160+
handleFetchStarted,
161+
handleFetchEnded,
162+
}),
163+
[props, handleFetchStarted, handleFetchEnded],
164+
);
165+
166+
return <NewComponent {...propsWithFetchHandlers} />;
157167
};
158168

159169
export default OrchestratorFormWrapper;

workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/StepperContext.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export type StepperContext = {
2626
isValidating: boolean;
2727
handleValidateStarted: () => void;
2828
handleValidateEnded: () => void;
29+
isFetching: boolean;
30+
handleFetchStarted: () => void;
31+
handleFetchEnded: () => void;
2932
t: TranslationFunction;
3033
};
3134

@@ -50,6 +53,8 @@ export const StepperContextProvider = ({
5053
}) => {
5154
const [activeStep, setActiveStep] = useState<number>(0);
5255
const [isValidating, setIsValidating] = useState<boolean>(false);
56+
const [fetchingCount, setFetchingCount] = useState<number>(0);
57+
5358
const contextData = useMemo(() => {
5459
return {
5560
activeStep,
@@ -61,8 +66,20 @@ export const StepperContextProvider = ({
6166
isValidating,
6267
handleValidateStarted: () => setIsValidating(true),
6368
handleValidateEnded: () => setIsValidating(false),
69+
isFetching: fetchingCount > 0,
70+
handleFetchStarted: () => setFetchingCount(count => count + 1),
71+
handleFetchEnded: () => setFetchingCount(count => Math.max(0, count - 1)),
6472
t,
6573
};
66-
}, [t, setActiveStep, activeStep, reviewStep, isValidating, setIsValidating]);
74+
}, [
75+
t,
76+
setActiveStep,
77+
activeStep,
78+
reviewStep,
79+
isValidating,
80+
setIsValidating,
81+
fetchingCount,
82+
setFetchingCount,
83+
]);
6784
return <context.Provider value={contextData}>{children}</context.Provider>;
6885
};

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export * from './useGetExtraErrors';
2222
export * from './useFetch';
2323
export * from './useFetchAndEvaluate';
2424
export * from './applySelector';
25+
export * from './useProcessingState';

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import { JsonObject } from '@backstage/types/index';
18-
import React, { useState } from 'react';
18+
import React, { useState, useEffect } from 'react';
1919
import { UiProps } from '../uiPropTypes';
2020
import { getErrorMessage } from './errorUtils';
2121
import { evaluateTemplateString } from './evaluateTemplate';
@@ -30,6 +30,8 @@ export const useFetchAndEvaluate = (
3030
formData: JsonObject,
3131
uiProps: UiProps,
3232
fieldId: string,
33+
handleFetchStarted?: () => void,
34+
handleFetchEnded?: () => void,
3335
) => {
3436
const unitEvaluator = useTemplateUnitEvaluator();
3537
const retrigger = useRetriggerEvaluate(
@@ -88,9 +90,24 @@ export const useFetchAndEvaluate = (
8890
template,
8991
],
9092
);
93+
94+
// Track the complete loading state (fetch + evaluation) in the parent context
95+
const completeLoading = loading || fetchLoading;
96+
useEffect(() => {
97+
if (completeLoading && handleFetchStarted) {
98+
handleFetchStarted();
99+
return () => {
100+
if (handleFetchEnded) {
101+
handleFetchEnded();
102+
}
103+
};
104+
}
105+
return undefined;
106+
}, [completeLoading, handleFetchStarted, handleFetchEnded]);
107+
91108
return {
92109
text: resultText,
93-
loading: loading || fetchLoading,
110+
loading: completeLoading,
94111
error: error ?? fetchError,
95112
};
96113
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { useCallback, useEffect, useState } from 'react';
18+
19+
/**
20+
* Custom hook to manage processing state and notify parent context about loading status.
21+
* This hook tracks both fetch loading and post-fetch processing, ensuring that the
22+
* parent form knows when async operations are complete.
23+
*
24+
* @param fetchLoading - Whether data is currently being fetched
25+
* @param handleFetchStarted - Optional callback to notify when processing starts
26+
* @param handleFetchEnded - Optional callback to notify when processing ends
27+
* @returns Object containing processing state management
28+
*/
29+
export const useProcessingState = (
30+
fetchLoading: boolean,
31+
handleFetchStarted?: () => void,
32+
handleFetchEnded?: () => void,
33+
) => {
34+
const [isProcessing, setIsProcessing] = useState(false);
35+
36+
// Complete loading = fetch loading OR post-fetch processing
37+
const completeLoading = fetchLoading || isProcessing;
38+
39+
// Notify parent context about the complete loading state
40+
useEffect(() => {
41+
if (completeLoading && handleFetchStarted) {
42+
handleFetchStarted();
43+
return () => {
44+
if (handleFetchEnded) {
45+
handleFetchEnded();
46+
}
47+
};
48+
}
49+
return undefined;
50+
}, [completeLoading, handleFetchStarted, handleFetchEnded]);
51+
52+
// Helper to wrap async processing with setIsProcessing
53+
const wrapProcessing = useCallback(
54+
async <T>(fn: () => Promise<T>): Promise<T> => {
55+
setIsProcessing(true);
56+
try {
57+
return await fn();
58+
} finally {
59+
setIsProcessing(false);
60+
}
61+
},
62+
[],
63+
);
64+
65+
return {
66+
isProcessing,
67+
setIsProcessing,
68+
completeLoading,
69+
wrapProcessing,
70+
};
71+
};

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
useRetriggerEvaluate,
3333
useTemplateUnitEvaluator,
3434
applySelectorArray,
35+
useProcessingState,
3536
} from '../utils';
3637
import { UiProps } from '../uiPropTypes';
3738
import { ErrorText } from './ErrorText';
@@ -82,6 +83,9 @@ export const ActiveDropdown: Widget<
8283
const [labels, setLabels] = useState<string[]>();
8384
const [values, setValues] = useState<string[]>();
8485

86+
const handleFetchStarted = formContext?.handleFetchStarted;
87+
const handleFetchEnded = formContext?.handleFetchEnded;
88+
8589
const retrigger = useRetriggerEvaluate(
8690
templateUnitEvaluator,
8791
formData,
@@ -91,28 +95,37 @@ export const ActiveDropdown: Widget<
9195

9296
const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger);
9397

98+
// Track the complete loading state (fetch + processing)
99+
const { completeLoading, wrapProcessing } = useProcessingState(
100+
loading,
101+
handleFetchStarted,
102+
handleFetchEnded,
103+
);
104+
94105
useEffect(() => {
95106
if (!data || !labelSelector || !valueSelector) {
96107
return;
97108
}
98109

99110
const doItAsync = async () => {
100-
const selectedLabels = await applySelectorArray(data, labelSelector);
101-
const selectedValues = await applySelectorArray(data, valueSelector);
102-
103-
if (selectedLabels.length !== selectedValues.length) {
104-
setLocalError(
105-
`Selected labels and values have different count (${selectedLabels.length} and ${selectedValues.length}) for ${props.id}`,
106-
);
107-
return;
108-
}
109-
110-
setLabels(selectedLabels);
111-
setValues(selectedValues);
111+
await wrapProcessing(async () => {
112+
const selectedLabels = await applySelectorArray(data, labelSelector);
113+
const selectedValues = await applySelectorArray(data, valueSelector);
114+
115+
if (selectedLabels.length !== selectedValues.length) {
116+
setLocalError(
117+
`Selected labels and values have different count (${selectedLabels.length} and ${selectedValues.length}) for ${props.id}`,
118+
);
119+
return;
120+
}
121+
122+
setLabels(selectedLabels);
123+
setValues(selectedValues);
124+
});
112125
};
113126

114127
doItAsync();
115-
}, [labelSelector, valueSelector, data, props.id]);
128+
}, [labelSelector, valueSelector, data, props.id, wrapProcessing]);
116129

117130
const handleChange = useCallback(
118131
(changed: string, isByUser: boolean) => {
@@ -136,7 +149,7 @@ export const ActiveDropdown: Widget<
136149
return <ErrorText text={localError ?? error ?? ''} id={id} />;
137150
}
138151

139-
if (loading || !labels || !values) {
152+
if (completeLoading || !labels || !values) {
140153
return <CircularProgress size={20} />;
141154
}
142155

0 commit comments

Comments
 (0)