diff --git a/workspaces/orchestrator/.changeset/fetch-error-skip-and-response-default.md b/workspaces/orchestrator/.changeset/fetch-error-skip-and-response-default.md new file mode 100644 index 0000000000..1410aaecbd --- /dev/null +++ b/workspaces/orchestrator/.changeset/fetch-error-skip-and-response-default.md @@ -0,0 +1,40 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': minor +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': minor +--- + +Add fetch:error:skip and fetch:response:default options for form widgets + +**Feature 1: fetch:error:skip** + +When using widgets with `fetch:retrigger` dependencies, the initial fetch often fails because dependent fields don't have values yet. This results in HTTP errors being displayed during initial load. + +- Add `fetch:error:skip` option to suppress fetch error display until all `fetch:retrigger` dependencies have non-empty values +- Errors are only suppressed when dependencies are empty; once filled, real errors are shown +- Supported by: ActiveTextInput, ActiveDropdown, ActiveMultiSelect, SchemaUpdater + +**Feature 2: fetch:response:default** + +Widgets previously required `fetch:response:value` for defaults, meaning fetch must succeed. This adds static fallback defaults. + +- Add `fetch:response:default` option for static default values applied immediately on form initialization +- Defaults are applied at form initialization level in `OrchestratorForm`, ensuring controlled components work correctly +- Static defaults act as fallback when fetch fails, hasn't completed, or returns empty +- Fetched values only override defaults when non-empty +- Supported by: ActiveTextInput (string), ActiveDropdown (string), ActiveMultiSelect (string[]) + +**Usage Examples:** + +```json +{ + "action": { + "ui:widget": "ActiveTextInput", + "ui:props": { + "fetch:url": "...", + "fetch:retrigger": ["current.appName"], + "fetch:error:skip": true, + "fetch:response:default": "create" + } + } +} +``` diff --git a/workspaces/orchestrator/docs/orchestratorFormWidgets.md b/workspaces/orchestrator/docs/orchestratorFormWidgets.md index 7e7aa17886..cabee7f6db 100644 --- a/workspaces/orchestrator/docs/orchestratorFormWidgets.md +++ b/workspaces/orchestrator/docs/orchestratorFormWidgets.md @@ -215,9 +215,10 @@ The widget supports following `ui:props`: - fetch:headers - fetch:method - fetch:body +- fetch:retrigger +- fetch:error:skip - fetch:response:value - fetch:response:mandatory -- fetch:retrigger [Check mode details](#content-of-uiprops) @@ -298,7 +299,9 @@ The widget supports following `ui:props`: - fetch:method - fetch:body - fetch:retrigger +- fetch:error:skip - fetch:response:value +- fetch:response:default - fetch:response:autocomplete - validate:url - validate:method @@ -336,7 +339,9 @@ The widget supports following `ui:props`: - fetch:method - fetch:body - fetch:retrigger +- fetch:error:skip - fetch:response:value +- fetch:response:default - fetch:response:label - validate:url - validate:method @@ -383,9 +388,11 @@ The widget supports following `ui:props`: - fetch:method - fetch:body - fetch:retrigger +- fetch:error:skip - fetch:response:autocomplete - fetch:response:mandatory - fetch:response:value +- fetch:response:default - validate:url - validate:method - validate:headers @@ -519,6 +526,8 @@ 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:error:skip | 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:response:default | A static default value that is applied immediately when the widget mounts, before any fetch completes. Acts as a fallback when fetch fails or has not completed yet. Gets overridden by `fetch:response:value` once fetch succeeds. For ActiveTextInput/ActiveDropdown use a string, for ActiveMultiSelect use a string array. | `"create"` (string) or `["tag1", "tag2"]` (array) | | fetch:response:\[YOUR_KEY\] | A JSONata selector (string) or object value for extracting data from the fetch response. There can be any count of the \[YOUR_KEY\] properties, so a single fetch response can be used to retrieve multiple records. Supports both string selectors and object type values. | Account.Order.Product.ProductID | | fetch:response:label | Special (well-known) case of the fetch:response:\[YOUR_KEY\] . Used i.e. by the ActiveDropdown to label the items. | | | fetch:response:value | Like fetch:response:label, but gives i.e. ActiveDropdown item values (not visible to the user but actually used as the field value) | | diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorForm.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorForm.tsx index f37eda079c..ede2cbb12e 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorForm.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorForm.tsx @@ -26,6 +26,7 @@ import get from 'lodash/get'; import { OrchestratorFormContextProps } from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api'; import { TranslationFunction } from '../hooks/useTranslation'; +import extractStaticDefaults from '../utils/extractStaticDefaults'; import generateUiSchema from '../utils/generateUiSchema'; import { pruneFormData } from '../utils/pruneFormData'; import { StepperContextProvider } from '../utils/StepperContext'; @@ -110,10 +111,15 @@ const OrchestratorForm = ({ setAuthTokenDescriptors, t, }: OrchestratorFormProps) => { + // Extract static defaults from fetch:response:default in schema and merge with initialFormData + // This ensures defaults are available before widgets render + const initialDataWithDefaults = useMemo(() => { + const base = initialFormData ? structuredClone(initialFormData) : {}; + return extractStaticDefaults(rawSchema, base); + }, [rawSchema, initialFormData]); + // make the form a controlled component so the state will remain when moving between steps. see https://rjsf-team.github.io/react-jsonschema-form/docs/quickstart#controlled-component - const [formData, setFormData] = useState( - initialFormData ? () => structuredClone(initialFormData) : {}, - ); + const [formData, setFormData] = useState(initialDataWithDefaults); const [changedByUserMap, setChangedByUserMap] = useState< Record diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.ts new file mode 100644 index 0000000000..c05921beb3 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.ts @@ -0,0 +1,185 @@ +/* + * 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 { JsonObject, JsonValue } from '@backstage/types'; + +import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; +import get from 'lodash/get'; +import set from 'lodash/set'; + +/** + * Extracts static default values from fetch:response:default properties in the schema. + * These values are applied to formData before widgets render, ensuring defaults + * are available immediately without waiting for fetch operations. + * + * @param schema - The JSON Schema containing ui:props with fetch:response:default + * @param existingFormData - Existing form data to preserve (won't be overwritten) + * @returns An object containing the extracted default values merged with existing data + */ +export function extractStaticDefaults( + schema: JSONSchema7, + existingFormData: JsonObject = {}, +): JsonObject { + const defaults: JsonObject = {}; + const rootSchema = schema; + + const getSchemaDefinition = (ref: string): JSONSchema7Definition => { + const refPath = ref.replace(/^#\//, '').replace(/\//g, '.'); + return get(rootSchema, refPath); + }; + + const processSchema = ( + curSchema: JSONSchema7Definition, + path: string, + ): void => { + if (typeof curSchema === 'boolean') { + return; + } + + // Handle $ref + if (curSchema.$ref) { + const resolved = getSchemaDefinition(curSchema.$ref); + if (resolved) { + processSchema(resolved, path); + } + return; + } + + // Extract fetch:response:default from ui:props + const uiProps = (curSchema as Record)['ui:props']; + if (uiProps && typeof uiProps === 'object') { + const staticDefault = (uiProps as Record)[ + 'fetch:response:default' + ]; + if (staticDefault !== undefined) { + // Only set if not already in existing form data + const existingValue = get(existingFormData, path); + if (existingValue === undefined || existingValue === null) { + set(defaults, path, staticDefault); + } + } + } + + // Recursively process nested objects (inline processProperties) + if (curSchema.properties) { + for (const [key, propSchema] of Object.entries(curSchema.properties)) { + const propPath = path ? `${path}.${key}` : key; + processSchema(propSchema, propPath); + } + } + + // Handle arrays + if (curSchema.items) { + if (Array.isArray(curSchema.items)) { + curSchema.items.forEach((itemSchema, index) => { + processSchema(itemSchema, `${path}[${index}]`); + }); + } else if (typeof curSchema.items === 'object') { + // For array items schema, we don't process individual items + // as they would need indices which don't exist yet + } + } + + // Handle composed schemas (allOf, oneOf, anyOf) + if (curSchema.allOf) { + curSchema.allOf.forEach(subSchema => processSchema(subSchema, path)); + } + if (curSchema.oneOf) { + curSchema.oneOf.forEach(subSchema => processSchema(subSchema, path)); + } + if (curSchema.anyOf) { + curSchema.anyOf.forEach(subSchema => processSchema(subSchema, path)); + } + + // Handle if/then/else conditionals + if (curSchema.if) { + processSchema(curSchema.if, path); + } + if (curSchema.then) { + processSchema(curSchema.then, path); + } + if (curSchema.else) { + processSchema(curSchema.else, path); + } + + // Handle dependencies + if (curSchema.dependencies) { + Object.values(curSchema.dependencies).forEach(depValue => { + if (typeof depValue === 'object' && !Array.isArray(depValue)) { + processSchema(depValue as JSONSchema7, path); + } + }); + } + }; + + // Start processing from root + processSchema(schema, ''); + + // Merge defaults with existing form data (existing data takes precedence) + return mergeDefaults(defaults, existingFormData); +} + +/** + * Recursively merges default values with existing form data. + * Existing values take precedence over defaults. + */ +function mergeDefaults(defaults: JsonObject, existing: JsonObject): JsonObject { + const result: JsonObject = {}; + + // First, add all defaults + for (const [key, value] of Object.entries(defaults)) { + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof existing[key] === 'object' && + existing[key] !== null && + !Array.isArray(existing[key]) + ) { + // Recursively merge nested objects + result[key] = mergeDefaults( + value as JsonObject, + existing[key] as JsonObject, + ); + } else { + result[key] = value as JsonValue; + } + } + + // Then, add/override with existing values + for (const [key, value] of Object.entries(existing)) { + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof result[key] === 'object' && + result[key] !== null && + !Array.isArray(result[key]) + ) { + // Already merged above, but ensure existing nested values override + result[key] = mergeDefaults( + result[key] as JsonObject, + value as JsonObject, + ); + } else { + result[key] = value as JsonValue; + } + } + + return result; +} + +export default extractStaticDefaults; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts index e85ae9199a..86e632e5b8 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:error:skip'?: boolean; [key: `fetch:response:${string}`]: JsonValue; }; 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 ebe6f2055b..52413216f8 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts @@ -25,6 +25,23 @@ import { useRetriggerEvaluate } from './useRetriggerEvaluate'; import { useDebounce } from 'react-use'; import { DEFAULT_DEBOUNCE_LIMIT } from '../widgets/constants'; +/** + * Checks if all fetch:retrigger dependencies have non-empty values. + * Used to determine if a fetch error should be shown or suppressed. + */ +const areRetriggerDependenciesSatisfied = ( + retrigger: ReturnType, +): boolean => { + // If no retrigger conditions, dependencies are satisfied + if (!retrigger || retrigger.length === 0) { + return true; + } + // All values must be non-empty + return retrigger.every( + value => value !== undefined && value !== null && value !== '', + ); +}; + export const useFetch = ( formData: JsonObject, uiProps: UiProps, @@ -37,6 +54,7 @@ export const useFetch = ( const [data, setData] = useState(); const fetchUrl = uiProps['fetch:url']; + const skipErrorWhenDepsEmpty = uiProps['fetch:error:skip'] === true; const evaluatedRequestInit = useRequestInit({ uiProps, prefix: 'fetch', @@ -121,5 +139,12 @@ export const useFetch = ( ], ); - return { data, error, loading }; + // If fetch:error:skip is enabled and retrigger dependencies are not satisfied, + // suppress the error. This handles the case where initial fetch fails because + // dependent fields don't have values yet. + const shouldSkipError = + skipErrorWhenDepsEmpty && !areRetriggerDependenciesSatisfied(retrigger); + const effectiveError = shouldSkipError ? undefined : error; + + return { data, error: effectiveError, loading }; }; 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 f473a38b9d..9208a11390 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx @@ -74,6 +74,9 @@ export const ActiveDropdown: Widget< const labelSelector = uiProps['fetch:response:label']?.toString(); const valueSelector = uiProps['fetch:response:value']?.toString(); + const staticDefault = uiProps['fetch:response:default']; + const staticDefaultValue = + typeof staticDefault === 'string' ? staticDefault : undefined; const [localError, setLocalError] = useState( !labelSelector || !valueSelector @@ -138,21 +141,43 @@ export const ActiveDropdown: Widget< [onChange, id, setIsChangedByUser], ); - // set default value to the first one + // Set default value from fetched options + // Priority: static default (if valid option) > first fetched option + // Note: Static defaults are applied at form initialization level (in OrchestratorForm) useEffect(() => { if (!isChangedByUser && !value && values && values.length > 0) { - handleChange(values[0], false); + // If static default is provided and is a valid option, use it + if (staticDefaultValue && values.includes(staticDefaultValue)) { + handleChange(staticDefaultValue, false); + } else { + // Otherwise use the first fetched value + handleChange(values[0], false); + } } - }, [handleChange, value, values, isChangedByUser]); + }, [handleChange, value, values, isChangedByUser, staticDefaultValue]); if (localError ?? error) { return ; } - if (completeLoading || !labels || !values) { + // Compute display options: use fetched options, or fall back to static default + const hasOptions = labels && labels.length > 0 && values && values.length > 0; + const hasFallbackDefault = !hasOptions && staticDefaultValue; + + // Show loading only if we have no options AND no fallback default + if (completeLoading && !hasFallbackDefault) { + return ; + } + + // If still loading but no options yet and no fallback, show spinner + if (!hasOptions && !hasFallbackDefault) { return ; } + // Use fetched options or fallback to static default as single option + const displayLabels = hasOptions ? labels : [staticDefaultValue!]; + const displayValues = hasOptions ? values : [staticDefaultValue!]; + return ( {label} @@ -160,7 +185,7 @@ export const ActiveDropdown: Widget< labelId={labelId} id={id} data-testid={id} - value={value} + value={value ?? ''} label={label} disabled={isReadOnly} onChange={event => handleChange(event.target.value as string, true)} @@ -168,14 +193,14 @@ export const ActiveDropdown: Widget< PaperProps: { sx: { maxHeight: '20rem' } }, }} > - {labels.map((itemLabel, idx) => ( + {displayLabels.map((itemLabel, idx) => ( {itemLabel} 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 ebd5c1a3cf..abbceaa187 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx @@ -77,6 +77,11 @@ export const ActiveMultiSelect: Widget< const mandatorySelector = uiProps['fetch:response:mandatory']?.toString(); const defaultValueSelector = uiProps['fetch:response:value']?.toString(); const allowNewItems = uiProps['ui:allowNewItems'] === true; + const staticDefault = uiProps['fetch:response:default']; + const staticDefaultValues = Array.isArray(staticDefault) + ? (staticDefault as string[]) + : undefined; + const [localError] = useState( autocompleteSelector ? undefined @@ -87,12 +92,32 @@ export const ActiveMultiSelect: Widget< const [autocompleteOptions, setAutocompleteOptions] = useState(); const [mandatoryValues, setMandatoryValues] = useState(); + // Compute all options: fetched options + in-progress item + static defaults as fallback const allOptions: string[] = useMemo(() => { + const baseOptions = autocompleteOptions ?? []; + const hasOptions = baseOptions.length > 0; + + // Start with fetched options or static defaults as fallback + let options = hasOptions ? baseOptions : (staticDefaultValues ?? []); + + // Add in-progress item if allowed if (allowNewItems && inProgressItem) { - return [...new Set([inProgressItem, ...(autocompleteOptions ?? [])])]; + options = [...new Set([inProgressItem, ...options])]; + } + + // Also include current values so they appear as options + if (value && value.length > 0) { + options = [...new Set([...options, ...value])]; } - return autocompleteOptions || []; - }, [inProgressItem, autocompleteOptions, allowNewItems]); + + return options; + }, [ + inProgressItem, + autocompleteOptions, + allowNewItems, + staticDefaultValues, + value, + ]); const handleFetchStarted = formContext?.handleFetchStarted; const handleFetchEnded = formContext?.handleFetchEnded; @@ -113,6 +138,8 @@ export const ActiveMultiSelect: Widget< handleFetchEnded, ); + // Process fetch results + // Note: Static defaults are applied at form initialization level (in OrchestratorForm) useEffect(() => { if (!data) { return; @@ -208,11 +235,17 @@ export const ActiveMultiSelect: Widget< return ; } - if (completeLoading) { + // Show spinner only if loading AND we don't have static defaults to show + const hasStaticDefaults = + staticDefaultValues && staticDefaultValues.length > 0; + if (completeLoading && !hasStaticDefaults) { return ; } - if (autocompleteOptions) { + // Render if we have fetched options, static defaults, or current values + const hasOptionsToShow = + allOptions.length > 0 || autocompleteOptions !== undefined; + if (hasOptionsToShow) { return ( 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 ff219897da..bd97a90d60 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -69,10 +69,16 @@ export const ActiveTextInput: Widget< const defaultValueSelector = uiProps['fetch:response:value']?.toString(); const autocompleteSelector = uiProps['fetch:response:autocomplete']?.toString(); + const staticDefault = uiProps['fetch:response:default']; + const staticDefaultValue = + typeof staticDefault === 'string' ? staticDefault : undefined; + const hasFetchUrl = !!uiProps['fetch:url']; + // 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. const [localError] = useState( - !defaultValueSelector - ? `The fetch:response:value needs to be set for ${props.id}.` + hasFetchUrl && !defaultValueSelector && !staticDefaultValue + ? `When fetch:url is configured, either fetch:response:value or fetch:response:default should be set for ${props.id}.` : undefined, ); const [autocompleteOptions, setAutocompleteOptions] = useState(); @@ -107,27 +113,30 @@ export const ActiveTextInput: Widget< [onChange, id, setIsChangedByUser], ); + // Process fetch results - only override if fetch returns a non-empty value + // Static defaults are applied at form initialization level (in OrchestratorForm) useEffect(() => { - if (!data || !defaultValueSelector) { + if (!data) { return; } const doItAsync = async () => { await wrapProcessing(async () => { - if (!isChangedByUser) { - // loading default so replace the value unless the user touched the field - const defaultValue = await applySelectorString( + // Only apply fetched value if user hasn't changed the field + if (!isChangedByUser && defaultValueSelector) { + const fetchedValue = await applySelectorString( data, defaultValueSelector, ); + // Only override if fetch returns a non-empty value + // This ensures static default remains as fallback when fetch returns empty if ( - value !== defaultValue && - defaultValue && - defaultValue !== null && - defaultValue !== 'null' + fetchedValue && + fetchedValue !== 'null' && + value !== fetchedValue ) { - handleChange(defaultValue, false); + handleChange(fetchedValue, false); } } @@ -157,7 +166,9 @@ export const ActiveTextInput: Widget< return ; } - if (completeLoading) { + // Show loading only if we don't have a static default value to display + // This ensures the default is shown instantly while fetch happens in background + if (completeLoading && !staticDefaultValue) { return ; }