Skip to content
Open
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,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"
}
}
}
```
11 changes: 10 additions & 1 deletion workspaces/orchestrator/docs/orchestratorFormWidgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<JsonObject>(
initialFormData ? () => structuredClone(initialFormData) : {},
);
const [formData, setFormData] = useState<JsonObject>(initialDataWithDefaults);

const [changedByUserMap, setChangedByUserMap] = useState<
Record<string, boolean>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)['ui:props'];
if (uiProps && typeof uiProps === 'object') {
const staticDefault = (uiProps as Record<string, unknown>)[
'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;
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:error:skip'?: boolean;
[key: `fetch:response:${string}`]: JsonValue;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useRetriggerEvaluate>,
): 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,
Expand All @@ -37,6 +54,7 @@ export const useFetch = (
const [data, setData] = useState<JsonObject>();

const fetchUrl = uiProps['fetch:url'];
const skipErrorWhenDepsEmpty = uiProps['fetch:error:skip'] === true;
const evaluatedRequestInit = useRequestInit({
uiProps,
prefix: 'fetch',
Expand Down Expand Up @@ -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 };
};
Loading