Загрузка данных
import { yupResolver } from '@hookform/resolvers/yup';
import {
Column,
FieldHeader,
Row,
TextField,
TextFieldProps,
Typography,
} from '@shared/components';
import { ipInputSchema } from '@shared/components/IpInput/validator';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
type Props = {
'data-fui-tid'?: string;
value?: string;
onChange: (value: string) => void;
error: boolean;
helperText: string | undefined;
label: string;
required?: boolean;
disabled?: boolean;
onBlur?: VoidFunction;
};
type FormValues = {
first: string;
second: string;
third: string;
fourth: string;
};
type FieldName = keyof FormValues;
const NEXT_FIELD_MAP: Record<FieldName, FieldName | undefined> = {
first: 'second',
second: 'third',
third: 'fourth',
fourth: undefined,
};
const PREVIOUS_FIELD_MAP: Record<FieldName, FieldName | undefined> = {
first: undefined,
second: 'first',
third: 'second',
fourth: 'third',
};
const MAX_IP_OCTET = 255;
export const IpInput = ({
'data-fui-tid': tid,
value: outerValue,
onChange: onIpChange,
error,
helperText,
label,
required,
onBlur,
disabled,
}: Props) => {
const { t } = useTranslation('controller');
const ipFields: string[] = outerValue
? outerValue.split('.')
: ['', '', '', ''];
const defaultValues: FormValues = {
first: ipFields[0],
second: ipFields[1],
third: ipFields[2],
fourth: ipFields[3],
};
const { control, setFocus, reset, getValues, setValue } = useForm<FormValues>(
{
mode: 'onChange',
defaultValues,
resolver: yupResolver(ipInputSchema),
},
);
useEffect(() => {
reset(defaultValues);
}, [outerValue]);
const moveToNextField = (currentField: FieldName) => {
const nextField = NEXT_FIELD_MAP[currentField];
if (nextField) {
setFocus(nextField);
}
};
const moveToPreviousField = (currentField: FieldName) => {
const previousField = PREVIOUS_FIELD_MAP[currentField];
if (previousField) {
setFocus(previousField);
}
};
const updateIpValue = () => {
const ipFields = Object.values(getValues()).join('.');
const ipValue = ipFields === '...' ? '' : ipFields;
onIpChange(ipValue);
handleBlur();
};
const handleCopy = () => navigator.clipboard.writeText(outerValue ?? '');
const handlePaste: TextFieldProps['onPaste'] = (event) => {
event.preventDefault();
const value = event.clipboardData.getData('text/plain');
const parts = value?.split('.') ?? [];
const parsedValue = parts.map((field) => field.replace(/\r\n|\n\r/gm, ''));
const isValid =
parsedValue.length === 4 &&
parsedValue.every((part) => /^[\d.]+$/.test(part));
const isWithinRange = parsedValue.every((part) => {
const num = parseInt(part, 10);
return num >= 0 && num <= 255;
});
if (!isValid || !isWithinRange) {
console.warn('Invalid IP format, paste cancelled');
return;
}
onIpChange(value);
reset({
first: parsedValue[0],
second: parsedValue[1],
third: parsedValue[2],
fourth: parsedValue[3],
});
};
const handleBackspace = (fieldName: FieldName, currentValue: string) => {
if (currentValue === '' && fieldName !== 'first') {
const previousField = PREVIOUS_FIELD_MAP[fieldName];
if (previousField) {
const previousValue = getValues()[previousField];
const newPreviousValue = previousValue.slice(0, -1);
setValue(previousField, newPreviousValue);
moveToPreviousField(fieldName);
setTimeout(updateIpValue, 0);
return true;
}
}
return false;
};
const handleExceedLimit = (
fieldName: FieldName,
inputValue: string,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void,
) => {
// Если это последнее поле, ограничиваем до 255
if (fieldName === 'fourth') {
onChange({
target: { value: String(MAX_IP_OCTET) },
} as React.ChangeEvent<HTMLInputElement>);
setTimeout(updateIpValue, 0);
return true;
}
// Находим максимальную часть, которая не превышает 255
let validPart = '';
let remainingPart = inputValue;
for (let i = 1; i <= inputValue.length; i++) {
const testValue = inputValue.slice(0, i);
if (parseInt(testValue, 10) <= MAX_IP_OCTET) {
validPart = testValue;
remainingPart = inputValue.slice(i);
} else {
break;
}
}
// Обновляем текущее поле (validPart всегда непустой при вызове функции)
onChange({
target: { value: validPart },
} as React.ChangeEvent<HTMLInputElement>);
// Переносим остаток в следующее поле
if (remainingPart) {
const nextField = NEXT_FIELD_MAP[fieldName];
if (nextField) {
const nextFieldValue = getValues()[nextField];
setValue(nextField, remainingPart + nextFieldValue);
}
}
// Перемещаем фокус на следующее поле
moveToNextField(fieldName);
setTimeout(updateIpValue, 0);
return true;
};
const handleChange =
(
fieldName: FieldName,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void,
): TextFieldProps['onChange'] =>
(event) => {
// Проверяем тип события и пропускаем вставку из буфера обмена
if (
event.nativeEvent instanceof InputEvent &&
event.nativeEvent.inputType === 'insertFromPaste'
) {
return;
}
let { value } = event.target;
// Очищаем значение от нецифровых символов
value = value.replace(/\D/g, '');
// Проверяем, не превышает ли число 255
if (value && parseInt(value, 10) > MAX_IP_OCTET) {
const handled = handleExceedLimit(fieldName, value, onChange);
if (handled) return;
}
// Обновляем значение в поле
onChange({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
// Если значение достигло 3 символов и это не последнее поле, перемещаем фокус
if (value.length === 3 && fieldName !== 'fourth') {
moveToNextField(fieldName);
}
// Формируем полный IP адрес
setTimeout(updateIpValue, 0);
};
const handleKeyDown =
(fieldName: FieldName, currentValue: string): TextFieldProps['onKeyDown'] =>
(event) => {
if (event.key === 'Backspace') {
event.preventDefault();
const handled = handleBackspace(fieldName, currentValue);
if (!handled) {
// Если backspace не обработан, имитируем обычное удаление
const newValue = currentValue.slice(0, -1);
setValue(fieldName, newValue);
setTimeout(updateIpValue, 0);
}
}
};
const handleBlur = () => onBlur && onBlur();
const renderTextField = (name: FieldName, testId: string) => (
<Controller
name={name}
control={control}
render={({ field: { value, onChange, ref } }) => (
<TextField
inputRef={ref}
value={value}
onChange={handleChange(name, onChange)}
onKeyDown={handleKeyDown(name, value)}
onBlur={handleBlur}
onPaste={handlePaste}
onCopy={handleCopy}
data-testid={testId}
error={error}
disabled={disabled}
/>
)}
/>
);
return (
<Column gridGap='0' data-fui-tid={tid} data-testid='ip-input'>
<FieldHeader label={t(label)} required={required} disabled={disabled} />
<Row gridGap='2px'>
{renderTextField('first', 'ip-first')}
<span style={{ display: 'inline-block', alignSelf: 'end' }}>.</span>
{renderTextField('second', 'ip-second')}
<span style={{ display: 'inline-block', alignSelf: 'end' }}>.</span>
{renderTextField('third', 'ip-third')}
<span style={{ display: 'inline-block', alignSelf: 'end' }}>.</span>
{renderTextField('fourth', 'ip-fourth')}
</Row>
{error && (
<Typography
variant='caption'
color='error'
style={{ marginTop: '4px' }}
>
{helperText}
</Typography>
)}
</Column>
);
};
const handleExceedLimit = (
fieldName: FieldName,
inputValue: string,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void,
) => {
Refactor this function to not always return the same value.
// Если это последнее поле, ограничиваем до 255
if (fieldName === 'fourth') {
onChange({
target: { value: String(MAX_IP_OCTET) },
} as React.ChangeEvent<HTMLInputElement>);
setTimeout(updateIpValue, 0);
return true;
}
// Находим максимальную часть, которая не превышает 255
let validPart = '';
let remainingPart = inputValue;
// Перемещаем фокус на следующее поле
moveToNextField(fieldName);
setTimeout(updateIpValue, 0);
return true;
};
const handleChange =
(
fieldName: FieldName,