Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import {Button, DatePicker, Input} from '@enonic/ui';
import {type JSX, type ReactElement, useEffect, useRef, useState} from 'react';
import {type JSX, type ReactElement, useRef, useState} from 'react';

import type {Value} from '../../../data/Value';
import {ValueTypes} from '../../../data/ValueTypes';
import {DateHelper} from '../../../util/DateHelper';
import type {DateConfig} from '../../descriptor';
import {DATE_PATTERN} from '../../descriptor/DateDescriptor';
import {useI18n} from '../../I18nContext';
import type {InputTypeComponentProps} from '../../types';
import {getFirstError, getInputAccessibleName} from '../../utils';
import {displayValue, getFirstError, getInputAccessibleName} from '../../utils';

const DATE_INPUT_NAME = 'DateInput';

export type DateInputProps = InputTypeComponentProps<DateConfig>;

function valueToString(value: DateInputProps['value']): string {
return value.isNull() ? '' : (value.getString() ?? '');
function valueToString(value: Value): string {
return value.getString() ?? '';
}

export const DateInput = ({
value,
rawValue,
onChange,
onBlur,
config,
Expand All @@ -27,29 +29,17 @@ export const DateInput = ({
index,
errors,
}: DateInputProps): ReactElement => {
const [rawInput, setRawInput] = useState(() => valueToString(value));
const [open, setOpen] = useState(false);
// ? DatePicker API uses null for "no selection" — applies to draftDate, selectedDate, calendarValue
const [draftDate, setDraftDate] = useState<Date | null>(null);
const isLocalChange = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const inputWrapperRef = useRef<HTMLDivElement>(null);
const t = useI18n();

// Sync from parent only on external value changes (e.g. save, form reset).
// Skip when the change was triggered by handleInputChange/handleConfirm below.
useEffect(() => {
if (isLocalChange.current) {
isLocalChange.current = false;
return;
}
setRawInput(valueToString(value));
}, [value]);
const display = displayValue(value, rawValue, valueToString);

const handleInputChange = (e: JSX.TargetedEvent<HTMLInputElement>) => {
const inputValue = e.currentTarget.value;
isLocalChange.current = true;
setRawInput(inputValue);
if (inputValue === '') {
onChange(ValueTypes.LOCAL_DATE.newNullValue());
} else {
Expand All @@ -64,9 +54,7 @@ export const DateInput = ({
const handleConfirm = () => {
if (draftDate == null) return;
const formatted = DateHelper.formatDate(draftDate);
isLocalChange.current = true;
setRawInput(formatted);
onChange(ValueTypes.LOCAL_DATE.newValue(formatted));
onChange(ValueTypes.LOCAL_DATE.newValue(formatted), formatted);
setOpen(false);
};

Expand All @@ -75,7 +63,7 @@ export const DateInput = ({
setDraftDate(config.default);
};

const selectedDate = DATE_PATTERN.test(rawInput) ? new Date(`${rawInput}T00:00:00`) : null;
const selectedDate = DATE_PATTERN.test(display) ? new Date(`${display}T00:00:00`) : null;
const calendarValue = open ? draftDate : selectedDate;

return (
Expand All @@ -100,7 +88,7 @@ export const DateInput = ({
aria-label={getInputAccessibleName(input, index)}
type='text'
placeholder={t('field.date.placeholder')}
value={rawInput}
value={display}
onChange={handleInputChange}
onBlur={onBlur}
disabled={!enabled}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {Button, DatePicker, Input, TimePicker} from '@enonic/ui';
import {type JSX, type ReactElement, useEffect, useRef, useState} from 'react';
import {type JSX, type ReactElement, useRef, useState} from 'react';

import type {Value} from '../../../data/Value';
import {ValueTypes} from '../../../data/ValueTypes';
import {DateHelper} from '../../../util/DateHelper';
import type {DateTimeConfig} from '../../descriptor';
import {useI18n} from '../../I18nContext';
import type {InputTypeComponentProps} from '../../types';
import {getFirstError, getInputAccessibleName} from '../../utils';
import {displayValue, getFirstError, getInputAccessibleName} from '../../utils';

const DATE_TIME_INPUT_NAME = 'DateTimeInput';

Expand All @@ -23,8 +24,7 @@ function displayToStorage(s: string): string {
return s.replace(' ', 'T');
}

function valueToDisplay(value: DateTimeInputProps['value']): string {
if (value.isNull()) return '';
function valueToDisplay(value: Value): string {
const str = value.getString();
return str ? storageToDisplay(str) : '';
}
Expand Down Expand Up @@ -57,6 +57,7 @@ function parseTimeFromDisplay(raw: string): string | null {

export const DateTimeInput = ({
value,
rawValue,
onChange,
onBlur,
config,
Expand All @@ -65,30 +66,18 @@ export const DateTimeInput = ({
index,
errors,
}: DateTimeInputProps): ReactElement => {
const [rawInput, setRawInput] = useState(() => valueToDisplay(value));
const [open, setOpen] = useState(false);
// ? DatePicker/TimePicker API uses null for "no selection"
const [draftDate, setDraftDate] = useState<Date | null>(null);
const [draftTime, setDraftTime] = useState<string | null>(null);
const isLocalChange = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const inputWrapperRef = useRef<HTMLDivElement>(null);
const t = useI18n();

// Sync from parent only on external value changes (e.g. save, form reset).
// Skip when the change was triggered by handleInputChange/handleConfirm below.
useEffect(() => {
if (isLocalChange.current) {
isLocalChange.current = false;
return;
}
setRawInput(valueToDisplay(value));
}, [value]);
const display = displayValue(value, rawValue, valueToDisplay);

const handleInputChange = (e: JSX.TargetedEvent<HTMLInputElement>) => {
const inputValue = e.currentTarget.value;
isLocalChange.current = true;
setRawInput(inputValue);
if (inputValue === '') {
onChange(ValueTypes.LOCAL_DATE_TIME.newNullValue());
} else {
Expand All @@ -107,11 +96,9 @@ export const DateTimeInput = ({

const handleConfirm = () => {
if (draftDate == null) return;
const display = formatDisplay(draftDate, draftTime);
const storageValue = displayToStorage(display);
isLocalChange.current = true;
setRawInput(display);
onChange(ValueTypes.LOCAL_DATE_TIME.newValue(storageValue));
const displayValueStr = formatDisplay(draftDate, draftTime);
const storageValue = displayToStorage(displayValueStr);
onChange(ValueTypes.LOCAL_DATE_TIME.newValue(storageValue), displayValueStr);
setOpen(false);
inputRef.current?.focus();
};
Expand All @@ -124,8 +111,8 @@ export const DateTimeInput = ({
setDraftTime(`${DateHelper.padNumber(hours)}:${DateHelper.padNumber(minutes)}`);
};

const selectedDate = parseDateFromDisplay(rawInput);
const selectedTime = parseTimeFromDisplay(rawInput);
const selectedDate = parseDateFromDisplay(display);
const selectedTime = parseTimeFromDisplay(display);

return (
<DatePicker.Root
Expand Down Expand Up @@ -156,7 +143,7 @@ export const DateTimeInput = ({
aria-label={getInputAccessibleName(input, index)}
type='text'
placeholder={t('field.dateTime.placeholder')}
value={rawInput}
value={display}
onChange={handleInputChange}
onBlur={onBlur}
disabled={!enabled}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import {Input} from '@enonic/ui';
import {type JSX, type ReactElement, useEffect, useRef, useState} from 'react';

import type {Value} from '../../../data/Value';
import {ValueTypes} from '../../../data/ValueTypes';
import type {NumberConfig} from '../../descriptor';
import type {InputTypeComponentProps} from '../../types';
import {getFirstError, getInputAccessibleName} from '../../utils';
import {displayValue, getFirstError, getInputAccessibleName} from '../../utils';
import {getStep} from './utils';

const DOUBLE_INPUT_NAME = 'DoubleInput';

export type DoubleInputProps = InputTypeComponentProps<NumberConfig>;

function valueToString(value: DoubleInputProps['value']): string {
return value.isNull() ? '' : String(value.getDouble() ?? '');
function valueToString(value: Value): string {
return String(value.getDouble() ?? '');
}

export const DoubleInput = ({
value,
rawValue,
onChange,
onBlur,
config,
Expand All @@ -24,29 +27,17 @@ export const DoubleInput = ({
index,
errors,
}: DoubleInputProps): ReactElement => {
const [rawInput, setRawInput] = useState(() => valueToString(value));
const minStep = useRef(getStep(rawInput));
const prevRawInput = useRef(rawInput);
const display = displayValue(value, rawValue, valueToString);
// ? Step is precision-sticky: shrinks to match user-typed decimals, but holds steady
// through browser increment/decrement clicks so 0.001 + 0.001 stays at 0.001 precision.
const minStep = useRef(getStep(display));
const prevDisplay = useRef(display);
const [step, setStep] = useState(minStep.current);
const isLocalChange = useRef(false);

// Sync from parent only on external value changes (e.g. form reset).
// Skip when the change was triggered by handleChange below.
useEffect(() => {
if (isLocalChange.current) {
isLocalChange.current = false;
return;
}
const newRaw = valueToString(value);
minStep.current = getStep(newRaw);
prevRawInput.current = newRaw;
setRawInput(newRaw);
}, [value]);

useEffect(() => {
const newStep = getStep(rawInput);
const prevNum = parseFloat(prevRawInput.current);
const newNum = parseFloat(rawInput);
const newStep = getStep(display);
const prevNum = parseFloat(prevDisplay.current);
const newNum = parseFloat(display);
// If the numeric delta matches the current step, the change came from the
// browser's increment/decrement control — keep the precision anchor sticky.
// Otherwise the user typed a new value directly, so reset to match it.
Expand All @@ -56,15 +47,12 @@ export const DoubleInput = ({
Math.abs(Math.abs(newNum - prevNum) - minStep.current) < minStep.current * 1e-4;

minStep.current = isStepping ? Math.min(minStep.current, newStep) : newStep;
prevRawInput.current = rawInput;
prevDisplay.current = display;
setStep(minStep.current);
}, [rawInput]);
}, [display]);

const handleChange = (e: JSX.TargetedEvent<HTMLInputElement>) => {
const inputValue = e.currentTarget.value;
isLocalChange.current = true;

setRawInput(inputValue);
if (inputValue === '') {
onChange(ValueTypes.DOUBLE.newNullValue());
} else {
Expand All @@ -78,7 +66,7 @@ export const DoubleInput = ({
aria-label={getInputAccessibleName(input, index)}
type='number'
step={step}
value={rawInput}
value={display}
onChange={handleChange}
onBlur={onBlur}
disabled={!enabled}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,45 +1,35 @@
import {Input} from '@enonic/ui';
import {type JSX, type ReactElement, useEffect, useRef, useState} from 'react';
import type {JSX, ReactElement} from 'react';

import type {Value} from '../../../data/Value';
import {ValueTypes} from '../../../data/ValueTypes';
import {GeoPoint} from '../../../util/GeoPoint';
import type {GeoPointConfig} from '../../descriptor';
import type {InputTypeComponentProps} from '../../types';
import {getFirstError, getInputAccessibleName} from '../../utils';
import {displayValue, getFirstError, getInputAccessibleName} from '../../utils';

const GEO_POINT_INPUT_NAME = 'GeoPointInput';

export type GeoPointInputProps = InputTypeComponentProps<GeoPointConfig>;

function valueToString(value: GeoPointInputProps['value']): string {
return value.isNull() ? '' : (value.getGeoPoint().toString() ?? '');
function valueToString(value: Value): string {
return value.getGeoPoint().toString() ?? '';
}

export const GeoPointInput = ({
value,
rawValue,
onChange,
onBlur,
input,
enabled,
index,
errors,
}: GeoPointInputProps): ReactElement => {
const [rawInput, setRawInput] = useState(() => valueToString(value));
const isLocalChange = useRef(false);

// Sync from parent only on external value changes (e.g. form reset).
// Skip when the change was triggered by handleChange below.
useEffect(() => {
if (isLocalChange.current) {
isLocalChange.current = false;
return;
}
setRawInput(valueToString(value));
}, [value]);
const display = displayValue(value, rawValue, valueToString);

const handleChange = (e: JSX.TargetedEvent<HTMLInputElement>) => {
const inputValue = e.currentTarget.value;
isLocalChange.current = true;
setRawInput(inputValue);

if (inputValue === '') {
onChange(ValueTypes.GEO_POINT.newNullValue());
Expand All @@ -59,7 +49,7 @@ export const GeoPointInput = ({
data-component={GEO_POINT_INPUT_NAME}
aria-label={getInputAccessibleName(input, index)}
type='text'
value={rawInput}
value={display}
onChange={handleChange}
onBlur={onBlur}
disabled={!enabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ function makeManagerState(value = ValueTypes.STRING.newValue('hello')) {
return {
ids: ['occurrence-0'],
values: [value],
rawValues: [undefined],
occurrenceValidation: [{index: 0, breaksRequired: false, validationResults: []}],
totalValid: 1,
isMinimumBreached: false,
Expand Down Expand Up @@ -372,7 +373,9 @@ describe('InputField', () => {
const syncEffect = effects.find(e => e.deps?.includes(sync));
syncEffect?.fn();

expect(sync).toHaveBeenCalledWith(staleValues);
// Sync receives live propertyArray contents (empty), not the stale hook snapshot,
// so the server-replaced (shorter) array stays at size 0.
expect(sync).toHaveBeenCalledWith([]);
expect(propertySet.getPropertyArray('testField')?.getSize() ?? 0).toBe(0);
});

Expand Down Expand Up @@ -922,6 +925,7 @@ describe('InputField', () => {
state: {
ids: [occurrenceId],
values: [ValueTypes.STRING.newValue('hello')],
rawValues: [undefined],
occurrenceValidation: [{index: 0, breaksRequired: false, validationResults: []}],
totalValid: 1,
isMinimumBreached: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,13 @@ export const InputFieldResolved = ({
);
const hasServerErrors = serverErrorsByOccurrence.size > 0;

// biome-ignore lint/correctness/useExhaustiveDependencies: `values` is the change-trigger; we read live data from propertyArray instead of consuming it.
useEffect(() => {
sync(values);
// ! Read live from propertyArray instead of the hook snapshot: under fast
// typing, `values` from usePropertyArray can lag behind the manager's own
// state, and a stale sync() would reset values/rawValues, blanking input.
const currentValues = propertyArray.getProperties().map(p => p.getValue());
sync(currentValues);
while (propertyArray.getSize() < minFill) {
propertyArray.add(defaultValue);
}
Expand Down Expand Up @@ -566,6 +571,7 @@ export const InputFieldResolved = ({
<div data-component={INPUT_FIELD_NAME}>
<Component
value={state.values[0] ?? descriptor.getValueType().newNullValue()}
rawValue={state.rawValues[0]}
onChange={(value: Value, rawValue?: string) => handleChange(0, value, rawValue)}
onBlur={() => handleOccurrenceBlur(0)}
onFocus={handleOccurrenceFocus}
Expand Down
Loading