fix(app): cursor jumps to the end of inputs [EE-4359] (#8163)

* feat(app/react): introduce controlled inputs HOC to avoid creating uncontrolled React components

* fix(app/env): jumping inputs when adding gpus to existing environment
pull/8207/head
LP B 2 years ago committed by GitHub
parent 67d3abcc9d
commit 68975620c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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',

@ -0,0 +1,134 @@
import { ComponentType, useEffect, useMemo, useState } from 'react';
type KeyRecord<T> = Record<keyof T, T[keyof T]>;
type KeysOfFunctions<T> = keyof {
[K in keyof T as T[K] extends (v: never) => void ? K : never]: never;
};
type KeysWithoutFunctions<T> = Exclude<keyof T, KeysOfFunctions<T>>;
/**
* 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 <input value={value} onChange={onChange} />
* }
*
* 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 (
* <>
* <input type="text" value={valueStr} onChange={onChangeStr} />
* <input type="number" value={valueNum} onChange={onChangeNum} />
* </>
* );
* }
*
* 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<T>(
WrappedComponent: ComponentType<T>,
controlledValueOnChangePairs:
| {
[Key in KeysWithoutFunctions<T>]?: KeysOfFunctions<T>;
}
| { value?: 'onChange' } = { value: 'onChange' }
): ComponentType<T> {
// 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<KeyRecord<T>>(
{} as KeyRecord<T>
);
// 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<T>)
);
// 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<T>;
setControlledValues(toUpdate);
}, [props]);
return <WrappedComponent {...props} {...handlers} {...controlledValues} />;
}
WrapperComponent.displayName = `withControlledInput(${displayName})`;
return WrapperComponent;
}

@ -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',
]);

Loading…
Cancel
Save