diff --git a/app/portainer/react/components/custom-templates/index.ts b/app/portainer/react/components/custom-templates/index.ts index 9c682067f..6a682b881 100644 --- a/app/portainer/react/components/custom-templates/index.ts +++ b/app/portainer/react/components/custom-templates/index.ts @@ -3,6 +3,7 @@ import angular from 'angular'; import { r2a } from '@/react-tools/react2angular'; import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField'; import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; +import { withControlledInput } from '@/react-tools/withControlledInput'; import { VariablesFieldAngular } from './variables-field'; @@ -10,12 +11,16 @@ export const customTemplatesModule = angular .module('portainer.app.react.components.custom-templates', []) .component( 'customTemplatesVariablesFieldReact', - r2a(CustomTemplatesVariablesField, ['value', 'onChange', 'definitions']) + r2a(withControlledInput(CustomTemplatesVariablesField), [ + 'value', + 'onChange', + 'definitions', + ]) ) .component('customTemplatesVariablesField', VariablesFieldAngular) .component( 'customTemplatesVariablesDefinitionField', - r2a(CustomTemplatesVariablesDefinitionField, [ + r2a(withControlledInput(CustomTemplatesVariablesDefinitionField), [ 'onChange', 'value', 'errors', diff --git a/app/react-tools/withControlledInput.tsx b/app/react-tools/withControlledInput.tsx new file mode 100644 index 000000000..50e01627b --- /dev/null +++ b/app/react-tools/withControlledInput.tsx @@ -0,0 +1,134 @@ +import { ComponentType, useEffect, useMemo, useState } from 'react'; + +type KeyRecord = Record; + +type KeysOfFunctions = keyof { + [K in keyof T as T[K] extends (v: never) => void ? K : never]: never; +}; + +type KeysWithoutFunctions = Exclude>; + +/** + * React component wrapper that will sync AngularJS bound variables in React state. + * This wrapper is mandatory to allow the binding of AngularJS variables directly in controlled components. + * + * Without this the component will be redrawn everytime the value changes in AngularJS (outside of React control) + * and the redraw will create issues when typing in the inputs. + * + * Examples + * --- + * SINGLE PAIR + * ```tsx + * type Props = { + * value: unknown; + * onChange: (v: unknown) => void; + * } + * + * function ReactComponent({ value, onChange }: Props) { + * return + * } + * + * r2a(withControlledInput(ReactComponent), ['value', 'onChange']); + * + * ``` + * --- + * MULTIPLE PAIRS + * ```tsx + * type Props = { + * valueStr: string; + * onChangeStr: (v: string) => void; + * valueNum: number; + * onChangeNum: (v: number) => void; + * } + * + * function ReactComponent({ valueStr, onChangeStr, valueNum, onChangeNum }: Props) { + * return ( + * <> + * + * + * + * ); + * } + * + * r2a(withControlledInput(ReactComponent, { + * valueStr: 'onChangeStr', + * valueNum: 'onChangeNum' + * }), + * ['valueStr', 'onChangeStr', 'valueNum', 'onChangeNum'] + * ) + * + * ``` + * + * @param WrappedComponent The React component to wrap + * @param controlledValueOnChangePairs A map of `(value:onChange)-like` pairs. + * @returns WrappedComponent + */ +export function withControlledInput( + WrappedComponent: ComponentType, + controlledValueOnChangePairs: + | { + [Key in KeysWithoutFunctions]?: KeysOfFunctions; + } + | { value?: 'onChange' } = { value: 'onChange' } +): ComponentType { + // Try to create a nice displayName for React Dev Tools. + const displayName = + WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + // extract keys of values that will be updated outside of React lifecycle and their handler functions + const keysToControl = Object.entries(controlledValueOnChangePairs) as [ + keyof T, + keyof T + ][]; + + function WrapperComponent(props: T) { + // map of key = value for all tracked values that will be changed outside of React lifecycle + // e.g. save in React state the values that will be changed in AngularJS + const [controlledValues, setControlledValues] = useState>( + {} as KeyRecord + ); + + // generate a map of `funckey = (v: typeof props[value_key]) => void` to wrapp all handler functions. + // each wrapped handler is referencing the key of the value it is changing so we can't use a single sync func + const handlers = useMemo( + () => + Object.fromEntries( + keysToControl.map(([valueKey, onChangeKey]) => [ + onChangeKey, + // generate and save a func that uses the key of the updated value + (value: T[keyof T]) => { + // update the state with the value coming from WrappedComponent + setControlledValues( + (c) => ({ ...c, [valueKey]: value } as KeyRecord) + ); + + // call the bound handler func to update the value outside of React + // eslint-disable-next-line react/destructuring-assignment + const onChange = props[onChangeKey]; + if (typeof onChange === 'function') { + onChange(value); + } + }, + ]) + ), + [props] + ); + + // update the React state when a prop changes from outside of React lifecycle + // limit keys to update to only tracked values ; ignore untracked props and handler functions + useEffect(() => { + const toUpdate = Object.fromEntries( + // eslint-disable-next-line react/destructuring-assignment + keysToControl.map(([key]) => [key, props[key]]) + ) as KeyRecord; + + setControlledValues(toUpdate); + }, [props]); + + return ; + } + + WrapperComponent.displayName = `withControlledInput(${displayName})`; + + return WrapperComponent; +} diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList.tsx index ffc74d947..4da184b70 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList.tsx @@ -1,6 +1,7 @@ import { array, object, string } from 'yup'; import { r2a } from '@/react-tools/react2angular'; +import { withControlledInput } from '@/react-tools/withControlledInput'; import { InputList } from '@@/form-components/InputList'; import { ItemProps } from '@@/form-components/InputList/InputList'; @@ -65,4 +66,7 @@ export function gpusListValidation() { return array().of(gpuShape).default([]); } -export const GpusListAngular = r2a(GpusList, ['value', 'onChange']); +export const GpusListAngular = r2a(withControlledInput(GpusList), [ + 'value', + 'onChange', +]);