Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch
---

Show ActiveText spinner immediately on retrigger changes to avoid stale text during debounce. Add fetch:clearOnRetrigger to clear widget values when dependencies change.
2 changes: 2 additions & 0 deletions workspaces/orchestrator/docs/orchestratorFormWidgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ The widget supports the following `ui:props` (for detailed information on each,
- `fetch:headers`: HTTP headers for the fetch request
- `fetch:body`: HTTP body for the fetch request
- `fetch:retrigger`: Array of field paths that trigger a refetch when their values change
- `fetch:clearOnRetrigger`: Clears the field value when retrigger dependencies change

## Content of `ui:props`

Expand All @@ -540,6 +541,7 @@ Various selectors (like `fetch:response:*`) are processed by the [jsonata](https
| fetch:method | HTTP method to use. The default is GET. | GET, POST (So far no identified use-case for PUT or DELETE) |
| 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}}"]}` |
| 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"]` |
| 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`) |
| 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`) |
| 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`) |
| 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`) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type UiProps = {
'fetch:headers'?: Record<string, string>;
'fetch:body'?: Record<string, JsonValue>;
'fetch:retrigger'?: string[];
'fetch:clearOnRetrigger'?: boolean;
'fetch:error:ignoreUnready'?: boolean;
'fetch:error:silent'?: boolean;
'fetch:skipInitialValue'?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export * from './useFetchAndEvaluate';
export * from './applySelector';
export * from './useProcessingState';
export * from './resolveDropdownDefault';
export * from './useClearOnRetrigger';
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright Red Hat, Inc.
*
* 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 { useLayoutEffect, useRef } from 'react';
import isEqual from 'lodash/isEqual';

type UseClearOnRetriggerArgs = {
enabled: boolean;
retrigger: (string | undefined)[] | undefined;
onClear: () => void;
};

export const useClearOnRetrigger = ({
enabled,
retrigger,
onClear,
}: UseClearOnRetriggerArgs) => {
const prevRetriggerRef = useRef<(string | undefined)[] | undefined>(
retrigger,
);

useLayoutEffect(() => {
if (!enabled) {
prevRetriggerRef.current = retrigger;
return;
}

if (!retrigger) {
prevRetriggerRef.current = retrigger;
return;
}

const prev = prevRetriggerRef.current;
if (prev && !isEqual(prev, retrigger)) {
onClear();
}

prevRetriggerRef.current = retrigger;
}, [enabled, retrigger, onClear]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@

import { useApi, fetchApiRef } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { UiProps } from '../uiPropTypes';
import { getErrorMessage } from './errorUtils';
import { useEvaluateTemplate } from './evaluateTemplate';
import { useRequestInit } from './useRequestInit';
import { useRetriggerEvaluate } from './useRetriggerEvaluate';
import { useDebounce } from 'react-use';
import { DEFAULT_DEBOUNCE_LIMIT } from '../widgets/constants';
import isEqual from 'lodash/isEqual';

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

const fetchUrl = uiProps['fetch:url'];
const skipErrorWhenDepsEmpty = uiProps['fetch:error:ignoreUnready'] === true;
const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true;
const evaluatedRequestInit = useRequestInit({
uiProps,
prefix: 'fetch',
Expand All @@ -67,6 +69,63 @@ export const useFetch = (
formData,
setError,
});
const prevRetriggerRef = useRef<(string | undefined)[] | undefined>(
retrigger,
);
const latestRetriggerRef = useRef<(string | undefined)[] | undefined>(
retrigger,
);
const requestIdRef = useRef(0);

useEffect(() => {
latestRetriggerRef.current = retrigger;
}, [retrigger]);

useEffect(() => {
if (!clearOnRetrigger) {
prevRetriggerRef.current = retrigger;
return;
}

if (!retrigger) {
prevRetriggerRef.current = retrigger;
return;
}

const prev = prevRetriggerRef.current;
if (prev && !isEqual(prev, retrigger)) {
setData(undefined);
setError(undefined);
}

prevRetriggerRef.current = retrigger;
}, [clearOnRetrigger, retrigger]);

const hasFetchInputs =
!!fetchUrl && !!evaluatedFetchUrl && !!evaluatedRequestInit && !!retrigger;

// Set loading immediately on dependency changes so UI shows a spinner during debounce.
useEffect(() => {
if (!hasFetchInputs) {
setLoading(false);
return;
}

if (!areRetriggerDependenciesSatisfied(retrigger)) {
setLoading(false);
return;
}

// Mark loading immediately when a retrigger change is detected so widgets
// can show the spinner during the debounce window before the fetch starts.
setLoading(true);
}, [
hasFetchInputs,
fetchUrl,
evaluatedFetchUrl,
evaluatedRequestInit,
retrigger,
]);

useDebounce(
() => {
Expand All @@ -81,6 +140,8 @@ export const useFetch = (
}

const fetchData = async () => {
const requestId = ++requestIdRef.current;
const retriggerSnapshot = retrigger;
try {
setError(undefined);
if (typeof evaluatedFetchUrl !== 'string') {
Expand Down Expand Up @@ -116,14 +177,23 @@ export const useFetch = (
throw new Error('JSON object expected');
}

setData(responseData);
if (
requestId === requestIdRef.current &&
isEqual(retriggerSnapshot, latestRetriggerRef.current)
) {
setData(responseData);
}
} catch (err) {
const prefix = `Failed to fetch data for url ${fetchUrl}.`;
// eslint-disable-next-line no-console
console.error(prefix, err);
setError(getErrorMessage(prefix, err));
if (requestId === requestIdRef.current) {
setError(getErrorMessage(prefix, err));
}
} finally {
setLoading(false);
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
};
fetchData();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,32 @@ export const useFetchAndEvaluate = (
const [error, setError] = useState<string>();
const [loading, setLoading] = React.useState(true);
const [resultText, setResultText] = React.useState<string>();

// Keep spinner visible during the debounce window after fetch/retrigger changes.
useEffect(() => {
if (!hasRetrigger || !retrigger || waitingForRetrigger) {
return;
}

if (fetchError) {
setLoading(false);
return;
}

// Show spinner immediately on retrigger changes and after data updates,
// even before the debounced evaluation completes.
if (retriggerSatisfied || fetchLoading || data !== undefined) {
setLoading(true);
}
}, [
hasRetrigger,
retrigger,
retriggerSatisfied,
waitingForRetrigger,
fetchError,
fetchLoading,
data,
]);
useDebounce(
() => {
const evaluate = async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
applySelectorArray,
resolveDropdownDefault,
useProcessingState,
useClearOnRetrigger,
} from '../utils';
import { UiProps } from '../uiPropTypes';
import { ErrorText } from './ErrorText';
Expand Down Expand Up @@ -79,6 +80,7 @@ export const ActiveDropdown: Widget<
const hasStaticDefault = typeof staticDefault === 'string';
const staticDefaultValue = hasStaticDefault ? staticDefault : undefined;
const skipInitialValue = uiProps['fetch:skipInitialValue'] === true;
const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true;

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

const handleClear = useCallback(() => {
handleChange('', false);
}, [handleChange]);

useClearOnRetrigger({
enabled: clearOnRetrigger,
retrigger,
onClear: handleClear,
});

// Set default value from fetched options
// Priority: selector default (if valid option) > static default (if valid) > first fetched option
// Note: Static defaults are applied at form initialization level (in OrchestratorForm)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import {
KeyboardEvent,
SyntheticEvent,
useCallback,
useEffect,
useMemo,
useState,
Expand All @@ -41,6 +42,7 @@ import {
useFetch,
useRetriggerEvaluate,
useProcessingState,
useClearOnRetrigger,
} from '../utils';
import { UiProps } from '../uiPropTypes';
import { ErrorText } from './ErrorText';
Expand Down Expand Up @@ -85,6 +87,7 @@ export const ActiveMultiSelect: Widget<
const allowNewItems = uiProps['ui:allowNewItems'] === true;
const staticDefault = uiProps['fetch:response:default'];
const skipInitialValue = uiProps['fetch:skipInitialValue'] === true;
const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true;
const staticDefaultValues = Array.isArray(staticDefault)
? (staticDefault as string[])
: undefined;
Expand Down Expand Up @@ -145,6 +148,17 @@ export const ActiveMultiSelect: Widget<
handleFetchEnded,
);

const handleClear = useCallback(() => {
setInProgressItem('');
onChange([]);
}, [onChange]);

useClearOnRetrigger({
enabled: clearOnRetrigger,
retrigger,
onClear: handleClear,
});

// Process fetch results
// Note: Static defaults are applied at form initialization level (in OrchestratorForm)
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
applySelectorArray,
applySelectorString,
useProcessingState,
useClearOnRetrigger,
} from '../utils';
import { ErrorText } from './ErrorText';
import { UiProps } from '../uiPropTypes';
Expand Down Expand Up @@ -73,6 +74,7 @@ export const ActiveTextInput: Widget<
const hasStaticDefault = typeof staticDefault === 'string';
const skipInitialValue = uiProps['fetch:skipInitialValue'] === true;
const hasFetchUrl = !!uiProps['fetch:url'];
const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true;

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

const handleClear = useCallback(() => {
handleChange('', false);
}, [handleChange]);

useClearOnRetrigger({
enabled: clearOnRetrigger,
retrigger,
onClear: handleClear,
});

// Process fetch results - only override if fetch returns a non-empty value
// Static defaults are applied at form initialization level (in OrchestratorForm)
useEffect(() => {
if (clearOnRetrigger && loading) {
return;
}

if (!data) {
return;
}
Expand Down Expand Up @@ -159,6 +175,8 @@ export const ActiveTextInput: Widget<
isChangedByUser,
skipInitialValue,
wrapProcessing,
clearOnRetrigger,
loading,
]);

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