fix: Custom time input for availability (#26373)

* add custom time input

* add unit test

* add validation logic
This commit is contained in:
Manas Kenge
2026-01-02 18:06:01 +00:00
committed by GitHub
parent 0099c72f53
commit c58e0ca783
2 changed files with 383 additions and 28 deletions
@@ -45,7 +45,12 @@ export type SelectInnerClassNames = {
};
export type FieldPathByValue<TFieldValues extends FieldValues, TValue> = {
[Key in FieldPath<TFieldValues>]: FieldPathValue<TFieldValues, Key> extends TValue ? Key : never;
[Key in FieldPath<TFieldValues>]: FieldPathValue<
TFieldValues,
Key
> extends TValue
? Key
: never;
}[FieldPath<TFieldValues>];
export const ScheduleDay = <TFieldValues extends FieldValues>({
@@ -404,12 +409,35 @@ const TimeRangeField = ({
);
};
export function parseTimeString(
input: string,
timeFormat: number | null
): Date | null {
if (!input.trim()) return null;
const formats = timeFormat === 12 ? ["h:mma", "HH:mm"] : ["HH:mm", "h:mma"];
const parsed = dayjs(input, formats, true); // strict parsing
if (!parsed.isValid()) return null;
const hours = parsed.hour();
const minutes = parsed.minute();
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return null;
}
return new Date(new Date().setUTCHours(hours, minutes, 0, 0));
}
const LazySelect = ({
value,
min,
max,
userTimeFormat,
menuPlacement,
innerClassNames,
onChange,
...props
}: Omit<Props<IOption, false, GroupBase<IOption>>, "value"> & {
value: ConfigType;
@@ -426,31 +454,100 @@ const LazySelect = ({
}, [filter, value]);
const [inputValue, setInputValue] = React.useState("");
const [timeInputError, setTimeInputError] = React.useState(false);
const defaultFilter = React.useMemo(() => createFilter(), []);
const handleInputChange = React.useCallback(
(newValue: string, actionMeta: { action: string }) => {
setInputValue(newValue);
if (actionMeta.action === "input-change" && newValue.trim()) {
const trimmedValue = newValue.trim();
const formats =
userTimeFormat === 12 ? ["h:mma", "HH:mm"] : ["HH:mm", "h:mma"];
const parsedTime = dayjs(trimmedValue, formats, true);
const looksLikeTime = /^\d{1,2}:\d{2}(a|p|am|pm)?$/i.test(trimmedValue);
if (looksLikeTime && !parsedTime.isValid()) {
setTimeInputError(true);
} else if (parsedTime.isValid()) {
const parsedDate = parseTimeString(trimmedValue, userTimeFormat);
if (parsedDate) {
const parsedDayjs = dayjs(parsedDate);
const violatesMin = min ? !parsedDayjs.isAfter(min) : false;
const violatesMax = max ? !parsedDayjs.isBefore(max) : false;
setTimeInputError(Boolean(violatesMin || violatesMax));
} else {
setTimeInputError(false);
}
} else {
setTimeInputError(false);
}
} else {
setTimeInputError(false);
}
},
[userTimeFormat, min, max]
);
const filteredOptions = React.useMemo(() => {
const regex = /^(\d{1,2})(a|p|am|pm)$/i;
const match = inputValue.replaceAll(" ", "").match(regex);
if (!match) {
return options.filter((option) =>
defaultFilter({ ...option, data: option.label, value: option.label }, inputValue)
);
const dropdownOptions = options.filter((option) =>
defaultFilter(
{ ...option, data: option.label, value: option.label },
inputValue
)
);
const trimmedInput = inputValue.trim();
if (trimmedInput) {
const parsedTime = parseTimeString(trimmedInput, userTimeFormat);
if (parsedTime) {
const parsedDayjs = dayjs(parsedTime);
// Validate against min/max bounds using same logic as filter function
const withinBounds =
(!min || parsedDayjs.isAfter(min)) &&
(!max || parsedDayjs.isBefore(max));
if (withinBounds) {
const parsedTimestamp = parsedTime.valueOf();
const existsInOptions = options.some(
(option) => option.value === parsedTimestamp
);
if (!existsInOptions) {
const manualOption: IOption = {
label: dayjs(parsedTime)
.utc()
.format(userTimeFormat === 12 ? "h:mma" : "HH:mm"),
value: parsedTimestamp,
};
return [manualOption, ...dropdownOptions];
}
}
}
}
const [, numberPart, periodPart] = match;
const periodLower = periodPart.toLowerCase();
const scoredOptions = options
.filter((option) => option.label && option.label.toLowerCase().includes(periodLower))
.map((option) => {
const labelLower = option.label.toLowerCase();
const index = labelLower.indexOf(numberPart);
const score = index >= 0 ? index + labelLower.length : Infinity;
return { score, option };
})
.sort((a, b) => a.score - b.score);
return dropdownOptions;
}, [inputValue, options, defaultFilter, userTimeFormat, min, max]);
const maxScore = scoredOptions[0]?.score;
return scoredOptions.filter((item) => item.score === maxScore).map((item) => item.option);
}, [inputValue, options, defaultFilter]);
const currentValue = dayjs(value).toDate().valueOf();
const currentOption =
options.find((option) => option.value === currentValue) ||
(value
? {
value: currentValue,
label: dayjs(value)
.utc()
.format(userTimeFormat === 12 ? "h:mma" : "HH:mm"),
}
: null);
const errorInnerClassNames: SelectInnerClassNames = {
...innerClassNames,
control: cn(innerClassNames?.control, timeInputError && "!border-error"),
};
return (
<Select
@@ -461,11 +558,16 @@ const LazySelect = ({
if (!min && !max) filter({ offset: 0, limit: 0 });
}}
menuPlacement={menuPlacement}
value={options.find((option) => option.value === dayjs(value).toDate().valueOf())}
value={currentOption}
onMenuClose={() => filter({ current: value })}
components={{ DropdownIndicator: () => null, IndicatorSeparator: () => null }}
onInputChange={setInputValue}
components={{
DropdownIndicator: () => null,
IndicatorSeparator: () => null,
}}
onInputChange={handleInputChange}
filterOption={() => true}
innerClassNames={errorInnerClassNames}
onChange={onChange}
{...props}
/>
);
@@ -514,17 +616,34 @@ const useOptions = (timeFormat: number | null) => {
const filter = useCallback(
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
if (current) {
const currentOption = options.find((option) => option.value === dayjs(current).toDate().valueOf());
if (currentOption) setFilteredOptions([currentOption]);
const currentValue = dayjs(current).toDate().valueOf();
const currentOption = options.find(
(option) => option.value === currentValue
);
if (currentOption) {
setFilteredOptions([currentOption]);
} else {
// Create temporary option for custom time not in predefined options
const customOption: IOption = {
value: currentValue,
label: dayjs(current)
.utc()
.format(timeFormat === 12 ? "h:mma" : "HH:mm"),
};
setFilteredOptions([customOption]);
}
} else
setFilteredOptions(
options.filter((option) => {
const time = dayjs(option.value);
return (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset));
return (
(!limit || time.isBefore(limit)) &&
(!offset || time.isAfter(offset))
);
})
);
},
[options]
[options, timeFormat]
);
return { options: filteredOptions, filter };
@@ -0,0 +1,236 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { parseTimeString } from "./Schedule";
describe("parseTimeString", () => {
beforeEach(() => {
vi.useFakeTimers();
// Set a consistent date for testing
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("24-hour format (timeFormat = 24 or null)", () => {
it("should parse valid 24h format (HH:mm)", () => {
const result = parseTimeString("16:05", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(16);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should parse valid 24h format with single digit hour (via h:mma fallback)", () => {
const result = parseTimeString("8:05am", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(8);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should parse valid 12h format when user prefers 24h (format fallback)", () => {
const result = parseTimeString("4:05pm", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(16);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should parse midnight (00:00)", () => {
const result = parseTimeString("00:00", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(0);
expect(result?.getUTCMinutes()).toBe(0);
});
it("should parse end of day (23:59)", () => {
const result = parseTimeString("23:59", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(23);
expect(result?.getUTCMinutes()).toBe(59);
});
it("should return null for invalid hours (> 23)", () => {
const result = parseTimeString("24:00", 24);
expect(result).toBeNull();
});
it("should return null for invalid minutes (> 59)", () => {
const result = parseTimeString("16:60", 24);
expect(result).toBeNull();
});
it("should return null for negative hours", () => {
const result = parseTimeString("-1:00", 24);
expect(result).toBeNull();
});
it("should return null for negative minutes", () => {
const result = parseTimeString("16:-5", 24);
expect(result).toBeNull();
});
it("should return null for invalid format", () => {
const result = parseTimeString("invalid", 24);
expect(result).toBeNull();
});
it("should return null for empty string", () => {
const result = parseTimeString("", 24);
expect(result).toBeNull();
});
it("should return null for whitespace only", () => {
const result = parseTimeString(" ", 24);
expect(result).toBeNull();
});
it("should handle null timeFormat as 24h", () => {
const result = parseTimeString("16:05", null);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(16);
expect(result?.getUTCMinutes()).toBe(5);
});
});
describe("12-hour format (timeFormat = 12)", () => {
it("should parse valid 12h format with am (h:mma)", () => {
const result = parseTimeString("4:05am", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(4);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should parse valid 12h format with pm (h:mma)", () => {
const result = parseTimeString("4:05pm", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(16);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should parse valid 12h format with single digit hour", () => {
const result = parseTimeString("8:30am", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(8);
expect(result?.getUTCMinutes()).toBe(30);
});
it("should parse 12:00am (midnight)", () => {
const result = parseTimeString("12:00am", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(0);
expect(result?.getUTCMinutes()).toBe(0);
});
it("should parse 12:00pm (noon)", () => {
const result = parseTimeString("12:00pm", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(12);
expect(result?.getUTCMinutes()).toBe(0);
});
it("should parse 11:59pm (end of day)", () => {
const result = parseTimeString("11:59pm", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(23);
expect(result?.getUTCMinutes()).toBe(59);
});
it("should parse valid 24h format when user prefers 12h (format fallback)", () => {
const result = parseTimeString("16:05", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(16);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should return null for invalid format", () => {
const result = parseTimeString("invalid", 12);
expect(result).toBeNull();
});
it("should return null for empty string", () => {
const result = parseTimeString("", 12);
expect(result).toBeNull();
});
});
describe("custom times (5-minute intervals)", () => {
it("should parse 16:05 in 24h format", () => {
const result = parseTimeString("16:05", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(16);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should parse 08:20 in 24h format", () => {
const result = parseTimeString("08:20", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(8);
expect(result?.getUTCMinutes()).toBe(20);
});
it("should parse 4:05pm in 12h format", () => {
const result = parseTimeString("4:05pm", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(16);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should parse 8:20am in 12h format", () => {
const result = parseTimeString("8:20am", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(8);
expect(result?.getUTCMinutes()).toBe(20);
});
});
describe("edge cases", () => {
it("should handle times with leading zeros", () => {
const result = parseTimeString("09:05", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(9);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should handle times without leading zeros (via h:mma fallback)", () => {
const result = parseTimeString("9:05am", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(9);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should set seconds and milliseconds to 0", () => {
const result = parseTimeString("16:05", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCSeconds()).toBe(0);
expect(result?.getUTCMilliseconds()).toBe(0);
});
it("should preserve exact minutes (no rounding)", () => {
const result = parseTimeString("16:07", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCMinutes()).toBe(7);
});
it("should preserve exact minutes in 12h format (no rounding)", () => {
const result = parseTimeString("4:07pm", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCMinutes()).toBe(7);
});
});
describe("format acceptance (both formats)", () => {
it("should accept 24h format when user prefers 12h (format fallback)", () => {
const result = parseTimeString("16:05", 12);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(16);
expect(result?.getUTCMinutes()).toBe(5);
});
it("should accept 12h format when user prefers 24h (format fallback)", () => {
const result = parseTimeString("4:05pm", 24);
expect(result).toBeInstanceOf(Date);
expect(result?.getUTCHours()).toBe(16);
expect(result?.getUTCMinutes()).toBe(5);
});
});
});