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..8b5a30c01d2 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.test.tsx @@ -0,0 +1,319 @@ +/* + * 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); + }); + + 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 new file mode 100644 index 00000000000..84861839f7c --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveBoolean.tsx @@ -0,0 +1,192 @@ +/* + * 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 = coerceToBoolean(uiProps['fetch:response:default']); + const hasStaticDefault = staticDefault !== undefined; + const skipInitialValue = uiProps['fetch:skipInitialValue'] === true; + const hasFetchUrl = !!uiProps['fetch:url']; + const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true; + + 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 retrigger = useRetriggerEvaluate( + templateUnitEvaluator, + formData, + uiProps['fetch:retrigger'] as string[], + ); + + const { data, error, loading } = useFetch( + formData ?? {}, + uiProps, + retrigger, + formContext?.onSamlSsoError, + ); + + const { completeLoading, wrapProcessing } = useProcessingState( + loading, + formContext?.handleFetchStarted, + formContext?.handleFetchEnded, + ); + + const handleChange = useCallback( + (changed: boolean, isByUser: boolean) => { + if (isByUser && setIsChangedByUser) { + setIsChangedByUser(id, true); + } + onChange(changed); + }, + [onChange, id, setIsChangedByUser], + ); + + const handleClear = useCallback( + () => handleChange(false, false), + [handleChange], + ); + + useClearOnRetrigger({ + enabled: clearOnRetrigger, + retrigger, + onClear: handleClear, + }); + + useEffect(() => { + 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); + } + }); + } + }, [ + data, + loading, + clearOnRetrigger, + skipInitialValue, + isChangedByUser, + defaultValueSelector, + wrapProcessing, + templateUnitEvaluator, + formData, + uiProps, + value, + handleChange, + ]); + + if (localError || (error && uiProps['fetch:error:silent'] !== true)) { + return ; + } + + 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';