Skip to content

Commit 546ccb2

Browse files
karthikjeeyarlokanandaprabhucursoragent
authored
Fix/orchestrator plugins (#2518)
* feat(orchestrator): add card height mode config for workflow run page (#2386) * feat(orchestrator): add card height mode config Expose a workflow instance page option to switch between fixed card heights and content-based sizing, with a new hook and changeset entry. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(orchestrator): simplify layout and warn on invalid mode Refactor the workflow instance layout to reuse card components and rename the height mode flag for clarity, and warn when config values are unexpected before falling back to fixed mode. Made-with: Cursor --------- Co-authored-by: Cursor <cursoragent@cursor.com> * fix(orchestrator-form-widgets): show spinner immediately on ActiveText retrigger (#2279) * fix: show spinner on ActiveText retrigger * fix(orchestrator-form-widgets): keep spinner until ActiveText eval completes * Merge upstream/main Co-authored-by: Cursor <cursoragent@cursor.com> * chore(changeset): mention clearOnRetrigger Document the new fetch:clearOnRetrigger behavior in the existing changeset for the ActiveText retrigger spinner update. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(orchestrator-form-widgets): dedupe clearOnRetrigger Extract shared clear-on-retrigger behavior into a reusable hook and reuse it across ActiveTextInput, ActiveDropdown, and ActiveMultiSelect. Co-authored-by: Cursor <cursoragent@cursor.com> * 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: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Lokananda Prabhu <102503482+lokanandaprabhu@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8b96efc commit 546ccb2

14 files changed

Lines changed: 349 additions & 58 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.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-orchestrator': patch
3+
'@red-hat-developer-hub/backstage-plugin-orchestrator-common': patch
4+
---
5+
6+
Add workflow instance card height mode config for fixed or content-based layouts.

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-common/config.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,19 @@ export interface Config {
103103
value: string;
104104
}>;
105105
};
106+
/**
107+
* UI configuration for the workflow instance page.
108+
* @visibility frontend
109+
*/
110+
workflowInstancePage?: {
111+
/**
112+
* Controls card height behavior on the workflow instance page.
113+
* "fixed" keeps the current fixed-height cards with internal scrolling.
114+
* "content" lets cards expand to fit their content.
115+
* Default: fixed
116+
* @visibility frontend
117+
*/
118+
cardHeightMode?: 'fixed' | 'content';
119+
};
106120
};
107121
}

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)

0 commit comments

Comments
 (0)