mirror of https://github.com/portainer/portainer
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 environmentpull/8207/head
parent
67d3abcc9d
commit
68975620c5
|
@ -3,6 +3,7 @@ import angular from 'angular';
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||||
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
|
import { withControlledInput } from '@/react-tools/withControlledInput';
|
||||||
|
|
||||||
import { VariablesFieldAngular } from './variables-field';
|
import { VariablesFieldAngular } from './variables-field';
|
||||||
|
|
||||||
|
@ -10,12 +11,16 @@ export const customTemplatesModule = angular
|
||||||
.module('portainer.app.react.components.custom-templates', [])
|
.module('portainer.app.react.components.custom-templates', [])
|
||||||
.component(
|
.component(
|
||||||
'customTemplatesVariablesFieldReact',
|
'customTemplatesVariablesFieldReact',
|
||||||
r2a(CustomTemplatesVariablesField, ['value', 'onChange', 'definitions'])
|
r2a(withControlledInput(CustomTemplatesVariablesField), [
|
||||||
|
'value',
|
||||||
|
'onChange',
|
||||||
|
'definitions',
|
||||||
|
])
|
||||||
)
|
)
|
||||||
.component('customTemplatesVariablesField', VariablesFieldAngular)
|
.component('customTemplatesVariablesField', VariablesFieldAngular)
|
||||||
.component(
|
.component(
|
||||||
'customTemplatesVariablesDefinitionField',
|
'customTemplatesVariablesDefinitionField',
|
||||||
r2a(CustomTemplatesVariablesDefinitionField, [
|
r2a(withControlledInput(CustomTemplatesVariablesDefinitionField), [
|
||||||
'onChange',
|
'onChange',
|
||||||
'value',
|
'value',
|
||||||
'errors',
|
'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 { array, object, string } from 'yup';
|
||||||
|
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
import { withControlledInput } from '@/react-tools/withControlledInput';
|
||||||
|
|
||||||
import { InputList } from '@@/form-components/InputList';
|
import { InputList } from '@@/form-components/InputList';
|
||||||
import { ItemProps } from '@@/form-components/InputList/InputList';
|
import { ItemProps } from '@@/form-components/InputList/InputList';
|
||||||
|
@ -65,4 +66,7 @@ export function gpusListValidation() {
|
||||||
return array().of(gpuShape).default([]);
|
return array().of(gpuShape).default([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GpusListAngular = r2a(GpusList, ['value', 'onChange']);
|
export const GpusListAngular = r2a(withControlledInput(GpusList), [
|
||||||
|
'value',
|
||||||
|
'onChange',
|
||||||
|
]);
|
||||||
|
|
Loading…
Reference in New Issue