Skip to content

Commit c58e0ca

Browse files
authored
fix: Custom time input for availability (#26373)
* add custom time input * add unit test * add validation logic
1 parent 0099c72 commit c58e0ca

File tree

2 files changed

+384
-29
lines changed

2 files changed

+384
-29
lines changed

packages/features/schedules/components/Schedule.tsx

Lines changed: 148 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ export type SelectInnerClassNames = {
4545
};
4646

4747
export type FieldPathByValue<TFieldValues extends FieldValues, TValue> = {
48-
[Key in FieldPath<TFieldValues>]: FieldPathValue<TFieldValues, Key> extends TValue ? Key : never;
48+
[Key in FieldPath<TFieldValues>]: FieldPathValue<
49+
TFieldValues,
50+
Key
51+
> extends TValue
52+
? Key
53+
: never;
4954
}[FieldPath<TFieldValues>];
5055

5156
export const ScheduleDay = <TFieldValues extends FieldValues>({
@@ -404,12 +409,35 @@ const TimeRangeField = ({
404409
);
405410
};
406411

412+
export function parseTimeString(
413+
input: string,
414+
timeFormat: number | null
415+
): Date | null {
416+
if (!input.trim()) return null;
417+
418+
const formats = timeFormat === 12 ? ["h:mma", "HH:mm"] : ["HH:mm", "h:mma"];
419+
const parsed = dayjs(input, formats, true); // strict parsing
420+
421+
if (!parsed.isValid()) return null;
422+
423+
const hours = parsed.hour();
424+
const minutes = parsed.minute();
425+
426+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
427+
return null;
428+
}
429+
430+
return new Date(new Date().setUTCHours(hours, minutes, 0, 0));
431+
}
432+
407433
const LazySelect = ({
408434
value,
409435
min,
410436
max,
411437
userTimeFormat,
412438
menuPlacement,
439+
innerClassNames,
440+
onChange,
413441
...props
414442
}: Omit<Props<IOption, false, GroupBase<IOption>>, "value"> & {
415443
value: ConfigType;
@@ -426,31 +454,100 @@ const LazySelect = ({
426454
}, [filter, value]);
427455

428456
const [inputValue, setInputValue] = React.useState("");
457+
const [timeInputError, setTimeInputError] = React.useState(false);
429458
const defaultFilter = React.useMemo(() => createFilter(), []);
459+
460+
const handleInputChange = React.useCallback(
461+
(newValue: string, actionMeta: { action: string }) => {
462+
setInputValue(newValue);
463+
464+
if (actionMeta.action === "input-change" && newValue.trim()) {
465+
const trimmedValue = newValue.trim();
466+
467+
const formats =
468+
userTimeFormat === 12 ? ["h:mma", "HH:mm"] : ["HH:mm", "h:mma"];
469+
const parsedTime = dayjs(trimmedValue, formats, true);
470+
const looksLikeTime = /^\d{1,2}:\d{2}(a|p|am|pm)?$/i.test(trimmedValue);
471+
472+
if (looksLikeTime && !parsedTime.isValid()) {
473+
setTimeInputError(true);
474+
} else if (parsedTime.isValid()) {
475+
const parsedDate = parseTimeString(trimmedValue, userTimeFormat);
476+
if (parsedDate) {
477+
const parsedDayjs = dayjs(parsedDate);
478+
const violatesMin = min ? !parsedDayjs.isAfter(min) : false;
479+
const violatesMax = max ? !parsedDayjs.isBefore(max) : false;
480+
setTimeInputError(Boolean(violatesMin || violatesMax));
481+
} else {
482+
setTimeInputError(false);
483+
}
484+
} else {
485+
setTimeInputError(false);
486+
}
487+
} else {
488+
setTimeInputError(false);
489+
}
490+
},
491+
[userTimeFormat, min, max]
492+
);
493+
430494
const filteredOptions = React.useMemo(() => {
431-
const regex = /^(\d{1,2})(a|p|am|pm)$/i;
432-
const match = inputValue.replaceAll(" ", "").match(regex);
433-
if (!match) {
434-
return options.filter((option) =>
435-
defaultFilter({ ...option, data: option.label, value: option.label }, inputValue)
436-
);
495+
const dropdownOptions = options.filter((option) =>
496+
defaultFilter(
497+
{ ...option, data: option.label, value: option.label },
498+
inputValue
499+
)
500+
);
501+
502+
const trimmedInput = inputValue.trim();
503+
if (trimmedInput) {
504+
const parsedTime = parseTimeString(trimmedInput, userTimeFormat);
505+
506+
if (parsedTime) {
507+
const parsedDayjs = dayjs(parsedTime);
508+
// Validate against min/max bounds using same logic as filter function
509+
const withinBounds =
510+
(!min || parsedDayjs.isAfter(min)) &&
511+
(!max || parsedDayjs.isBefore(max));
512+
513+
if (withinBounds) {
514+
const parsedTimestamp = parsedTime.valueOf();
515+
const existsInOptions = options.some(
516+
(option) => option.value === parsedTimestamp
517+
);
518+
519+
if (!existsInOptions) {
520+
const manualOption: IOption = {
521+
label: dayjs(parsedTime)
522+
.utc()
523+
.format(userTimeFormat === 12 ? "h:mma" : "HH:mm"),
524+
value: parsedTimestamp,
525+
};
526+
return [manualOption, ...dropdownOptions];
527+
}
528+
}
529+
}
437530
}
438531

439-
const [, numberPart, periodPart] = match;
440-
const periodLower = periodPart.toLowerCase();
441-
const scoredOptions = options
442-
.filter((option) => option.label && option.label.toLowerCase().includes(periodLower))
443-
.map((option) => {
444-
const labelLower = option.label.toLowerCase();
445-
const index = labelLower.indexOf(numberPart);
446-
const score = index >= 0 ? index + labelLower.length : Infinity;
447-
return { score, option };
448-
})
449-
.sort((a, b) => a.score - b.score);
450-
451-
const maxScore = scoredOptions[0]?.score;
452-
return scoredOptions.filter((item) => item.score === maxScore).map((item) => item.option);
453-
}, [inputValue, options, defaultFilter]);
532+
return dropdownOptions;
533+
}, [inputValue, options, defaultFilter, userTimeFormat, min, max]);
534+
535+
const currentValue = dayjs(value).toDate().valueOf();
536+
const currentOption =
537+
options.find((option) => option.value === currentValue) ||
538+
(value
539+
? {
540+
value: currentValue,
541+
label: dayjs(value)
542+
.utc()
543+
.format(userTimeFormat === 12 ? "h:mma" : "HH:mm"),
544+
}
545+
: null);
546+
547+
const errorInnerClassNames: SelectInnerClassNames = {
548+
...innerClassNames,
549+
control: cn(innerClassNames?.control, timeInputError && "!border-error"),
550+
};
454551

455552
return (
456553
<Select
@@ -461,11 +558,16 @@ const LazySelect = ({
461558
if (!min && !max) filter({ offset: 0, limit: 0 });
462559
}}
463560
menuPlacement={menuPlacement}
464-
value={options.find((option) => option.value === dayjs(value).toDate().valueOf())}
561+
value={currentOption}
465562
onMenuClose={() => filter({ current: value })}
466-
components={{ DropdownIndicator: () => null, IndicatorSeparator: () => null }}
467-
onInputChange={setInputValue}
563+
components={{
564+
DropdownIndicator: () => null,
565+
IndicatorSeparator: () => null,
566+
}}
567+
onInputChange={handleInputChange}
468568
filterOption={() => true}
569+
innerClassNames={errorInnerClassNames}
570+
onChange={onChange}
469571
{...props}
470572
/>
471573
);
@@ -514,17 +616,34 @@ const useOptions = (timeFormat: number | null) => {
514616
const filter = useCallback(
515617
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
516618
if (current) {
517-
const currentOption = options.find((option) => option.value === dayjs(current).toDate().valueOf());
518-
if (currentOption) setFilteredOptions([currentOption]);
619+
const currentValue = dayjs(current).toDate().valueOf();
620+
const currentOption = options.find(
621+
(option) => option.value === currentValue
622+
);
623+
if (currentOption) {
624+
setFilteredOptions([currentOption]);
625+
} else {
626+
// Create temporary option for custom time not in predefined options
627+
const customOption: IOption = {
628+
value: currentValue,
629+
label: dayjs(current)
630+
.utc()
631+
.format(timeFormat === 12 ? "h:mma" : "HH:mm"),
632+
};
633+
setFilteredOptions([customOption]);
634+
}
519635
} else
520636
setFilteredOptions(
521637
options.filter((option) => {
522638
const time = dayjs(option.value);
523-
return (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset));
639+
return (
640+
(!limit || time.isBefore(limit)) &&
641+
(!offset || time.isAfter(offset))
642+
);
524643
})
525644
);
526645
},
527-
[options]
646+
[options, timeFormat]
528647
);
529648

530649
return { options: filteredOptions, filter };

0 commit comments

Comments
 (0)