Skip to content

Commit e9a932e

Browse files
committed
fixup! ui-core: enable the user to type in the date picker
Signed-off-by: Clara Ni <[email protected]>
1 parent 0e4b6ce commit e9a932e

File tree

5 files changed

+171
-26
lines changed

5 files changed

+171
-26
lines changed

ui-core/src/components/inputs/datePicker/DatePicker.tsx

+5-12
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ type BaseDatePickerProps = {
1212
selectableSlot?: CalendarSlot;
1313
inputProps: InputProps;
1414
calendarPickerProps?: CalendarPickerPublicProps;
15-
errorMessage?: string;
15+
errorMessages?: { invalidInput?: string; invalidDate?: string };
1616
width?: number;
1717
};
1818

@@ -33,7 +33,7 @@ export type DatePickerProps = SingleDatePickerProps | RangeDatePickerProps;
3333
export const DatePicker = (props: DatePickerProps) => {
3434
const {
3535
inputValue,
36-
inputIsValid,
36+
statusWithMessage,
3737
selectedSlot,
3838
showPicker,
3939
modalPosition,
@@ -46,7 +46,7 @@ export const DatePicker = (props: DatePickerProps) => {
4646
} = useDatePicker(props);
4747

4848
const { inputFieldWrapperClassname, ...otherInputProps } = props.inputProps;
49-
const { selectableSlot, errorMessage } = props;
49+
const { selectableSlot } = props;
5050

5151
return (
5252
<div className="date-picker">
@@ -65,15 +65,8 @@ export const DatePicker = (props: DatePickerProps) => {
6565
'range-mode': props.isRangeMode,
6666
})}
6767
autoComplete="off"
68-
onChange={handleInputOnChange}
69-
statusWithMessage={
70-
!inputIsValid
71-
? {
72-
status: 'error',
73-
message: errorMessage ?? 'Invalid date',
74-
}
75-
: undefined
76-
}
68+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleInputOnChange(e.target.value)}
69+
statusWithMessage={statusWithMessage}
7770
/>
7871
</div>
7972
{showPicker && (

ui-core/src/components/inputs/datePicker/__tests__/useDatePicker.spec.ts

+138-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
44
import { type RangeDatePickerProps, type SingleDatePickerProps } from '..';
55
import useDatePicker from '../useDatePicker';
66

7+
const errorMessages = {
8+
invalidDate: 'Invalid date',
9+
invalidInput: 'Invalid input',
10+
};
11+
712
describe('useDatePicker', () => {
813
let onDayChange: RangeDatePickerProps['onDateChange'];
914
let onSlotChange: SingleDatePickerProps['onDateChange'];
@@ -59,22 +64,150 @@ describe('useDatePicker', () => {
5964
});
6065

6166
describe('input click handling', () => {
62-
it('should show the picker and call the input onClick callback ', () => {
63-
const onClickCallback = vi.fn();
67+
it('should not show the picker in single date mode', () => {
6468
const { result } = renderHook(() =>
6569
useDatePicker({
66-
inputProps: { id: 'id', label: 'label', onClick: onClickCallback },
70+
inputProps: { id: 'id', label: 'label' },
6771
isRangeMode: false,
6872
onDateChange: onDayChange,
6973
})
7074
);
7175

7276
act(() => {
73-
result.current.handleInputClick({} as React.MouseEvent<HTMLInputElement, MouseEvent>);
77+
result.current.handleInputClick();
78+
});
79+
80+
expect(result.current.showPicker).toBe(false);
81+
});
82+
83+
it('should show the picker in range mode', () => {
84+
const { result } = renderHook(() =>
85+
useDatePicker({
86+
inputProps: { id: 'id', label: 'label' },
87+
isRangeMode: true,
88+
onDateChange: onDayChange,
89+
})
90+
);
91+
92+
act(() => {
93+
result.current.handleInputClick();
7494
});
7595

7696
expect(result.current.showPicker).toBe(true);
77-
expect(onClickCallback).toHaveBeenCalled();
97+
});
98+
});
99+
100+
describe('input change handling', () => {
101+
describe('single mode', () => {
102+
it.each(['toto', '01/01/2024', '01/i'])(
103+
'should show an error if the input is invalid (%i)',
104+
(newValue) => {
105+
const { result } = renderHook(() =>
106+
useDatePicker({
107+
inputProps: { id: 'id', label: 'label' },
108+
isRangeMode: false,
109+
onDateChange: onDayChange,
110+
errorMessages,
111+
})
112+
);
113+
114+
act(() => {
115+
result.current.handleInputOnChange(newValue);
116+
});
117+
118+
expect(result.current.inputValue).toBe(newValue);
119+
expect(result.current.statusWithMessage).toEqual({
120+
status: 'error',
121+
message: 'Invalid input',
122+
});
123+
expect(onDayChange).not.toBeCalled();
124+
}
125+
);
126+
127+
it('should update the input value if the date string is not complete', () => {
128+
const { result } = renderHook(() =>
129+
useDatePicker({
130+
inputProps: { id: 'id', label: 'label' },
131+
isRangeMode: false,
132+
onDateChange: onDayChange,
133+
errorMessages,
134+
})
135+
);
136+
137+
act(() => {
138+
result.current.handleInputOnChange('3/04/24');
139+
});
140+
141+
expect(result.current.inputValue).toBe('3/04/24');
142+
expect(result.current.statusWithMessage).toBe(undefined);
143+
expect(onDayChange).not.toBeCalled();
144+
});
145+
146+
it('should show an error if the date is not within the selectable slot', () => {
147+
const { result } = renderHook(() =>
148+
useDatePicker({
149+
selectableSlot: { start: new Date(2024, 0, 1), end: new Date(2024, 1, 1) },
150+
inputProps: { id: 'id', label: 'label' },
151+
isRangeMode: false,
152+
onDateChange: onDayChange,
153+
errorMessages,
154+
})
155+
);
156+
157+
act(() => {
158+
result.current.handleInputOnChange('03/04/24');
159+
});
160+
161+
expect(result.current.inputValue).toBe('03/04/24');
162+
expect(result.current.statusWithMessage).toEqual({
163+
status: 'error',
164+
message: 'Invalid date',
165+
});
166+
expect(onDayChange).not.toBeCalled();
167+
});
168+
169+
it('should update the parent value is the date is valid', () => {
170+
const { result } = renderHook(() =>
171+
useDatePicker({
172+
inputProps: { id: 'id', label: 'label' },
173+
isRangeMode: false,
174+
onDateChange: onDayChange,
175+
errorMessages,
176+
})
177+
);
178+
179+
act(() => {
180+
result.current.handleInputOnChange('01/02/24');
181+
});
182+
183+
expect(result.current.inputValue).toBe('01/02/24');
184+
expect(result.current.statusWithMessage).toBe(undefined);
185+
expect(onDayChange).toHaveBeenCalledWith(new Date(2024, 1, 1));
186+
});
187+
});
188+
189+
describe('range mode', () => {
190+
it('should do nothing in range mode', () => {
191+
const { result } = renderHook(() =>
192+
useDatePicker({
193+
value: { start: new Date(2024, 0, 1), end: null },
194+
inputProps: {
195+
id: 'id',
196+
label: 'label',
197+
},
198+
isRangeMode: true,
199+
onDateChange: onDayChange,
200+
errorMessages,
201+
})
202+
);
203+
204+
act(() => {
205+
result.current.handleInputOnChange('01/02/24');
206+
});
207+
208+
expect(result.current.inputValue).toBe('01/01/24 - ');
209+
expect(onDayChange).not.toBeCalled();
210+
});
78211
});
79212
});
80213
});

ui-core/src/components/inputs/datePicker/useDatePicker.ts

+21-9
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { useState, useRef, useEffect } from 'react';
33
import type { DatePickerProps } from './DatePicker';
44
import {
55
computeNewSelectedSlot,
6+
containsOnlyNumbersAndSlashes,
67
formatInputValue,
78
formatValueToSlot,
89
isWithinInterval,
910
} from './utils';
1011
import { useModalPosition } from '../../../hooks/useModalPosition';
1112
import useOutsideClick from '../../../hooks/useOutsideClick';
13+
import type { StatusWithMessage } from '../StatusMessage';
1214

1315
const MODAL_HORIZONTAL_OFFSET = -24;
1416
const MODAL_VERTICAL_OFFSET = 3;
@@ -17,11 +19,11 @@ const MODAL_VERTICAL_OFFSET = 3;
1719
const SINGLE_DATE_REGEX = /^\d{2}\/\d{2}\/\d{2}$/;
1820

1921
export default function useDatePicker(datePickerProps: DatePickerProps) {
20-
const { value, isRangeMode, selectableSlot, onDateChange } = datePickerProps;
22+
const { value, isRangeMode, selectableSlot, errorMessages, onDateChange } = datePickerProps;
2123
const [showPicker, setShowPicker] = useState(false);
2224
const [inputValue, setInputValue] = useState(formatInputValue(datePickerProps));
2325
const [selectedSlot, setSelectedSlot] = useState(formatValueToSlot(datePickerProps));
24-
const [inputIsValid, setInputIsValid] = useState(true);
26+
const [statusWithMessage, setStatusWithMessage] = useState<StatusWithMessage>();
2527

2628
const calendarPickerRef = useRef<HTMLDivElement>(null);
2729
const inputRef = useRef<HTMLInputElement>(null);
@@ -57,24 +59,34 @@ export default function useDatePicker(datePickerProps: DatePickerProps) {
5759
}
5860
};
5961

60-
const handleInputOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
62+
const handleInputOnChange = (newValue: string) => {
6163
if (isRangeMode) {
6264
return;
6365
}
6466

65-
const inputIsDate = SINGLE_DATE_REGEX.test(e.target.value);
66-
setInputValue(e.target.value);
67+
setInputValue(newValue);
68+
const inputIsDate = SINGLE_DATE_REGEX.test(newValue);
6769

6870
if (inputIsDate) {
69-
const [day, month, year] = e.target.value.split('/').map(Number);
71+
const [day, month, year] = newValue.split('/').map(Number);
7072
const date = new Date(year + 2000, month - 1, day);
7173

7274
if (!isNaN(date.getTime()) && isWithinInterval(date, selectableSlot) && onDateChange) {
7375
onDateChange(date);
7476
} else {
75-
setInputIsValid(false);
77+
setStatusWithMessage({ status: 'error', message: errorMessages?.invalidDate });
7678
}
79+
return;
80+
}
81+
82+
if (containsOnlyNumbersAndSlashes(newValue) && newValue.length < 8) {
83+
setStatusWithMessage(undefined);
84+
return;
7785
}
86+
setStatusWithMessage({
87+
status: 'error',
88+
message: errorMessages?.invalidInput,
89+
});
7890
};
7991

8092
useEffect(() => {
@@ -88,15 +100,15 @@ export default function useDatePicker(datePickerProps: DatePickerProps) {
88100
// otherwise the user loses the focus
89101
setInputValue(newInput);
90102
}
91-
setInputIsValid(true);
103+
setStatusWithMessage(undefined);
92104
setSelectedSlot(formatValueToSlot(datePickerProps));
93105
// eslint-disable-next-line react-hooks/exhaustive-deps
94106
}, [value]);
95107

96108
return {
97109
showPicker,
98110
inputValue,
99-
inputIsValid,
111+
statusWithMessage,
100112
selectedSlot,
101113
modalPosition,
102114
inputRef,

ui-core/src/components/inputs/datePicker/utils.ts

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export const INVALID_SELECTED_SLOT_BASED_ON_SELECTABLE_SLOT_ERROR =
88
'selectedSlot must be within selectableSlot';
99
export const INVALID_INITIAL_DATE_ERROR = 'initialDate must be within selectableSlot';
1010

11+
export const containsOnlyNumbersAndSlashes = (s: string) => /^[0-9/]+$/.test(s);
12+
1113
/**
1214
* Normalize the given date to midnight
1315
* @param date The date to normalize

ui-core/src/stories/DatePicker.stories.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '../components/inputs/datePicker';
1212
import '@osrd-project/ui-core/dist/theme.css';
1313
import './stories.css';
14+
import { formatDateString } from '../components/inputs/datePicker/utils';
1415

1516
const now = new Date();
1617
const endSelectableDate = new Date(now);
@@ -44,6 +45,10 @@ const DatePickerStory = (props: DatePickerProps) => {
4445
{...props}
4546
value={value as SingleDatePickerProps['value']}
4647
onDateChange={onDayChange}
48+
errorMessages={{
49+
invalidDate: `Please select a valid date between ${formatDateString(selectableSlot.start)} and ${formatDateString(selectableSlot.end)}`,
50+
invalidInput: 'Please enter a valid date dd/mm/yy',
51+
}}
4752
/>
4853
</div>
4954
);

0 commit comments

Comments
 (0)