fix: Custom time input for availability (#26373)
* add custom time input * add unit test * add validation logic
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user