diff --git a/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md b/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md new file mode 100644 index 0000000000..8f53b66310 --- /dev/null +++ b/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md @@ -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. diff --git a/workspaces/orchestrator/docs/orchestratorFormWidgets.md b/workspaces/orchestrator/docs/orchestratorFormWidgets.md index ba6b6ca5d2..248c4a31bb 100644 --- a/workspaces/orchestrator/docs/orchestratorFormWidgets.md +++ b/workspaces/orchestrator/docs/orchestratorFormWidgets.md @@ -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` @@ -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`) | diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts index b74a2d5bf0..385f932d3f 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts @@ -25,6 +25,7 @@ export type UiProps = { 'fetch:headers'?: Record; 'fetch:body'?: Record; 'fetch:retrigger'?: string[]; + 'fetch:clearOnRetrigger'?: boolean; 'fetch:error:ignoreUnready'?: boolean; 'fetch:error:silent'?: boolean; 'fetch:skipInitialValue'?: boolean; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts index 5899c1b647..1984e8ee0b 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts @@ -24,3 +24,4 @@ export * from './useFetchAndEvaluate'; export * from './applySelector'; export * from './useProcessingState'; export * from './resolveDropdownDefault'; +export * from './useClearOnRetrigger'; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts new file mode 100644 index 0000000000..ef961535d4 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts @@ -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]); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts index 8d71e06250..8d16a8fb81 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts @@ -16,7 +16,7 @@ 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'; @@ -24,6 +24,7 @@ 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. @@ -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', @@ -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( () => { @@ -81,6 +140,8 @@ export const useFetch = ( } const fetchData = async () => { + const requestId = ++requestIdRef.current; + const retriggerSnapshot = retrigger; try { setError(undefined); if (typeof evaluatedFetchUrl !== 'string') { @@ -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(); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts index bf7f0681ac..9e915ff27b 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts @@ -59,6 +59,32 @@ export const useFetchAndEvaluate = ( const [error, setError] = useState(); const [loading, setLoading] = React.useState(true); const [resultText, setResultText] = React.useState(); + + // 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 () => { diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx index fb41b98a71..200c02e259 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx @@ -34,6 +34,7 @@ import { applySelectorArray, resolveDropdownDefault, useProcessingState, + useClearOnRetrigger, } from '../utils'; import { UiProps } from '../uiPropTypes'; import { ErrorText } from './ErrorText'; @@ -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( !labelSelector || !valueSelector @@ -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) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx index 7653161bdd..5f505a4e26 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx @@ -16,6 +16,7 @@ import { KeyboardEvent, SyntheticEvent, + useCallback, useEffect, useMemo, useState, @@ -41,6 +42,7 @@ import { useFetch, useRetriggerEvaluate, useProcessingState, + useClearOnRetrigger, } from '../utils'; import { UiProps } from '../uiPropTypes'; import { ErrorText } from './ErrorText'; @@ -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; @@ -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(() => { diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx index 5af03f7542..8fab03bfa3 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -37,6 +37,7 @@ import { applySelectorArray, applySelectorString, useProcessingState, + useClearOnRetrigger, } from '../utils'; import { ErrorText } from './ErrorText'; import { UiProps } from '../uiPropTypes'; @@ -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. @@ -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; } @@ -159,6 +175,8 @@ export const ActiveTextInput: Widget< isChangedByUser, skipInitialValue, wrapProcessing, + clearOnRetrigger, + loading, ]); const shouldShowFetchError = uiProps['fetch:error:silent'] !== true;