From d188f674c4c751bb873ff6939149e2b8b29df77d Mon Sep 17 00:00:00 2001 From: Aliaksandr Shklianko Date: Wed, 24 Jun 2026 13:19:30 +0200 Subject: [PATCH] Incorrect TextArea behavior when additional params are specified in the content type #4544 --- .../form2/components/date-input/DateInput.tsx | 32 ++++--------- .../date-time-input/DateTimeInput.tsx | 37 +++++---------- .../components/double-input/DoubleInput.tsx | 46 +++++++------------ .../geo-point-input/GeoPointInput.tsx | 28 ++++------- .../components/input-field/InputField.test.ts | 6 ++- .../components/input-field/InputField.tsx | 8 +++- .../components/instant-input/InstantInput.tsx | 37 +++++---------- .../form2/components/long-input/LongInput.tsx | 26 +++-------- .../occurrence-list/OccurrenceList.test.ts | 3 ++ .../occurrence-list/OccurrenceList.tsx | 5 ++ .../text-area-input/TextAreaInput.tsx | 16 +++++-- .../text-line-input/TextLineInput.test.ts | 17 +++---- .../text-line-input/TextLineInput.tsx | 32 ++++--------- .../form2/components/time-input/TimeInput.tsx | 32 ++++--------- .../descriptor/OccurrenceManager.test.ts | 34 ++++++++++++++ .../js/form2/descriptor/OccurrenceManager.ts | 2 + .../descriptor/TextAreaDescriptor.test.ts | 28 +++++++---- .../js/form2/descriptor/TextAreaDescriptor.ts | 11 +++-- .../assets/admin/common/js/form2/types.ts | 1 + .../common/js/form2/utils/displayValue.ts | 7 +++ .../admin/common/js/form2/utils/index.ts | 1 + 21 files changed, 197 insertions(+), 212 deletions(-) create mode 100644 src/main/resources/assets/admin/common/js/form2/utils/displayValue.ts diff --git a/src/main/resources/assets/admin/common/js/form2/components/date-input/DateInput.tsx b/src/main/resources/assets/admin/common/js/form2/components/date-input/DateInput.tsx index 48eabdecb..982be3135 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/date-input/DateInput.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/date-input/DateInput.tsx @@ -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; -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, @@ -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(null); - const isLocalChange = useRef(false); const inputRef = useRef(null); const inputWrapperRef = useRef(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) => { const inputValue = e.currentTarget.value; - isLocalChange.current = true; - setRawInput(inputValue); if (inputValue === '') { onChange(ValueTypes.LOCAL_DATE.newNullValue()); } else { @@ -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); }; @@ -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 ( @@ -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} diff --git a/src/main/resources/assets/admin/common/js/form2/components/date-time-input/DateTimeInput.tsx b/src/main/resources/assets/admin/common/js/form2/components/date-time-input/DateTimeInput.tsx index ff2733c1a..a6bedbd0c 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/date-time-input/DateTimeInput.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/date-time-input/DateTimeInput.tsx @@ -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'; @@ -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) : ''; } @@ -57,6 +57,7 @@ function parseTimeFromDisplay(raw: string): string | null { export const DateTimeInput = ({ value, + rawValue, onChange, onBlur, config, @@ -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(null); const [draftTime, setDraftTime] = useState(null); - const isLocalChange = useRef(false); const inputRef = useRef(null); const inputWrapperRef = useRef(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) => { const inputValue = e.currentTarget.value; - isLocalChange.current = true; - setRawInput(inputValue); if (inputValue === '') { onChange(ValueTypes.LOCAL_DATE_TIME.newNullValue()); } else { @@ -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(); }; @@ -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 ( ; -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, @@ -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. @@ -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) => { const inputValue = e.currentTarget.value; - isLocalChange.current = true; - - setRawInput(inputValue); if (inputValue === '') { onChange(ValueTypes.DOUBLE.newNullValue()); } else { @@ -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} diff --git a/src/main/resources/assets/admin/common/js/form2/components/geo-point-input/GeoPointInput.tsx b/src/main/resources/assets/admin/common/js/form2/components/geo-point-input/GeoPointInput.tsx index f20ee24a1..8e0a23340 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/geo-point-input/GeoPointInput.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/geo-point-input/GeoPointInput.tsx @@ -1,21 +1,24 @@ 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; -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, @@ -23,23 +26,10 @@ export const GeoPointInput = ({ 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) => { const inputValue = e.currentTarget.value; - isLocalChange.current = true; - setRawInput(inputValue); if (inputValue === '') { onChange(ValueTypes.GEO_POINT.newNullValue()); @@ -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} diff --git a/src/main/resources/assets/admin/common/js/form2/components/input-field/InputField.test.ts b/src/main/resources/assets/admin/common/js/form2/components/input-field/InputField.test.ts index d3259b0bf..91cfd9686 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/input-field/InputField.test.ts +++ b/src/main/resources/assets/admin/common/js/form2/components/input-field/InputField.test.ts @@ -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, @@ -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); }); @@ -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, diff --git a/src/main/resources/assets/admin/common/js/form2/components/input-field/InputField.tsx b/src/main/resources/assets/admin/common/js/form2/components/input-field/InputField.tsx index a168bcb97..bad175271 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/input-field/InputField.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/input-field/InputField.tsx @@ -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); } @@ -566,6 +571,7 @@ export const InputFieldResolved = ({
handleChange(0, value, rawValue)} onBlur={() => handleOccurrenceBlur(0)} onFocus={handleOccurrenceFocus} diff --git a/src/main/resources/assets/admin/common/js/form2/components/instant-input/InstantInput.tsx b/src/main/resources/assets/admin/common/js/form2/components/instant-input/InstantInput.tsx index ab8c557fe..516161bb2 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/instant-input/InstantInput.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/instant-input/InstantInput.tsx @@ -1,12 +1,13 @@ import {Button, DatePicker, Input, TimePicker} from '@enonic/ui'; -import {type JSX, type ReactElement, useEffect, useMemo, useRef, useState} from 'react'; +import {type JSX, type ReactElement, useMemo, useRef, useState} from 'react'; +import type {Value} from '../../../data/Value'; import {ValueTypes} from '../../../data/ValueTypes'; import {DateHelper} from '../../../util/DateHelper'; import type {InstantConfig} from '../../descriptor'; import {useI18n} from '../../I18nContext'; import type {InputTypeComponentProps} from '../../types'; -import {getFirstError, getInputAccessibleName} from '../../utils'; +import {displayValue, getFirstError, getInputAccessibleName} from '../../utils'; const INSTANT_INPUT_NAME = 'InstantInput'; @@ -42,8 +43,7 @@ function displayToStorage(s: string): string { return `${y}-${m}-${d}T${h}:${min}:${sec}Z`; } -function valueToDisplay(value: InstantInputProps['value']): string { - if (value.isNull()) return ''; +function valueToDisplay(value: Value): string { const str = value.getString(); return str ? storageToDisplay(str) : ''; } @@ -92,6 +92,7 @@ function formatTimezoneLabel(date: Date | null, time: string | null): string { export const InstantInput = ({ value, + rawValue, onChange, onBlur, config, @@ -100,32 +101,20 @@ export const InstantInput = ({ index, errors, }: InstantInputProps): ReactElement => { - const [rawInput, setRawInput] = useState(() => valueToDisplay(value)); const [open, setOpen] = useState(false); // ? DatePicker/TimePicker API uses null for "no selection" const [draftDate, setDraftDate] = useState(null); const [draftTime, setDraftTime] = useState(null); - const isLocalChange = useRef(false); const inputRef = useRef(null); const inputWrapperRef = useRef(null); const t = useI18n(); const timezoneLabel = useMemo(() => formatTimezoneLabel(draftDate, draftTime), [draftDate, draftTime]); - // 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) => { const inputValue = e.currentTarget.value; - isLocalChange.current = true; - setRawInput(inputValue); if (inputValue === '') { onChange(ValueTypes.DATE_TIME.newNullValue()); } else { @@ -144,11 +133,9 @@ export const InstantInput = ({ const handleConfirm = () => { if (draftDate == null) return; - const display = formatDisplay(draftDate, draftTime); - const storageValue = displayToStorage(display); - isLocalChange.current = true; - setRawInput(display); - onChange(ValueTypes.DATE_TIME.newValue(storageValue)); + const displayValueStr = formatDisplay(draftDate, draftTime); + const storageValue = displayToStorage(displayValueStr); + onChange(ValueTypes.DATE_TIME.newValue(storageValue), displayValueStr); setOpen(false); inputRef.current?.focus(); }; @@ -161,8 +148,8 @@ export const InstantInput = ({ setDraftTime(`${DateHelper.padNumber(hours)}:${DateHelper.padNumber(minutes)}`); }; - const selectedDate = parseDateFromDisplay(rawInput); - const selectedTime = parseTimeFromDisplay(rawInput); + const selectedDate = parseDateFromDisplay(display); + const selectedTime = parseTimeFromDisplay(display); return ( ; -function valueToString(value: LongInputProps['value']): string { - return value.isNull() ? '' : String(value.getLong() ?? ''); +function valueToString(value: Value): string { + return String(value.getLong() ?? ''); } export const LongInput = ({ value, + rawValue, onChange, onBlur, config, @@ -25,23 +26,10 @@ export const LongInput = ({ index, errors, }: LongInputProps): 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) => { const inputValue = e.currentTarget.value; - isLocalChange.current = true; - setRawInput(inputValue); if (inputValue === '') { onChange(ValueTypes.LONG.newNullValue()); } else { @@ -55,7 +43,7 @@ export const LongInput = ({ aria-label={getInputAccessibleName(input, index)} type='number' step={1} - value={rawInput} + value={display} onChange={handleChange} onBlur={onBlur} disabled={!enabled} diff --git a/src/main/resources/assets/admin/common/js/form2/components/occurrence-list/OccurrenceList.test.ts b/src/main/resources/assets/admin/common/js/form2/components/occurrence-list/OccurrenceList.test.ts index dcd477e3b..c02882a78 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/occurrence-list/OccurrenceList.test.ts +++ b/src/main/resources/assets/admin/common/js/form2/components/occurrence-list/OccurrenceList.test.ts @@ -64,6 +64,7 @@ function makeState(values: Value[], min: number, max: number): OccurrenceManager return { ids: values.map((_, i) => `occurrence-${i}`), values, + rawValues: values.map(() => undefined), occurrenceValidation: values.map((_, i) => ({ index: i, breaksRequired: false, @@ -148,6 +149,7 @@ describe('OccurrenceList', () => { const state: OccurrenceManagerState = { ids: ['occurrence-0'], values: [ValueTypes.STRING.newValue('a')], + rawValues: [undefined], occurrenceValidation: [{index: 0, breaksRequired: false, validationResults: []}], totalValid: 1, isMinimumBreached: true, @@ -167,6 +169,7 @@ describe('OccurrenceList', () => { const state: OccurrenceManagerState = { ids: ['occurrence-0'], values: [ValueTypes.STRING.newValue('a')], + rawValues: [undefined], occurrenceValidation: [{index: 0, breaksRequired: false, validationResults: [{message: 'Invalid'}]}], totalValid: 0, isMinimumBreached: true, diff --git a/src/main/resources/assets/admin/common/js/form2/components/occurrence-list/OccurrenceList.tsx b/src/main/resources/assets/admin/common/js/form2/components/occurrence-list/OccurrenceList.tsx index 2c96a42b0..3e7724d28 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/occurrence-list/OccurrenceList.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/occurrence-list/OccurrenceList.tsx @@ -50,6 +50,7 @@ export type OccurrenceListRootProps type OccurrenceListItemContentProps = { Component: InputTypeComponent; value: Value; + rawValue?: string; index: number; config: C; input: Input; @@ -77,6 +78,7 @@ type OccurrenceListItemProps = Occu const OccurrenceListItemContent = ({ Component, value, + rawValue, index, config, input, @@ -99,6 +101,7 @@ const OccurrenceListItemContent = (
onChange(index, v, raw)} onBlur={onBlur ? () => onBlur(index) : undefined} onFocus={onFocus} @@ -204,6 +207,7 @@ const OccurrenceListRoot = ({ onChange(0, v, raw)} onBlur={onBlur ? () => onBlur(0) : undefined} onFocus={onFocus} @@ -223,6 +227,7 @@ const OccurrenceListRoot = ({ const contentProps = (index: number): OccurrenceListItemContentProps => ({ Component, value: state.values[index], + rawValue: state.rawValues[index], index, config, input, diff --git a/src/main/resources/assets/admin/common/js/form2/components/text-area-input/TextAreaInput.tsx b/src/main/resources/assets/admin/common/js/form2/components/text-area-input/TextAreaInput.tsx index 8cfbdb811..cceebe0ca 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/text-area-input/TextAreaInput.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/text-area-input/TextAreaInput.tsx @@ -2,19 +2,25 @@ import {cn, TextArea, useBlinkAttention} from '@enonic/ui'; import type {JSX} from 'react'; import {useEffect, useRef} from 'react'; +import type {Value} from '../../../data/Value'; import {ValueTypes} from '../../../data/ValueTypes'; import type {TextAreaConfig} from '../../descriptor'; import {useLocale} from '../../LocaleContext'; import type {InputTypeComponentProps} from '../../types'; -import {getFirstError, getInputAccessibleName, getLangAttributes} from '../../utils'; +import {displayValue, getFirstError, getInputAccessibleName, getLangAttributes} from '../../utils'; import {Counter} from '../counter'; export type TextAreaInputProps = InputTypeComponentProps; const TEXT_AREA_INPUT_NAME = 'TextAreaInput'; +function valueToString(value: Value): string { + return value.getString() ?? ''; +} + export const TextAreaInput = ({ value, + rawValue, onChange, onBlur, onFocus, @@ -40,10 +46,9 @@ export const TextAreaInput = ({ externalInputRef(textAreaRef.current); return () => externalInputRef(null); }, [externalInputRef]); - const stringValue = value.isNull() ? '' : (value.getString() ?? ''); + const stringValue = displayValue(value, rawValue, valueToString); const hasMaxLength = config.maxLength > 0; const maxLength = hasMaxLength ? config.maxLength : undefined; - const hasBoth = hasMaxLength && config.showCounter; const effectiveReadOnly = readOnly || processing; const counterAddon = config.showCounter ? ( @@ -60,7 +65,8 @@ export const TextAreaInput = ({ ) : undefined; const handleChange = (e: JSX.TargetedEvent) => { - onChange(ValueTypes.STRING.newValue(e.currentTarget.value)); + const inputValue = e.currentTarget.value; + onChange(ValueTypes.STRING.newValue(inputValue), inputValue); }; return ( @@ -79,8 +85,8 @@ export const TextAreaInput = ({ tabIndex={processing ? -1 : undefined} highlight={isBlinking} error={getFirstError(errors)} - maxLength={hasBoth ? undefined : maxLength} endAddon={counterAddon} + className='min-w-0' /> ); }; diff --git a/src/main/resources/assets/admin/common/js/form2/components/text-line-input/TextLineInput.test.ts b/src/main/resources/assets/admin/common/js/form2/components/text-line-input/TextLineInput.test.ts index 50ae89d21..29f736b1a 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/text-line-input/TextLineInput.test.ts +++ b/src/main/resources/assets/admin/common/js/form2/components/text-line-input/TextLineInput.test.ts @@ -130,24 +130,25 @@ describe('TextLineInput', () => { expect(newValue.getType()).toBe(ValueTypes.STRING); }); - it('forwards raw input value through onChange', () => { + it('forwards parsed Value and rawValue through onChange', () => { const onChange = vi.fn(); - const setRawInput = vi.fn(); - - mocks.useState.mockImplementationOnce((initial: unknown) => [ - typeof initial === 'function' ? (initial as () => unknown)() : initial, - setRawInput, - ]); const element = TextLineInput(makeProps({onChange})) as VNode; element.props.onChange({currentTarget: {value: 'abc'}} as JSX.TargetedEvent); - expect(setRawInput).toHaveBeenCalledWith('abc'); expect(onChange).toHaveBeenCalledOnce(); expect((onChange.mock.calls[0][0] as Value).getString()).toBe('abc'); expect(onChange.mock.calls[0][1]).toBe('abc'); }); + + it('prefers rawValue over value for display', () => { + const element = TextLineInput( + makeProps({value: ValueTypes.STRING.newNullValue(), rawValue: 'typed-but-invalid'}), + ) as VNode; + + expect(element.props.value).toBe('typed-but-invalid'); + }); }); describe('locale attributes', () => { diff --git a/src/main/resources/assets/admin/common/js/form2/components/text-line-input/TextLineInput.tsx b/src/main/resources/assets/admin/common/js/form2/components/text-line-input/TextLineInput.tsx index a20fd5632..294eb51df 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/text-line-input/TextLineInput.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/text-line-input/TextLineInput.tsx @@ -1,24 +1,26 @@ import {cn, Input, useBlinkAttention} from '@enonic/ui'; import type {JSX} from 'react'; -import {useEffect, useRef, useState} from 'react'; +import {useEffect, useRef} from 'react'; +import type {Value} from '../../../data/Value'; import {ValueTypes} from '../../../data/ValueTypes'; import type {TextLineConfig} from '../../descriptor'; import {useLocale} from '../../LocaleContext'; import type {InputTypeComponentProps} from '../../types'; -import {getFirstError, getInputAccessibleName, getLangAttributes} from '../../utils'; +import {displayValue, getFirstError, getInputAccessibleName, getLangAttributes} from '../../utils'; import {Counter} from '../counter'; export type TextLineInputProps = InputTypeComponentProps; -function valueToString(value: TextLineInputProps['value']): string { - return value.isNull() ? '' : (value.getString() ?? ''); +function valueToString(value: Value): string { + return value.getString() ?? ''; } const TEXT_LINE_INPUT_NAME = 'TextLineInput'; export const TextLineInput = ({ value, + rawValue, onChange, onBlur, onFocus, @@ -32,8 +34,6 @@ export const TextLineInput = ({ highlight, inputRef: externalInputRef, }: TextLineInputProps): JSX.Element => { - const [rawInput, setRawInput] = useState(() => valueToString(value)); - const isLocalChange = useRef(false); const inputRef = useRef(null); // ? Scroll is owned by the parent InputField (gated on RevealOptions.scroll); // the inner blink should highlight only, never scroll again. @@ -42,24 +42,16 @@ export const TextLineInput = ({ const langAttrs = getLangAttributes(locale); const hasMaxLength = config.maxLength > 0; const maxLength = hasMaxLength ? config.maxLength : undefined; - const hasBoth = hasMaxLength && config.showCounter; const effectiveReadOnly = readOnly || processing; - useEffect(() => { - if (isLocalChange.current) { - isLocalChange.current = false; - return; - } - - setRawInput(valueToString(value)); - }, [value]); - useEffect(() => { if (externalInputRef == null) return undefined; externalInputRef(inputRef.current); return () => externalInputRef(null); }, [externalInputRef]); + const display = displayValue(value, rawValue, valueToString); + const counterAddon = config.showCounter ? (
- +
) : undefined; const handleChange = (e: JSX.TargetedEvent) => { const inputValue = e.currentTarget.value; - - isLocalChange.current = true; - setRawInput(inputValue); onChange(ValueTypes.STRING.newValue(inputValue), inputValue); }; @@ -84,7 +73,7 @@ export const TextLineInput = ({ ref={inputRef} {...langAttrs} aria-label={getInputAccessibleName(input, index)} - value={rawInput} + value={display} onChange={handleChange} onBlur={onBlur} onFocus={onFocus} @@ -94,7 +83,6 @@ export const TextLineInput = ({ tabIndex={processing ? -1 : undefined} highlight={isBlinking} error={getFirstError(errors)} - maxLength={hasBoth ? undefined : maxLength} endAddon={counterAddon} /> ); diff --git a/src/main/resources/assets/admin/common/js/form2/components/time-input/TimeInput.tsx b/src/main/resources/assets/admin/common/js/form2/components/time-input/TimeInput.tsx index a9e5183ec..b8a8ecf77 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/time-input/TimeInput.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/time-input/TimeInput.tsx @@ -1,20 +1,21 @@ import {Button, 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 {TimeConfig} from '../../descriptor'; import {TIME_PATTERN} from '../../descriptor/TimeDescriptor'; import {useI18n} from '../../I18nContext'; import type {InputTypeComponentProps} from '../../types'; -import {getFirstError, getInputAccessibleName} from '../../utils'; +import {displayValue, getFirstError, getInputAccessibleName} from '../../utils'; const TIME_INPUT_NAME = 'TimeInput'; export type TimeInputProps = InputTypeComponentProps; -function valueToString(value: TimeInputProps['value']): string { - return value.isNull() ? '' : (value.getString() ?? ''); +function valueToString(value: Value): string { + return value.getString() ?? ''; } function formatTimeFromDate(date: Date): string { @@ -34,6 +35,7 @@ function parseTimeToPickerValue(raw: string): string | null { export const TimeInput = ({ value, + rawValue, onChange, onBlur, config, @@ -42,29 +44,17 @@ export const TimeInput = ({ index, errors, }: TimeInputProps): ReactElement => { - const [rawInput, setRawInput] = useState(() => valueToString(value)); const [open, setOpen] = useState(false); // ? Uses `null` instead of `undefined` because TimePicker API uses `null` for empty value const [draftTime, setDraftTime] = useState(null); - const isLocalChange = useRef(false); const inputRef = useRef(null); const inputWrapperRef = useRef(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) => { const inputValue = e.currentTarget.value; - isLocalChange.current = true; - setRawInput(inputValue); if (inputValue === '') { onChange(ValueTypes.LOCAL_TIME.newNullValue()); } else { @@ -78,9 +68,7 @@ export const TimeInput = ({ const handleConfirm = () => { if (draftTime == null) return; - isLocalChange.current = true; - setRawInput(draftTime); - onChange(ValueTypes.LOCAL_TIME.newValue(draftTime)); + onChange(ValueTypes.LOCAL_TIME.newValue(draftTime), draftTime); setOpen(false); inputRef.current?.focus(); }; @@ -90,7 +78,7 @@ export const TimeInput = ({ setDraftTime(formatTimeFromDate(config.default)); }; - const pickerValue = TIME_PATTERN.test(rawInput) ? parseTimeToPickerValue(rawInput) : null; + const pickerValue = TIME_PATTERN.test(display) ? parseTimeToPickerValue(display) : null; return ( { expect(state.isValid).toBe(true); }); }); + + describe('rawValues', () => { + it('exposes per-occurrence rawValues in state', () => { + const mgr = createManager({values: ['a', 'b']}); + mgr.set(0, ValueTypes.STRING.newValue('a2'), 'a2-raw'); + const state = mgr.validate(); + expect(state.rawValues).toEqual(['a2-raw', undefined]); + }); + + it('preserves rawValue on set() and clears it when the value reference changes via setValues', () => { + const mgr = createManager({values: ['a']}); + mgr.set(0, ValueTypes.STRING.newNullValue(), 'typed-invalid'); + expect(mgr.validate().rawValues).toEqual(['typed-invalid']); + + mgr.setValues([ValueTypes.STRING.newNullValue()]); + expect(mgr.validate().rawValues).toEqual([undefined]); + }); + + it('keeps rawValue across setValues when the value reference is unchanged', () => { + const mgr = createManager({values: ['a']}); + const v = ValueTypes.STRING.newValue('a'); + mgr.set(0, v, 'a-raw'); + mgr.setValues([v]); + expect(mgr.validate().rawValues).toEqual(['a-raw']); + }); + + it('keeps rawValue when sync is called with the same Value reference handleChange just wrote', () => { + const mgr = createManager({values: ['a']}); + const nullValue = ValueTypes.STRING.newNullValue(); + mgr.set(0, nullValue, 'typed-past-maxLength'); + mgr.setValues([nullValue]); + expect(mgr.validate().rawValues).toEqual(['typed-past-maxLength']); + }); + }); }); diff --git a/src/main/resources/assets/admin/common/js/form2/descriptor/OccurrenceManager.ts b/src/main/resources/assets/admin/common/js/form2/descriptor/OccurrenceManager.ts index 10d6bcfe9..d903492e5 100644 --- a/src/main/resources/assets/admin/common/js/form2/descriptor/OccurrenceManager.ts +++ b/src/main/resources/assets/admin/common/js/form2/descriptor/OccurrenceManager.ts @@ -13,6 +13,7 @@ export type OccurrenceValidationState = { export type OccurrenceManagerState = { readonly ids: string[]; readonly values: Value[]; + readonly rawValues: (string | undefined)[]; readonly occurrenceValidation: OccurrenceValidationState[]; readonly totalValid: number; readonly isMinimumBreached: boolean; @@ -225,6 +226,7 @@ export class OccurrenceManager { return { ids: this.getIds(), values: this.getValues(), + rawValues: [...this.rawValues], occurrenceValidation, totalValid, isMinimumBreached, diff --git a/src/main/resources/assets/admin/common/js/form2/descriptor/TextAreaDescriptor.test.ts b/src/main/resources/assets/admin/common/js/form2/descriptor/TextAreaDescriptor.test.ts index 48c33d9ab..bd0d7b201 100644 --- a/src/main/resources/assets/admin/common/js/form2/descriptor/TextAreaDescriptor.test.ts +++ b/src/main/resources/assets/admin/common/js/form2/descriptor/TextAreaDescriptor.test.ts @@ -1,9 +1,13 @@ -import {describe, expect, it} from 'vitest'; +import {describe, expect, it, vi} from 'vitest'; import {Value} from '../../data/Value'; import {ValueTypes} from '../../data/ValueTypes'; import type {TextAreaConfig} from './InputTypeConfig'; import {TextAreaDescriptor} from './TextAreaDescriptor'; +vi.mock('../../util/Messages', () => ({ + i18n: (key: string, ..._args: unknown[]) => `#${key}#`, +})); + describe('TextAreaDescriptor', () => { describe('getValueType', () => { it('returns STRING', () => { @@ -86,22 +90,28 @@ describe('TextAreaDescriptor', () => { const value = ValueTypes.STRING.newValue('toolong'); const results = TextAreaDescriptor.validate(value, config); expect(results).toHaveLength(1); - expect(results[0].message).toBe('Value exceeds maximum length of 5'); + expect(results[0].message).toContain('field.value.breaks.maxlength'); }); - it('uses hardcoded message (not i18n)', () => { - const config = makeConfig({maxLength: 10}); - const value = ValueTypes.STRING.newValue('a'.repeat(11)); - const results = TextAreaDescriptor.validate(value, config); - expect(results[0].message).toBe('Value exceeds maximum length of 10'); + it('validates rawValue when stored value is null and maxLength is exceeded', () => { + const config = makeConfig({maxLength: 3}); + const results = TextAreaDescriptor.validate(ValueTypes.STRING.newNullValue(), config, 'abcd'); + + expect(results).toHaveLength(1); + expect(results[0].message).toContain('field.value.breaks.maxlength'); }); - it('returns empty for null value', () => { + it('returns empty for null value with no rawValue', () => { const config = makeConfig({maxLength: 5}); const value = ValueTypes.STRING.newNullValue(); expect(TextAreaDescriptor.validate(value, config)).toEqual([]); }); + it('returns empty for null value with empty rawValue', () => { + const config = makeConfig({maxLength: 5}); + expect(TextAreaDescriptor.validate(ValueTypes.STRING.newNullValue(), config, '')).toEqual([]); + }); + it('returns empty when maxLength is -1 (unlimited)', () => { const config = makeConfig({maxLength: -1}); const value = ValueTypes.STRING.newValue('a'.repeat(10000)); @@ -154,7 +164,7 @@ describe('TextAreaDescriptor', () => { const config = TextAreaDescriptor.readConfig({maxLength: [{value: 5}]}); const results = TextAreaDescriptor.validate(ValueTypes.STRING.newValue('toolong'), config); expect(results).toHaveLength(1); - expect(results[0].message).toBe('Value exceeds maximum length of 5'); + expect(results[0].message).toContain('field.value.breaks.maxlength'); }); it('accepts any value with empty config', () => { diff --git a/src/main/resources/assets/admin/common/js/form2/descriptor/TextAreaDescriptor.ts b/src/main/resources/assets/admin/common/js/form2/descriptor/TextAreaDescriptor.ts index 76ad629e4..90fd2ae9d 100644 --- a/src/main/resources/assets/admin/common/js/form2/descriptor/TextAreaDescriptor.ts +++ b/src/main/resources/assets/admin/common/js/form2/descriptor/TextAreaDescriptor.ts @@ -2,6 +2,7 @@ import type {Value} from '../../data/Value'; import type {ValueType} from '../../data/ValueType'; import {ValueTypes} from '../../data/ValueTypes'; import type {RawInputConfig} from '../../form/Input'; +import {i18n} from '../../util/Messages'; import {StringHelper} from '../../util/StringHelper'; import type {TextAreaConfig} from './InputTypeConfig'; import type {InputTypeDescriptor} from './InputTypeDescriptor'; @@ -31,16 +32,16 @@ export const TextAreaDescriptor: InputTypeDescriptor = { return ValueTypes.STRING.newValue(raw); }, - validate(value: Value, config: TextAreaConfig): ValidationResult[] { + validate(value: Value, config: TextAreaConfig, rawValue?: string): ValidationResult[] { const results: ValidationResult[] = []; - if (value.isNull()) { + const str = value.isNull() ? rawValue : (value.getString() ?? ''); + + if (str == null) { return results; } - const str = value.getString(); - if (config.maxLength > 0 && str.length > config.maxLength) { - results.push({message: `Value exceeds maximum length of ${config.maxLength}`}); + results.push({message: i18n('field.value.breaks.maxlength', config.maxLength)}); } return results; diff --git a/src/main/resources/assets/admin/common/js/form2/types.ts b/src/main/resources/assets/admin/common/js/form2/types.ts index 95d7a13a7..e6996e620 100644 --- a/src/main/resources/assets/admin/common/js/form2/types.ts +++ b/src/main/resources/assets/admin/common/js/form2/types.ts @@ -10,6 +10,7 @@ export type InputTypeMode = 'list' | 'single' | 'internal'; /** Props every React input type component receives (one per occurrence). */ export type InputTypeComponentProps = { value: Value; + rawValue?: string; onChange: (value: Value, rawValue?: string) => void; onBlur?: () => void; onFocus?: () => void; diff --git a/src/main/resources/assets/admin/common/js/form2/utils/displayValue.ts b/src/main/resources/assets/admin/common/js/form2/utils/displayValue.ts new file mode 100644 index 000000000..55ee69f6b --- /dev/null +++ b/src/main/resources/assets/admin/common/js/form2/utils/displayValue.ts @@ -0,0 +1,7 @@ +import type {Value} from '../../data/Value'; + +export function displayValue(value: Value, rawValue: string | undefined, toDisplay: (v: Value) => string): string { + if (rawValue != null) return rawValue; + if (value.isNull()) return ''; + return toDisplay(value); +} diff --git a/src/main/resources/assets/admin/common/js/form2/utils/index.ts b/src/main/resources/assets/admin/common/js/form2/utils/index.ts index e966d906e..71a4733e9 100644 --- a/src/main/resources/assets/admin/common/js/form2/utils/index.ts +++ b/src/main/resources/assets/admin/common/js/form2/utils/index.ts @@ -1,4 +1,5 @@ export * from './accessibility'; +export * from './displayValue'; export * from './getLangAttributes'; export * from './serverErrors'; export * from './validation';