From 210d513db347a6089e5f964eb62d363c10b32d91 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Mon, 24 Aug 2026 14:04:34 +0530 Subject: [PATCH 1/3] feat(orchestrator-form-widgets): add ActiveBoolean widget for dynamic boolean form fields Add ActiveBoolean widget that enables boolean fields in Orchestrator workflow forms to dynamically fetch values from external APIs using the same ui:props pattern as existing Active widgets. Features: - Supports fetch:url, fetch:response:value, fetch:response:default - Coerces string and number values to boolean - Supports fetch:retrigger and fetch:clearOnRetrigger - Tracks user changes to prevent overwriting manual edits - Includes comprehensive unit tests and documentation Co-Authored-By: Claude Sonnet 4.5 --- .../.changeset/active-boolean-widget.md | 5 + .../docs/orchestratorFormWidgets.md | 66 +++++ .../src/FormDecoratorContent.tsx | 2 + .../src/widgets/ActiveBoolean.test.tsx | 273 ++++++++++++++++++ .../src/widgets/ActiveBoolean.tsx | 224 ++++++++++++++ .../src/widgets/index.ts | 1 + 6 files changed, 571 insertions(+) create mode 100644 workspaces/orchestrator/.changeset/active-boolean-widget.md create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.test.tsx create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx diff --git a/workspaces/orchestrator/.changeset/active-boolean-widget.md b/workspaces/orchestrator/.changeset/active-boolean-widget.md new file mode 100644 index 00000000000..3112c98a0da --- /dev/null +++ b/workspaces/orchestrator/.changeset/active-boolean-widget.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': minor +--- + +Add ActiveBoolean widget for dynamic boolean form fields with fetch capabilities diff --git a/workspaces/orchestrator/docs/orchestratorFormWidgets.md b/workspaces/orchestrator/docs/orchestratorFormWidgets.md index 371060a8451..05696303b2e 100644 --- a/workspaces/orchestrator/docs/orchestratorFormWidgets.md +++ b/workspaces/orchestrator/docs/orchestratorFormWidgets.md @@ -41,6 +41,9 @@ Implementation of the HTTP endpoints is out of the scope of this library, they a - [ActiveMultiSelect widget](#activemultiselect-widget) - [ActiveMultiSelect Data Fetching and validation](#activemultiselect-data-fetching-and-validation) - [ActiveMultiSelect widget ui:props](#activemultiselect-widget-uiprops) + - [ActiveBoolean widget](#activeboolean-widget) + - [ActiveBoolean Data Fetching](#activeboolean-data-fetching) + - [ActiveBoolean widget ui:props](#activeboolean-widget-uiprops) - [ActiveText widget](#activetext-widget) - [ActiveText Data Fetching](#activetext-data-fetching) - [Dynamic Text Templating](#dynamic-text-templating) @@ -437,6 +440,69 @@ The widget supports following `ui:props`: [Check more details](#content-of-uiprops) +## ActiveBoolean widget + +Referenced as: `"ui:widget": "ActiveBoolean"`. + +A smart boolean component based on the [@mui/material/Checkbox](https://mui.com/material-ui/api/checkbox/) keeping look&feel with other RJSF-default fields. + +This widget enables boolean (checkbox) fields to dynamically fetch their values from external APIs and respond to form changes, following the same patterns as other Active widgets. + +### ActiveBoolean Data Fetching + +When instantiated, it loads (prefetch) the **default** value using a single HTTP call based on the `fetch:*` from the `ui:props`. + +Once fetched, the `fetch:response:value` selector is used to pick the default value. +This selector is expected to resolve into a boolean value, or a value that can be coerced to boolean: + +- Boolean values: `true`, `false` +- String values: `"true"`, `"false"`, `"1"`, `"0"` (case-insensitive) +- Numeric values: `1` (true), `0` (false), any non-zero number (true) + +The data are further re-fetched if the value of one of the `fetch:retrigger` referenced values is changed. +If the `fetch:retrigger` is omitted, the fetch is issued just once to preload the data. + +Because a checkbox's default value only applies when the field is initially unchecked, any changes to the returned value in subsequent requests are ignored if the user has already interacted with the field. +If you want to keep the field unchanged until the user interacts with it, set `fetch:skipInitialValue` to `true`. + +**Example:** + +```json +"enableFeature": { + "type": "boolean", + "title": "Enable Advanced Features", + "ui:widget": "ActiveBoolean", + "ui:props": { + "fetch:url": "https://api.example.com/feature-flags?tenant=$${{current.tenantId}}", + "fetch:response:value": "features.advanced.enabled", + "fetch:response:default": false, + "fetch:retrigger": ["current.tenantId"] + } +} +``` + +### ActiveBoolean widget ui:props + +The widget supports following `ui:props`: + +- fetch:url +- fetch:headers +- fetch:method +- fetch:body +- fetch:retrigger +- fetch:clearOnRetrigger +- fetch:retry:maxAttempts +- fetch:retry:delay +- fetch:retry:backoff +- fetch:retry:statusCodes +- fetch:error:ignoreUnready +- fetch:error:silent +- fetch:skipInitialValue +- fetch:response:value +- fetch:response:default + +[Check more details](#content-of-uiprops) + ## ActiveText widget Referenced as: `"ui:widget": "ActiveText"`. diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormDecoratorContent.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormDecoratorContent.tsx index 9d55019faa9..6719eed854d 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormDecoratorContent.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormDecoratorContent.tsx @@ -28,6 +28,7 @@ import { ActiveText, ActiveDropdown, ActiveMultiSelect, + ActiveBoolean, } from './widgets'; import { useGetExtraErrors, useGetExtraErrorsForField } from './utils'; @@ -44,6 +45,7 @@ const widgets = { ActiveText, ActiveDropdown, ActiveMultiSelect, + ActiveBoolean, }; const FormDecoratorContent = ({ diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.test.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.test.tsx new file mode 100644 index 00000000000..a9b8321ff0d --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.test.tsx @@ -0,0 +1,273 @@ +/* + * 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 { fireEvent, render, screen } from '@testing-library/react'; +import { ActiveBoolean } from './ActiveBoolean'; +import * as utils from '../utils'; + +jest.mock('../utils', () => { + const actual = jest.requireActual('../utils'); + return { + ...actual, + useTemplateUnitEvaluator: jest.fn(), + useRetriggerEvaluate: jest.fn(), + useFetch: jest.fn(), + useProcessingState: jest.fn(), + useClearOnRetrigger: jest.fn(), + }; +}); + +const mockedUseTemplateUnitEvaluator = + utils.useTemplateUnitEvaluator as jest.Mock; +const mockedUseRetriggerEvaluate = utils.useRetriggerEvaluate as jest.Mock; +const mockedUseFetch = utils.useFetch as jest.Mock; +const mockedUseProcessingState = utils.useProcessingState as jest.Mock; + +describe('ActiveBoolean', () => { + beforeEach(() => { + mockedUseTemplateUnitEvaluator.mockReturnValue(() => undefined); + mockedUseRetriggerEvaluate.mockReturnValue([]); + mockedUseFetch.mockReturnValue({ + data: undefined, + error: undefined, + loading: false, + }); + mockedUseProcessingState.mockReturnValue({ + completeLoading: false, + wrapProcessing: async (fn: () => Promise) => { + await fn(); + }, + }); + }); + + it('shows config error when fetch:url is provided without selectors', () => { + render( + {}} + onBlur={() => {}} + onFocus={() => {}} + formContext={ + { + formData: {}, + getIsChangedByUser: () => false, + setIsChangedByUser: () => {}, + } as any + } + rawErrors={[]} + registry={{} as any} + />, + ); + + expect(screen.getByTestId('ab-error-text')).toHaveTextContent( + 'fetch:response:value or fetch:response:default', + ); + }); + + it('shows spinner while complete loading and no static default', () => { + mockedUseProcessingState.mockReturnValue({ + completeLoading: true, + wrapProcessing: async (fn: () => Promise) => { + await fn(); + }, + }); + + const { container } = render( + {}} + onBlur={() => {}} + onFocus={() => {}} + formContext={ + { + formData: {}, + getIsChangedByUser: () => false, + setIsChangedByUser: () => {}, + } as any + } + rawErrors={[]} + registry={{} as any} + />, + ); + + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('marks field as user-changed and forwards checkbox value', () => { + const onChange = jest.fn(); + const setIsChangedByUser = jest.fn(); + + render( + {}} + onFocus={() => {}} + formContext={ + { + formData: {}, + getIsChangedByUser: () => false, + setIsChangedByUser, + } as any + } + rawErrors={[]} + registry={{} as any} + />, + ); + + const checkbox = screen.getByTestId('ab-checkbox'); + expect(checkbox).toBeInTheDocument(); + + fireEvent.click(checkbox); + + expect(setIsChangedByUser).toHaveBeenCalledWith('ab', true); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('renders unchecked checkbox for false value', () => { + render( + {}} + onBlur={() => {}} + onFocus={() => {}} + formContext={ + { + formData: {}, + getIsChangedByUser: () => false, + setIsChangedByUser: () => {}, + } as any + } + rawErrors={[]} + registry={{} as any} + />, + ); + + const checkbox = screen + .getByTestId('ab-checkbox') + .querySelector('input') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + }); + + it('renders checked checkbox for true value', () => { + render( + {}} + onBlur={() => {}} + onFocus={() => {}} + formContext={ + { + formData: {}, + getIsChangedByUser: () => false, + setIsChangedByUser: () => {}, + } as any + } + rawErrors={[]} + registry={{} as any} + />, + ); + + const checkbox = screen + .getByTestId('ab-checkbox') + .querySelector('input') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('disables checkbox when readonly is true', () => { + render( + {}} + onBlur={() => {}} + onFocus={() => {}} + formContext={ + { + formData: {}, + getIsChangedByUser: () => false, + setIsChangedByUser: () => {}, + } as any + } + rawErrors={[]} + registry={{} as any} + />, + ); + + const checkbox = screen + .getByTestId('ab-checkbox') + .querySelector('input') as HTMLInputElement; + expect(checkbox.disabled).toBe(true); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx new file mode 100644 index 00000000000..f2b682baef9 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx @@ -0,0 +1,224 @@ +/* + * 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 { useCallback, useEffect, useMemo, useState } from 'react'; +import { JsonObject } from '@backstage/types'; +import { Widget } from '@rjsf/utils'; +import { JSONSchema7 } from 'json-schema'; + +import CircularProgress from '@mui/material/CircularProgress'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; + +import { OrchestratorFormContextProps } from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api'; + +import { + useRetriggerEvaluate, + useTemplateUnitEvaluator, + useFetch, + applySelectorString, + useProcessingState, + useClearOnRetrigger, + evaluateFetchResponseSelectorTemplate, +} from '../utils'; +import { ErrorText } from './ErrorText'; +import { UiProps } from '../uiPropTypes'; + +const coerceToBoolean = (value: any): boolean | undefined => { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + const lower = value.toLowerCase().trim(); + if (lower === 'true' || lower === '1') { + return true; + } + if (lower === 'false' || lower === '0') { + return false; + } + } + if (typeof value === 'number') { + return value !== 0; + } + return undefined; +}; + +export const ActiveBoolean: Widget< + JsonObject, + JSONSchema7, + OrchestratorFormContextProps +> = props => { + const templateUnitEvaluator = useTemplateUnitEvaluator(); + + const { id, label, value, onChange, formContext } = props; + const formData = formContext?.formData; + const isChangedByUser = !!formContext?.getIsChangedByUser(id); + const setIsChangedByUser = formContext?.setIsChangedByUser; + + const uiProps = useMemo( + () => (props.options?.props ?? {}) as UiProps, + [props.options?.props], + ); + const isReadOnly = !!props?.schema.readOnly; + + const defaultValueSelector = uiProps['fetch:response:value']?.toString(); + const staticDefault = uiProps['fetch:response:default']; + const hasStaticDefault = + typeof staticDefault === 'boolean' || + staticDefault === 'true' || + staticDefault === 'false' || + staticDefault === '1' || + staticDefault === '0'; + 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 checkbox. + const [localError] = useState( + hasFetchUrl && !defaultValueSelector && !hasStaticDefault + ? `When fetch:url is configured, either fetch:response:value or fetch:response:default should be set for ${props.id}.` + : undefined, + ); + + const handleFetchStarted = formContext?.handleFetchStarted; + const handleFetchEnded = formContext?.handleFetchEnded; + + const retrigger = useRetriggerEvaluate( + templateUnitEvaluator, + formData, + /* This is safe retype, since proper checking of input value is done in the useRetriggerEvaluate() hook */ + uiProps['fetch:retrigger'] as string[], + ); + + const { data, error, loading } = useFetch( + formData ?? {}, + uiProps, + retrigger, + formContext?.onSamlSsoError, + ); + + // Track the complete loading state (fetch + processing) + const { completeLoading, wrapProcessing } = useProcessingState( + loading, + handleFetchStarted, + handleFetchEnded, + ); + + const handleChange = useCallback( + (changed: boolean, isByUser: boolean) => { + if (isByUser && setIsChangedByUser) { + // we must handle this change out of this component's state since the component can be (de)mounted on wizard transitions or by the SchemaUpdater + setIsChangedByUser(id, true); + } + onChange(changed); + }, + [onChange, id, setIsChangedByUser], + ); + + const handleClear = useCallback(() => { + handleChange(false, false); + }, [handleChange]); + + useClearOnRetrigger({ + enabled: clearOnRetrigger, + retrigger, + onClear: handleClear, + }); + + // Process fetch results - only override if fetch returns a valid boolean value + // Static defaults are applied at form initialization level (in OrchestratorForm) + useEffect(() => { + if (clearOnRetrigger && loading) { + return; + } + + if (!data) { + return; + } + + const doItAsync = async () => { + await wrapProcessing(async () => { + const fd = formData ?? {}; + // Only apply fetched value if user hasn't changed the field + if (!skipInitialValue && !isChangedByUser && defaultValueSelector) { + const resolvedSelector = await evaluateFetchResponseSelectorTemplate({ + template: defaultValueSelector, + key: 'fetch:response:value', + unitEvaluator: templateUnitEvaluator, + formData: fd, + responseData: data, + uiProps, + }); + const fetchedValue = await applySelectorString( + data, + resolvedSelector, + true, + ); + + const coercedValue = coerceToBoolean(fetchedValue); + if (coercedValue !== undefined && value !== coercedValue) { + handleChange(coercedValue, false); + } + } + }); + }; + + doItAsync(); + }, [ + defaultValueSelector, + data, + formData, + uiProps, + templateUnitEvaluator, + props.id, + value, + handleChange, + isChangedByUser, + skipInitialValue, + wrapProcessing, + clearOnRetrigger, + loading, + ]); + + const shouldShowFetchError = uiProps['fetch:error:silent'] !== true; + const displayError = localError ?? (shouldShowFetchError ? error : undefined); + if (displayError) { + return ; + } + + // 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 && !hasStaticDefault) { + return ; + } + + return ( + + handleChange(event.target.checked, true)} + disabled={isReadOnly} + data-testid={`${id}-checkbox`} + /> + } + label={label} + /> + + ); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/index.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/index.ts index 97483a93d60..1378b0f2e3f 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/index.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/index.ts @@ -18,3 +18,4 @@ export * from './ActiveTextInput'; export * from './ActiveText'; export * from './ActiveDropdown'; export * from './ActiveMultiSelect'; +export * from './ActiveBoolean'; From 47f01b6dc5f643808532c3379f8ddf3457c12175 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Mon, 24 Aug 2026 14:13:06 +0530 Subject: [PATCH 2/3] refactor: reduce code duplication in ActiveBoolean widget Co-Authored-By: Claude Sonnet 4.5 --- .../src/widgets/ActiveBoolean.tsx | 106 ++++++------------ 1 file changed, 37 insertions(+), 69 deletions(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx index f2b682baef9..075b481aa01 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx @@ -76,31 +76,20 @@ export const ActiveBoolean: Widget< const defaultValueSelector = uiProps['fetch:response:value']?.toString(); const staticDefault = uiProps['fetch:response:default']; - const hasStaticDefault = - typeof staticDefault === 'boolean' || - staticDefault === 'true' || - staticDefault === 'false' || - staticDefault === '1' || - staticDefault === '0'; + const hasStaticDefault = staticDefault !== undefined; 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 checkbox. const [localError] = useState( hasFetchUrl && !defaultValueSelector && !hasStaticDefault ? `When fetch:url is configured, either fetch:response:value or fetch:response:default should be set for ${props.id}.` : undefined, ); - const handleFetchStarted = formContext?.handleFetchStarted; - const handleFetchEnded = formContext?.handleFetchEnded; - const retrigger = useRetriggerEvaluate( templateUnitEvaluator, formData, - /* This is safe retype, since proper checking of input value is done in the useRetriggerEvaluate() hook */ uiProps['fetch:retrigger'] as string[], ); @@ -111,17 +100,15 @@ export const ActiveBoolean: Widget< formContext?.onSamlSsoError, ); - // Track the complete loading state (fetch + processing) const { completeLoading, wrapProcessing } = useProcessingState( loading, - handleFetchStarted, - handleFetchEnded, + formContext?.handleFetchStarted, + formContext?.handleFetchEnded, ); const handleChange = useCallback( (changed: boolean, isByUser: boolean) => { if (isByUser && setIsChangedByUser) { - // we must handle this change out of this component's state since the component can be (de)mounted on wizard transitions or by the SchemaUpdater setIsChangedByUser(id, true); } onChange(changed); @@ -129,9 +116,10 @@ export const ActiveBoolean: Widget< [onChange, id, setIsChangedByUser], ); - const handleClear = useCallback(() => { - handleChange(false, false); - }, [handleChange]); + const handleClear = useCallback( + () => handleChange(false, false), + [handleChange], + ); useClearOnRetrigger({ enabled: clearOnRetrigger, @@ -139,69 +127,49 @@ export const ActiveBoolean: Widget< onClear: handleClear, }); - // Process fetch results - only override if fetch returns a valid boolean value - // Static defaults are applied at form initialization level (in OrchestratorForm) useEffect(() => { - if (clearOnRetrigger && loading) { - return; - } - - if (!data) { - return; - } - - const doItAsync = async () => { - await wrapProcessing(async () => { - const fd = formData ?? {}; - // Only apply fetched value if user hasn't changed the field - if (!skipInitialValue && !isChangedByUser && defaultValueSelector) { - const resolvedSelector = await evaluateFetchResponseSelectorTemplate({ - template: defaultValueSelector, - key: 'fetch:response:value', - unitEvaluator: templateUnitEvaluator, - formData: fd, - responseData: data, - uiProps, - }); - const fetchedValue = await applySelectorString( - data, - resolvedSelector, - true, - ); - - const coercedValue = coerceToBoolean(fetchedValue); - if (coercedValue !== undefined && value !== coercedValue) { - handleChange(coercedValue, false); - } + if (!data || (clearOnRetrigger && loading)) return; + + if (!skipInitialValue && !isChangedByUser && defaultValueSelector) { + wrapProcessing(async () => { + const resolvedSelector = await evaluateFetchResponseSelectorTemplate({ + template: defaultValueSelector, + key: 'fetch:response:value', + unitEvaluator: templateUnitEvaluator, + formData: formData ?? {}, + responseData: data, + uiProps, + }); + const fetchedValue = await applySelectorString( + data, + resolvedSelector, + true, + ); + const coercedValue = coerceToBoolean(fetchedValue); + if (coercedValue !== undefined && value !== coercedValue) { + handleChange(coercedValue, false); } }); - }; - - doItAsync(); + } }, [ - defaultValueSelector, data, + loading, + clearOnRetrigger, + skipInitialValue, + isChangedByUser, + defaultValueSelector, + wrapProcessing, + templateUnitEvaluator, formData, uiProps, - templateUnitEvaluator, - props.id, value, handleChange, - isChangedByUser, - skipInitialValue, - wrapProcessing, - clearOnRetrigger, - loading, ]); - const shouldShowFetchError = uiProps['fetch:error:silent'] !== true; - const displayError = localError ?? (shouldShowFetchError ? error : undefined); - if (displayError) { - return ; + if (localError || (error && uiProps['fetch:error:silent'] !== true)) { + return ; } - // 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 && !hasStaticDefault) { return ; } From 8d61aff29e11ce5977c03c85f9f028171c58a239 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Tue, 1 Sep 2026 12:51:59 +0530 Subject: [PATCH 3/3] fix: coerce fetch:response:default to boolean Apply reviewer suggestion to coerce static default values. Now fetch:response:default: "true" works correctly. Co-Authored-By: Claude Sonnet 4.5 --- .../src/widgets/ActiveBoolean.test.tsx | 46 +++++++++++++++++++ .../src/widgets/ActiveBoolean.tsx | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.test.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.test.tsx index a9b8321ff0d..8b5a30c01d2 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.test.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.test.tsx @@ -270,4 +270,50 @@ describe('ActiveBoolean', () => { .querySelector('input') as HTMLInputElement; expect(checkbox.disabled).toBe(true); }); + + it('coerces string default value to boolean', () => { + mockedUseProcessingState.mockReturnValue({ + completeLoading: true, + wrapProcessing: async (fn: () => Promise) => { + await fn(); + }, + }); + + render( + {}} + onBlur={() => {}} + onFocus={() => {}} + formContext={ + { + formData: {}, + getIsChangedByUser: () => false, + setIsChangedByUser: () => {}, + } as any + } + rawErrors={[]} + registry={{} as any} + />, + ); + + const checkbox = screen.getByTestId('ab-checkbox'); + expect(checkbox).toBeInTheDocument(); + }); }); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx index 075b481aa01..84861839f7c 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx @@ -75,7 +75,7 @@ export const ActiveBoolean: Widget< const isReadOnly = !!props?.schema.readOnly; const defaultValueSelector = uiProps['fetch:response:value']?.toString(); - const staticDefault = uiProps['fetch:response:default']; + const staticDefault = coerceToBoolean(uiProps['fetch:response:default']); const hasStaticDefault = staticDefault !== undefined; const skipInitialValue = uiProps['fetch:skipInitialValue'] === true; const hasFetchUrl = !!uiProps['fetch:url'];