Skip to content

Commit 2930ca0

Browse files
karthikjeeyarlokanandaprabhucursoragent
authored
fix(orchestrator-form-widgets): show spinner immediately on ActiveText retrigger (#2279) (#2517)
* fix: show spinner on ActiveText retrigger * fix(orchestrator-form-widgets): keep spinner until ActiveText eval completes * Merge upstream/main * chore(changeset): mention clearOnRetrigger Document the new fetch:clearOnRetrigger behavior in the existing changeset for the ActiveText retrigger spinner update. * refactor(orchestrator-form-widgets): dedupe clearOnRetrigger Extract shared clear-on-retrigger behavior into a reusable hook and reuse it across ActiveTextInput, ActiveDropdown, and ActiveMultiSelect. * fix(orchestrator-form-widgets): guard retrigger races Ignore stale fetch responses when retrigger values change and avoid reapplying cached data while a retriggered fetch is loading. Use layout effect for clearOnRetrigger to reduce UI flicker. --------- Co-authored-by: Lokananda Prabhu <102503482+lokanandaprabhu@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 444e066 commit 2930ca0

10 files changed

Lines changed: 205 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch
3+
---
4+
5+
Show ActiveText spinner immediately on retrigger changes to avoid stale text during debounce. Add fetch:clearOnRetrigger to clear widget values when dependencies change.

workspaces/orchestrator/docs/orchestratorFormWidgets.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ The widget supports the following `ui:props` (for detailed information on each,
521521
- `fetch:headers`: HTTP headers for the fetch request
522522
- `fetch:body`: HTTP body for the fetch request
523523
- `fetch:retrigger`: Array of field paths that trigger a refetch when their values change
524+
- `fetch:clearOnRetrigger`: Clears the field value when retrigger dependencies change
524525

525526
## Content of `ui:props`
526527

@@ -540,6 +541,7 @@ Various selectors (like `fetch:response:*`) are processed by the [jsonata](https
540541
| fetch:method | HTTP method to use. The default is GET. | GET, POST (So far no identified use-case for PUT or DELETE) |
541542
| fetch:body | An object representing the body of an HTTP POST request. Not used with the GET method. Property value can be a string template or an array of strings. templates. | `{“foo”: “bar $${{identityApi.token}}”, "myArray": ["constant", "$${{current.solutionName}}"]}` |
542543
| fetch:retrigger | An array of keys/key families as described in the Backstage API Exposed Parts. If the value referenced by any key from this list is changed, the fetch is triggered. | `["current.solutionName", "identityApi.profileName"]` |
544+
| fetch:clearOnRetrigger | When set to `true`, clears the field value as soon as any `fetch:retrigger` dependency changes, before the fetch completes. Useful to avoid stale values while refetching. | `true`, `false` (default: `false`) |
543545
| fetch:error:ignoreUnready | When set to `true`, suppresses fetch error display until all `fetch:retrigger` dependencies have non-empty values. This is useful when fetch depends on other fields that are not filled yet, preventing expected errors from being displayed during initial load. | `true`, `false` (default: `false`) |
544546
| fetch:error:silent | When set to `true`, suppresses fetch error display when the fetch request returns a non-OK status (4xx/5xx). Use this when you want to handle error states via conditional UI instead of showing the widget error. | `true`, `false` (default: `false`) |
545547
| fetch:skipInitialValue | When set to `true`, prevents applying the initial value from `fetch:response:value`, keeping the field empty until the user selects or types a value. | `true`, `false` (default: `false`) |

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type UiProps = {
2525
'fetch:headers'?: Record<string, string>;
2626
'fetch:body'?: Record<string, JsonValue>;
2727
'fetch:retrigger'?: string[];
28+
'fetch:clearOnRetrigger'?: boolean;
2829
'fetch:error:ignoreUnready'?: boolean;
2930
'fetch:error:silent'?: boolean;
3031
'fetch:skipInitialValue'?: boolean;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export * from './useFetchAndEvaluate';
2424
export * from './applySelector';
2525
export * from './useProcessingState';
2626
export * from './resolveDropdownDefault';
27+
export * from './useClearOnRetrigger';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
import { useLayoutEffect, useRef } from 'react';
17+
import isEqual from 'lodash/isEqual';
18+
19+
type UseClearOnRetriggerArgs = {
20+
enabled: boolean;
21+
retrigger: (string | undefined)[] | undefined;
22+
onClear: () => void;
23+
};
24+
25+
export const useClearOnRetrigger = ({
26+
enabled,
27+
retrigger,
28+
onClear,
29+
}: UseClearOnRetriggerArgs) => {
30+
const prevRetriggerRef = useRef<(string | undefined)[] | undefined>(
31+
retrigger,
32+
);
33+
34+
useLayoutEffect(() => {
35+
if (!enabled) {
36+
prevRetriggerRef.current = retrigger;
37+
return;
38+
}
39+
40+
if (!retrigger) {
41+
prevRetriggerRef.current = retrigger;
42+
return;
43+
}
44+
45+
const prev = prevRetriggerRef.current;
46+
if (prev && !isEqual(prev, retrigger)) {
47+
onClear();
48+
}
49+
50+
prevRetriggerRef.current = retrigger;
51+
}, [enabled, retrigger, onClear]);
52+
};

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

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@
1616

1717
import { useApi, fetchApiRef } from '@backstage/core-plugin-api';
1818
import { JsonObject } from '@backstage/types';
19-
import { useState } from 'react';
19+
import { useEffect, useRef, useState } from 'react';
2020
import { UiProps } from '../uiPropTypes';
2121
import { getErrorMessage } from './errorUtils';
2222
import { useEvaluateTemplate } from './evaluateTemplate';
2323
import { useRequestInit } from './useRequestInit';
2424
import { useRetriggerEvaluate } from './useRetriggerEvaluate';
2525
import { useDebounce } from 'react-use';
2626
import { DEFAULT_DEBOUNCE_LIMIT } from '../widgets/constants';
27+
import isEqual from 'lodash/isEqual';
2728

2829
/**
2930
* Checks if all fetch:retrigger dependencies have non-empty values.
@@ -55,6 +56,7 @@ export const useFetch = (
5556

5657
const fetchUrl = uiProps['fetch:url'];
5758
const skipErrorWhenDepsEmpty = uiProps['fetch:error:ignoreUnready'] === true;
59+
const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true;
5860
const evaluatedRequestInit = useRequestInit({
5961
uiProps,
6062
prefix: 'fetch',
@@ -67,6 +69,63 @@ export const useFetch = (
6769
formData,
6870
setError,
6971
});
72+
const prevRetriggerRef = useRef<(string | undefined)[] | undefined>(
73+
retrigger,
74+
);
75+
const latestRetriggerRef = useRef<(string | undefined)[] | undefined>(
76+
retrigger,
77+
);
78+
const requestIdRef = useRef(0);
79+
80+
useEffect(() => {
81+
latestRetriggerRef.current = retrigger;
82+
}, [retrigger]);
83+
84+
useEffect(() => {
85+
if (!clearOnRetrigger) {
86+
prevRetriggerRef.current = retrigger;
87+
return;
88+
}
89+
90+
if (!retrigger) {
91+
prevRetriggerRef.current = retrigger;
92+
return;
93+
}
94+
95+
const prev = prevRetriggerRef.current;
96+
if (prev && !isEqual(prev, retrigger)) {
97+
setData(undefined);
98+
setError(undefined);
99+
}
100+
101+
prevRetriggerRef.current = retrigger;
102+
}, [clearOnRetrigger, retrigger]);
103+
104+
const hasFetchInputs =
105+
!!fetchUrl && !!evaluatedFetchUrl && !!evaluatedRequestInit && !!retrigger;
106+
107+
// Set loading immediately on dependency changes so UI shows a spinner during debounce.
108+
useEffect(() => {
109+
if (!hasFetchInputs) {
110+
setLoading(false);
111+
return;
112+
}
113+
114+
if (!areRetriggerDependenciesSatisfied(retrigger)) {
115+
setLoading(false);
116+
return;
117+
}
118+
119+
// Mark loading immediately when a retrigger change is detected so widgets
120+
// can show the spinner during the debounce window before the fetch starts.
121+
setLoading(true);
122+
}, [
123+
hasFetchInputs,
124+
fetchUrl,
125+
evaluatedFetchUrl,
126+
evaluatedRequestInit,
127+
retrigger,
128+
]);
70129

71130
useDebounce(
72131
() => {
@@ -81,6 +140,8 @@ export const useFetch = (
81140
}
82141

83142
const fetchData = async () => {
143+
const requestId = ++requestIdRef.current;
144+
const retriggerSnapshot = retrigger;
84145
try {
85146
setError(undefined);
86147
if (typeof evaluatedFetchUrl !== 'string') {
@@ -116,14 +177,23 @@ export const useFetch = (
116177
throw new Error('JSON object expected');
117178
}
118179

119-
setData(responseData);
180+
if (
181+
requestId === requestIdRef.current &&
182+
isEqual(retriggerSnapshot, latestRetriggerRef.current)
183+
) {
184+
setData(responseData);
185+
}
120186
} catch (err) {
121187
const prefix = `Failed to fetch data for url ${fetchUrl}.`;
122188
// eslint-disable-next-line no-console
123189
console.error(prefix, err);
124-
setError(getErrorMessage(prefix, err));
190+
if (requestId === requestIdRef.current) {
191+
setError(getErrorMessage(prefix, err));
192+
}
125193
} finally {
126-
setLoading(false);
194+
if (requestId === requestIdRef.current) {
195+
setLoading(false);
196+
}
127197
}
128198
};
129199
fetchData();

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,32 @@ export const useFetchAndEvaluate = (
5959
const [error, setError] = useState<string>();
6060
const [loading, setLoading] = React.useState(true);
6161
const [resultText, setResultText] = React.useState<string>();
62+
63+
// Keep spinner visible during the debounce window after fetch/retrigger changes.
64+
useEffect(() => {
65+
if (!hasRetrigger || !retrigger || waitingForRetrigger) {
66+
return;
67+
}
68+
69+
if (fetchError) {
70+
setLoading(false);
71+
return;
72+
}
73+
74+
// Show spinner immediately on retrigger changes and after data updates,
75+
// even before the debounced evaluation completes.
76+
if (retriggerSatisfied || fetchLoading || data !== undefined) {
77+
setLoading(true);
78+
}
79+
}, [
80+
hasRetrigger,
81+
retrigger,
82+
retriggerSatisfied,
83+
waitingForRetrigger,
84+
fetchError,
85+
fetchLoading,
86+
data,
87+
]);
6288
useDebounce(
6389
() => {
6490
const evaluate = async () => {

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
applySelectorArray,
3535
resolveDropdownDefault,
3636
useProcessingState,
37+
useClearOnRetrigger,
3738
} from '../utils';
3839
import { UiProps } from '../uiPropTypes';
3940
import { ErrorText } from './ErrorText';
@@ -79,6 +80,7 @@ export const ActiveDropdown: Widget<
7980
const hasStaticDefault = typeof staticDefault === 'string';
8081
const staticDefaultValue = hasStaticDefault ? staticDefault : undefined;
8182
const skipInitialValue = uiProps['fetch:skipInitialValue'] === true;
83+
const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true;
8284

8385
const [localError, setLocalError] = useState<string | undefined>(
8486
!labelSelector || !valueSelector
@@ -159,6 +161,16 @@ export const ActiveDropdown: Widget<
159161
[onChange, id, setIsChangedByUser],
160162
);
161163

164+
const handleClear = useCallback(() => {
165+
handleChange('', false);
166+
}, [handleChange]);
167+
168+
useClearOnRetrigger({
169+
enabled: clearOnRetrigger,
170+
retrigger,
171+
onClear: handleClear,
172+
});
173+
162174
// Set default value from fetched options
163175
// Priority: selector default (if valid option) > static default (if valid) > first fetched option
164176
// Note: Static defaults are applied at form initialization level (in OrchestratorForm)

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import {
1717
KeyboardEvent,
1818
SyntheticEvent,
19+
useCallback,
1920
useEffect,
2021
useMemo,
2122
useState,
@@ -41,6 +42,7 @@ import {
4142
useFetch,
4243
useRetriggerEvaluate,
4344
useProcessingState,
45+
useClearOnRetrigger,
4446
} from '../utils';
4547
import { UiProps } from '../uiPropTypes';
4648
import { ErrorText } from './ErrorText';
@@ -85,6 +87,7 @@ export const ActiveMultiSelect: Widget<
8587
const allowNewItems = uiProps['ui:allowNewItems'] === true;
8688
const staticDefault = uiProps['fetch:response:default'];
8789
const skipInitialValue = uiProps['fetch:skipInitialValue'] === true;
90+
const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true;
8891
const staticDefaultValues = Array.isArray(staticDefault)
8992
? (staticDefault as string[])
9093
: undefined;
@@ -145,6 +148,17 @@ export const ActiveMultiSelect: Widget<
145148
handleFetchEnded,
146149
);
147150

151+
const handleClear = useCallback(() => {
152+
setInProgressItem('');
153+
onChange([]);
154+
}, [onChange]);
155+
156+
useClearOnRetrigger({
157+
enabled: clearOnRetrigger,
158+
retrigger,
159+
onClear: handleClear,
160+
});
161+
148162
// Process fetch results
149163
// Note: Static defaults are applied at form initialization level (in OrchestratorForm)
150164
useEffect(() => {

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
applySelectorArray,
3838
applySelectorString,
3939
useProcessingState,
40+
useClearOnRetrigger,
4041
} from '../utils';
4142
import { ErrorText } from './ErrorText';
4243
import { UiProps } from '../uiPropTypes';
@@ -73,6 +74,7 @@ export const ActiveTextInput: Widget<
7374
const hasStaticDefault = typeof staticDefault === 'string';
7475
const skipInitialValue = uiProps['fetch:skipInitialValue'] === true;
7576
const hasFetchUrl = !!uiProps['fetch:url'];
77+
const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true;
7678

7779
// If fetch:url is configured, either fetch:response:value OR fetch:response:default should be set
7880
// to provide meaningful behavior. Without fetch:url, the widget works as a plain text input.
@@ -113,9 +115,23 @@ export const ActiveTextInput: Widget<
113115
[onChange, id, setIsChangedByUser],
114116
);
115117

118+
const handleClear = useCallback(() => {
119+
handleChange('', false);
120+
}, [handleChange]);
121+
122+
useClearOnRetrigger({
123+
enabled: clearOnRetrigger,
124+
retrigger,
125+
onClear: handleClear,
126+
});
127+
116128
// Process fetch results - only override if fetch returns a non-empty value
117129
// Static defaults are applied at form initialization level (in OrchestratorForm)
118130
useEffect(() => {
131+
if (clearOnRetrigger && loading) {
132+
return;
133+
}
134+
119135
if (!data) {
120136
return;
121137
}
@@ -159,6 +175,8 @@ export const ActiveTextInput: Widget<
159175
isChangedByUser,
160176
skipInitialValue,
161177
wrapProcessing,
178+
clearOnRetrigger,
179+
loading,
162180
]);
163181

164182
const shouldShowFetchError = uiProps['fetch:error:silent'] !== true;

0 commit comments

Comments
 (0)