Improve time input functionality and fix related test cases
Replace native time input with a custom text input and parser, enabling support for various time formats and fixing associated end-to-end tests. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 4ef80303-e96f-4ad0-ba0f-8fce49b7b340 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/HJW50TH Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// Unit tests for parseTypedTime — the human-input time parser used by
|
||||
// the executive-meetings inline time editor. Covers every shape listed
|
||||
// in task #308's "Done looks like" plus the obvious invalid edges.
|
||||
//
|
||||
// Runs with Node 24's built-in test runner + native TypeScript support.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseTypedTime } from "../lib/parse-time.ts";
|
||||
|
||||
test("returns null for empty / whitespace-only input", () => {
|
||||
assert.equal(parseTypedTime(""), null);
|
||||
assert.equal(parseTypedTime(" "), null);
|
||||
assert.equal(parseTypedTime("\t\n"), null);
|
||||
});
|
||||
|
||||
test("canonical 24h HH:MM passes through unchanged", () => {
|
||||
assert.equal(parseTypedTime("09:30"), "09:30");
|
||||
assert.equal(parseTypedTime("00:00"), "00:00");
|
||||
assert.equal(parseTypedTime("23:59"), "23:59");
|
||||
assert.equal(parseTypedTime("12:00"), "12:00");
|
||||
});
|
||||
|
||||
test("zero-padding is added for single-digit hours / minutes", () => {
|
||||
assert.equal(parseTypedTime("9:30"), "09:30");
|
||||
assert.equal(parseTypedTime("9:5"), "09:05");
|
||||
assert.equal(parseTypedTime("0:0"), "00:00");
|
||||
});
|
||||
|
||||
test("compact 3-4 digit form parses as HHMM / HMM", () => {
|
||||
assert.equal(parseTypedTime("0930"), "09:30");
|
||||
assert.equal(parseTypedTime("930"), "09:30");
|
||||
assert.equal(parseTypedTime("1230"), "12:30");
|
||||
assert.equal(parseTypedTime("0000"), "00:00");
|
||||
assert.equal(parseTypedTime("2359"), "23:59");
|
||||
// 3-digit form: first digit is the hour, last two the minutes.
|
||||
assert.equal(parseTypedTime("100"), "01:00");
|
||||
});
|
||||
|
||||
test("alternate separators (., -, whitespace) are accepted", () => {
|
||||
assert.equal(parseTypedTime("9.30"), "09:30");
|
||||
assert.equal(parseTypedTime("9-30"), "09:30");
|
||||
assert.equal(parseTypedTime("9 30"), "09:30");
|
||||
assert.equal(parseTypedTime(" 9 . 30 "), "09:30");
|
||||
});
|
||||
|
||||
test("English AM/PM markers are honoured", () => {
|
||||
assert.equal(parseTypedTime("9:30 AM"), "09:30");
|
||||
assert.equal(parseTypedTime("9:30am"), "09:30");
|
||||
assert.equal(parseTypedTime("9:30 PM"), "21:30");
|
||||
assert.equal(parseTypedTime("9:30pm"), "21:30");
|
||||
// Dotted forms.
|
||||
assert.equal(parseTypedTime("9:30 a.m."), "09:30");
|
||||
assert.equal(parseTypedTime("9:30 p.m."), "21:30");
|
||||
// 12 AM = midnight, 12 PM = noon.
|
||||
assert.equal(parseTypedTime("12:00 AM"), "00:00");
|
||||
assert.equal(parseTypedTime("12:30 AM"), "00:30");
|
||||
assert.equal(parseTypedTime("12:00 PM"), "12:00");
|
||||
assert.equal(parseTypedTime("12:30 PM"), "12:30");
|
||||
});
|
||||
|
||||
test("Arabic AM/PM markers (ص / م) are honoured", () => {
|
||||
assert.equal(parseTypedTime("9:30 ص"), "09:30");
|
||||
assert.equal(parseTypedTime("9:30 م"), "21:30");
|
||||
assert.equal(parseTypedTime("12:00 ص"), "00:00");
|
||||
assert.equal(parseTypedTime("12:00 م"), "12:00");
|
||||
// No space between time and marker.
|
||||
assert.equal(parseTypedTime("9:30م"), "21:30");
|
||||
});
|
||||
|
||||
test("Arabic-Indic digits (٠-٩) are mapped to ASCII", () => {
|
||||
assert.equal(parseTypedTime("٠٩:٣٠"), "09:30");
|
||||
assert.equal(parseTypedTime("٩:٣٠"), "09:30");
|
||||
assert.equal(parseTypedTime("٩:٣٠ ص"), "09:30");
|
||||
assert.equal(parseTypedTime("٩:٣٠ م"), "21:30");
|
||||
assert.equal(parseTypedTime("٢٣:٥٩"), "23:59");
|
||||
});
|
||||
|
||||
test("Persian / Eastern Arabic-Indic digits (۰-۹) are mapped to ASCII", () => {
|
||||
assert.equal(parseTypedTime("۰۹:۳۰"), "09:30");
|
||||
assert.equal(parseTypedTime("۱۲:۴۵"), "12:45");
|
||||
});
|
||||
|
||||
test("invalid hours/minutes return null", () => {
|
||||
assert.equal(parseTypedTime("24:00"), null);
|
||||
assert.equal(parseTypedTime("25:99"), null);
|
||||
assert.equal(parseTypedTime("9:60"), null);
|
||||
assert.equal(parseTypedTime("99:99"), null);
|
||||
});
|
||||
|
||||
test("12-hour-only hours out of 1..12 reject when AM/PM is given", () => {
|
||||
assert.equal(parseTypedTime("0:30 AM"), null);
|
||||
assert.equal(parseTypedTime("13:00 PM"), null);
|
||||
assert.equal(parseTypedTime("13:00 ص"), null);
|
||||
});
|
||||
|
||||
test("garbage input returns null", () => {
|
||||
assert.equal(parseTypedTime("abc"), null);
|
||||
// 3-segment form (HH:MM:SS) is rejected — we don't support seconds.
|
||||
assert.equal(parseTypedTime("9:30:45"), null);
|
||||
assert.equal(parseTypedTime(":30"), null);
|
||||
assert.equal(parseTypedTime("9:"), null);
|
||||
// 5-digit compact form is not a valid HHMM/HMM.
|
||||
assert.equal(parseTypedTime("12345"), null);
|
||||
// Mixed-with-letters input is rejected.
|
||||
assert.equal(parseTypedTime("9h30m"), null);
|
||||
});
|
||||
|
||||
test("collapses repeated separators (lenient typo recovery)", () => {
|
||||
// Repeated separator characters are treated as one: this is a
|
||||
// deliberate "be liberal in what you accept" choice so a user who
|
||||
// double-tapped ":" or mixed "." and " " still gets a valid time
|
||||
// rather than a useless parse error.
|
||||
assert.equal(parseTypedTime("9::30"), "09:30");
|
||||
assert.equal(parseTypedTime("9.. 30"), "09:30");
|
||||
});
|
||||
|
||||
test("non-string inputs return null", () => {
|
||||
// @ts-expect-error - intentionally testing the runtime guard
|
||||
assert.equal(parseTypedTime(null), null);
|
||||
// @ts-expect-error
|
||||
assert.equal(parseTypedTime(undefined), null);
|
||||
// @ts-expect-error
|
||||
assert.equal(parseTypedTime(930), null);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// Pure, unit-testable parser for human-typed times in the executive
|
||||
// meetings inline time editor. The native <input type="time"> only
|
||||
// surfaces a value to the controlled state when the typed text is a
|
||||
// complete, canonical "HH:MM" 24h string — partial input, "9:30"
|
||||
// without a leading zero, "0930" compact, "9:30 PM" / "9:30 ص", or
|
||||
// Arabic-Indic digits all leave the React state stale, which made
|
||||
// "type then press Enter" a silent no-op. We replace the native time
|
||||
// input with a plain text input and route every save path through
|
||||
// this parser so what the user types is what gets saved.
|
||||
//
|
||||
// Returns canonical "HH:MM" (24h, zero-padded) or null when the input
|
||||
// cannot be parsed. Empty / whitespace-only input returns null too —
|
||||
// the caller decides whether that means "clear the time" (current
|
||||
// behaviour) or "show a parse error".
|
||||
|
||||
export function parseTypedTime(input: string): string | null {
|
||||
if (typeof input !== "string") return null;
|
||||
let s = input.trim();
|
||||
if (!s) return null;
|
||||
|
||||
// Map Arabic-Indic (٠-٩) and Persian/Eastern Arabic-Indic (۰-۹)
|
||||
// digits to ASCII so an Arabic user typing "٠٩:٣٠" parses identically
|
||||
// to "09:30".
|
||||
s = s.replace(/[\u0660-\u0669]/g, (d) =>
|
||||
String(d.charCodeAt(0) - 0x0660),
|
||||
);
|
||||
s = s.replace(/[\u06F0-\u06F9]/g, (d) =>
|
||||
String(d.charCodeAt(0) - 0x06F0),
|
||||
);
|
||||
|
||||
// Detect a trailing AM/PM marker. Accepts English (am/pm, a.m./p.m.,
|
||||
// any case) and Arabic (ص for morning/AM, م for evening/PM). The
|
||||
// marker is stripped before numeric parsing and applied at the end.
|
||||
let isAm = false;
|
||||
let isPm = false;
|
||||
const amPmMatch = /\s*(a\.?m\.?|p\.?m\.?|ص|م)\s*$/i.exec(s);
|
||||
if (amPmMatch) {
|
||||
const m = amPmMatch[1].toLowerCase().replace(/\./g, "");
|
||||
if (m === "am" || m === "ص") isAm = true;
|
||||
else if (m === "pm" || m === "م") isPm = true;
|
||||
s = s.slice(0, amPmMatch.index).trim();
|
||||
}
|
||||
|
||||
if (!s) return null;
|
||||
|
||||
let h: number;
|
||||
let mm: number;
|
||||
|
||||
// Compact 3-4 digit form with no separator: "930" -> 9:30, "0930" ->
|
||||
// 09:30, "1230" -> 12:30. We branch on length so "930" doesn't get
|
||||
// read as 93:0.
|
||||
if (/^\d{3,4}$/.test(s)) {
|
||||
if (s.length === 3) {
|
||||
h = Number(s[0]);
|
||||
mm = Number(s.slice(1));
|
||||
} else {
|
||||
h = Number(s.slice(0, 2));
|
||||
mm = Number(s.slice(2));
|
||||
}
|
||||
} else {
|
||||
// Accept ":", ".", "-", or whitespace as the H/M separator. Collapse
|
||||
// runs so "9 . 30" still parses cleanly.
|
||||
const normalized = s.replace(/[.\-\s]+/g, ":").replace(/:+/g, ":");
|
||||
const parts = normalized.split(":");
|
||||
if (parts.length !== 2) return null;
|
||||
if (!/^\d{1,2}$/.test(parts[0])) return null;
|
||||
if (!/^\d{1,2}$/.test(parts[1])) return null;
|
||||
h = Number(parts[0]);
|
||||
mm = Number(parts[1]);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
|
||||
if (mm < 0 || mm > 59) return null;
|
||||
|
||||
if (isAm || isPm) {
|
||||
// 12h shapes only allow 1..12 for the hour. "13:00 PM" is rejected.
|
||||
if (h < 1 || h > 12) return null;
|
||||
if (isAm) {
|
||||
// 12 AM = midnight = 00:xx.
|
||||
if (h === 12) h = 0;
|
||||
} else {
|
||||
// 12 PM = noon = 12:xx; everything else gets +12.
|
||||
if (h !== 12) h += 12;
|
||||
}
|
||||
} else {
|
||||
if (h < 0 || h > 23) return null;
|
||||
}
|
||||
|
||||
const hStr = String(h).padStart(2, "0");
|
||||
const mStr = String(mm).padStart(2, "0");
|
||||
return `${hStr}:${mStr}`;
|
||||
}
|
||||
@@ -1163,6 +1163,7 @@
|
||||
"timeStartShort": "البدء",
|
||||
"timeEndShort": "الانتهاء",
|
||||
"timeOrderError": "وقت النهاية قبل وقت البداية",
|
||||
"timeParseError": "صيغة الوقت غير صحيحة. اكتب الوقت مثل: 09:30 أو 9:30 ص",
|
||||
"addAttendee": "أضف حاضر",
|
||||
"addSubheading": "+ عنوان فرعي",
|
||||
"addVirtualAttendee": "+ حاضر عبر الاتصال المرئي",
|
||||
|
||||
@@ -1046,6 +1046,7 @@
|
||||
"timeStartShort": "Start",
|
||||
"timeEndShort": "End",
|
||||
"timeOrderError": "End time is before start time",
|
||||
"timeParseError": "Time format isn't valid. Try 09:30 or 9:30 PM",
|
||||
"addAttendee": "Add attendee",
|
||||
"addSubheading": "+ subheading",
|
||||
"addVirtualAttendee": "+ Virtual",
|
||||
|
||||
@@ -88,6 +88,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { EditableCell } from "@/components/editable-cell";
|
||||
import { safeHtml } from "@/lib/safe-html";
|
||||
import { parseTypedTime } from "@/lib/parse-time";
|
||||
import {
|
||||
ALERT_COLOR_PRESETS,
|
||||
DEFAULT_ALERT_PREFS,
|
||||
@@ -3361,6 +3362,7 @@ function TimeRangeCell({
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const startInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const endInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// If the saved times change while we're not editing (e.g. another tab
|
||||
// updated them, or the user picked a different date), sync local state.
|
||||
@@ -3374,6 +3376,10 @@ function TimeRangeCell({
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
startInputRef.current?.focus();
|
||||
// Select the existing value so the user can type to overwrite
|
||||
// immediately without having to clear the field first — matches
|
||||
// the behaviour of the native <input type="time"> spinner.
|
||||
startInputRef.current?.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
@@ -3401,8 +3407,42 @@ function TimeRangeCell({
|
||||
}, [canMutate, editing, startSaved, endSaved]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const newStart = start || null;
|
||||
const newEnd = end || null;
|
||||
// Always read the live input values from the refs in addition to
|
||||
// the controlled state. This is belt-and-braces against any path
|
||||
// (Enter pressed before React's batched onChange flush, browser
|
||||
// autofill, etc.) where the controlled state could be a frame
|
||||
// behind what the user actually sees. parseTypedTime accepts a
|
||||
// wide range of human-typed shapes (24h, 12h with AM/PM or ص/م,
|
||||
// compact "HHMM", Arabic-Indic digits) and canonicalises them to
|
||||
// "HH:MM"; empty input means "clear the time".
|
||||
const rawStart = (startInputRef.current?.value ?? start ?? "").trim();
|
||||
const rawEnd = (endInputRef.current?.value ?? end ?? "").trim();
|
||||
const newStart = rawStart === "" ? null : parseTypedTime(rawStart);
|
||||
const newEnd = rawEnd === "" ? null : parseTypedTime(rawEnd);
|
||||
|
||||
// If the user typed something in either field but it cannot be
|
||||
// parsed, surface a clear destructive toast and keep the editor
|
||||
// open with focus on the offending input — never silently discard
|
||||
// their typing.
|
||||
if (rawStart !== "" && newStart === null) {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.timeParseError"),
|
||||
variant: "destructive",
|
||||
});
|
||||
startInputRef.current?.focus();
|
||||
startInputRef.current?.select();
|
||||
return;
|
||||
}
|
||||
if (rawEnd !== "" && newEnd === null) {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.timeParseError"),
|
||||
variant: "destructive",
|
||||
});
|
||||
endInputRef.current?.focus();
|
||||
endInputRef.current?.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStart === (startSaved || null) && newEnd === (endSaved || null)) {
|
||||
setEditing(false);
|
||||
return;
|
||||
@@ -3417,6 +3457,12 @@ function TimeRangeCell({
|
||||
startInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
// Snap the controlled state to the canonical "HH:MM" so the
|
||||
// collapsed display (and any subsequent re-open of the editor)
|
||||
// shows the same value the server now holds, even if the user
|
||||
// typed a non-canonical shape like "9:30" or "0930".
|
||||
setStart(newStart ?? "");
|
||||
setEnd(newEnd ?? "");
|
||||
try {
|
||||
await onSaveTimes(newStart, newEnd);
|
||||
setEditing(false);
|
||||
@@ -3435,7 +3481,7 @@ function TimeRangeCell({
|
||||
setEnd(endSaved);
|
||||
setEditing(false);
|
||||
}
|
||||
}, [start, end, startSaved, endSaved, onSaveTimes]);
|
||||
}, [start, end, startSaved, endSaved, onSaveTimes, toast, t]);
|
||||
|
||||
const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (!editing) return;
|
||||
@@ -3530,12 +3576,23 @@ function TimeRangeCell({
|
||||
<input
|
||||
ref={startInputRef}
|
||||
id={startInputId}
|
||||
type="time"
|
||||
// Plain text input (not type="time") so the controlled state
|
||||
// updates on EVERY keystroke. The native <input type="time">
|
||||
// only fires onChange when the value is a complete, canonical
|
||||
// "HH:MM" string, which made keyboard typing + Enter a silent
|
||||
// no-op for partial input, "9:30" without leading zero,
|
||||
// "0930" compact, AM/PM shapes, and Arabic-Indic digits.
|
||||
// parseTypedTime() handles all of those on save.
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="HH:MM"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={t("executiveMeetings.schedule.timeStart")}
|
||||
className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white"
|
||||
className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white text-center"
|
||||
data-testid={`em-time-start-${meeting.id}`}
|
||||
/>
|
||||
</div>
|
||||
@@ -3548,13 +3605,20 @@ function TimeRangeCell({
|
||||
{t("executiveMeetings.schedule.timeEndShort")}
|
||||
</label>
|
||||
<input
|
||||
ref={endInputRef}
|
||||
id={endInputId}
|
||||
type="time"
|
||||
// See the start input above for why this is type="text" and
|
||||
// not type="time".
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="HH:MM"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={t("executiveMeetings.schedule.timeEnd")}
|
||||
className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white"
|
||||
className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white text-center"
|
||||
data-testid={`em-time-end-${meeting.id}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -698,3 +698,266 @@ test("Schedule keyboard: Tab from the title cell reaches the time cell in the sa
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toBeFocused();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Task #308: typing on the keyboard + Enter must save.
|
||||
//
|
||||
// The historical bug: the time cell used <input type="time">, which
|
||||
// only fires onChange when the value is a complete, canonical "HH:MM"
|
||||
// string. Typing "9:30" without a leading zero, "0930" compact, "9:30
|
||||
// PM", or Arabic-Indic digits left the controlled React state empty,
|
||||
// so pressing Enter ran save() with the stale value, equality with
|
||||
// the saved value short-circuited, no PATCH fired, and the cell
|
||||
// snapped back to "—" (or the prior value) — looking to the user
|
||||
// like Enter "didn't save".
|
||||
//
|
||||
// These cases exercise each typed shape through the full UI stack
|
||||
// (keystrokes → React state → save() → PATCH → DB → refetched cell
|
||||
// text) so a regression at any layer trips a test.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Reusable helper: open a meeting's time cell, focus the start input,
|
||||
// clear it, type a new value, do the same for end, press Enter, and
|
||||
// wait for the PATCH + day GET refetch.
|
||||
async function typeTimesAndSave(page, meetingId, date, startText, endText) {
|
||||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||||
await timeCell.evaluate((el) => el.focus());
|
||||
await page.keyboard.press("Enter");
|
||||
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
|
||||
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
|
||||
await expect(startInput).toBeVisible();
|
||||
await expect(startInput).toBeFocused();
|
||||
|
||||
// Clear and type into start. Use page.keyboard.type for the AR-digit
|
||||
// case so the OS IME doesn't intercept; for ASCII we use fill() which
|
||||
// is faster and equivalent.
|
||||
await startInput.click();
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page.keyboard.press("Delete");
|
||||
await page.keyboard.type(startText, { delay: 10 });
|
||||
|
||||
await endInput.click();
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page.keyboard.press("Delete");
|
||||
await page.keyboard.type(endText, { delay: 10 });
|
||||
|
||||
const savePromise = page.waitForResponse(
|
||||
(resp) => {
|
||||
const u = new URL(resp.url());
|
||||
return (
|
||||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||||
resp.request().method() === "PATCH" &&
|
||||
resp.ok()
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const refetchPromise = waitForDayRefetch(page, date);
|
||||
await page.keyboard.press("Enter");
|
||||
await Promise.all([savePromise, refetchPromise]);
|
||||
}
|
||||
|
||||
// Each row in this matrix is one accepted typing shape from the task's
|
||||
// "Done looks like" list. Both fields use the same shape so the
|
||||
// assertions stay simple, and the tested shapes intentionally vary in
|
||||
// hour/minute so a test that accidentally passes by reading stale
|
||||
// state (rather than the new value) would fail.
|
||||
const TYPING_SHAPES = [
|
||||
{ offset: 8, label: "canonical 24h", startText: "13:30", endText: "14:45", expectStart: "13:30:00", expectEnd: "14:45:00" },
|
||||
{ offset: 9, label: "no leading zero", startText: "9:30", endText: "10:45", expectStart: "09:30:00", expectEnd: "10:45:00" },
|
||||
{ offset: 10, label: "compact HHMM", startText: "0930", endText: "1045", expectStart: "09:30:00", expectEnd: "10:45:00" },
|
||||
{ offset: 11, label: "12h with PM", startText: "9:30 AM", endText: "9:30 PM", expectStart: "09:30:00", expectEnd: "21:30:00" },
|
||||
];
|
||||
|
||||
for (const shape of TYPING_SHAPES) {
|
||||
test(`Schedule keyboard: typing "${shape.label}" + Enter saves and PATCH succeeds`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await setLangEn(page);
|
||||
// Each shape gets its own future date offset so the loop never
|
||||
// collides on the (date, daily_number) unique constraint when
|
||||
// these tests run in the same worker.
|
||||
const date = uniqueFutureDate(shape.offset);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `KbdType ${shape.label} ${Date.now().toString(36)}`,
|
||||
titleAr: `إدخال ${shape.label} ${Date.now().toString(36)}`,
|
||||
// Seed with an empty time cell so we exercise the
|
||||
// "type from scratch" path the user reported broken.
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
});
|
||||
|
||||
await gotoMeetingRow(page, meetingId, date);
|
||||
|
||||
await typeTimesAndSave(page, meetingId, date, shape.startText, shape.endText);
|
||||
|
||||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||||
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveCount(0);
|
||||
// Don't pin the displayed text format — formatTime() picks 12h/24h
|
||||
// per locale and the dash glyph varies. The DB row is the source
|
||||
// of truth and is asserted below.
|
||||
await expect(timeCell).not.toHaveText("—", { timeout: 10_000 });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||||
[meetingId],
|
||||
);
|
||||
expect(String(rows[0].start_time)).toBe(shape.expectStart);
|
||||
expect(String(rows[0].end_time)).toBe(shape.expectEnd);
|
||||
});
|
||||
}
|
||||
|
||||
test("Schedule keyboard (AR): typing Arabic-Indic digits + Enter saves canonical HH:MM", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Default lang is Arabic — don't set EN. This mirrors how an Arabic
|
||||
// user types times from their keyboard layout.
|
||||
const date = uniqueFutureDate(13);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `KbdAr ${Date.now().toString(36)}`,
|
||||
titleAr: `أرقام عربية ${Date.now().toString(36)}`,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
});
|
||||
|
||||
await gotoMeetingRow(page, meetingId, date);
|
||||
|
||||
// ٠٩:٣٠ -> 09:30, ١٠:٤٥ -> 10:45
|
||||
await typeTimesAndSave(page, meetingId, date, "٠٩:٣٠", "١٠:٤٥");
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||||
[meetingId],
|
||||
);
|
||||
expect(String(rows[0].start_time)).toBe("09:30:00");
|
||||
expect(String(rows[0].end_time)).toBe("10:45:00");
|
||||
});
|
||||
|
||||
test("Schedule keyboard: typing an unparseable time + Enter shows a toast and does NOT save", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setLangEn(page);
|
||||
const date = uniqueFutureDate(14);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `KbdBad ${Date.now().toString(36)}`,
|
||||
titleAr: `غير صالح ${Date.now().toString(36)}`,
|
||||
startTime: "08:00:00",
|
||||
endTime: "09:00:00",
|
||||
});
|
||||
|
||||
await gotoMeetingRow(page, meetingId, date);
|
||||
|
||||
// Spy on PATCH — none should fire for an unparseable typed value.
|
||||
let patchSeen = false;
|
||||
page.on("response", (resp) => {
|
||||
try {
|
||||
const u = new URL(resp.url());
|
||||
if (
|
||||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||||
resp.request().method() === "PATCH"
|
||||
) {
|
||||
patchSeen = true;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||||
await timeCell.evaluate((el) => el.focus());
|
||||
await page.keyboard.press("Enter");
|
||||
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
|
||||
await expect(startInput).toBeFocused();
|
||||
await startInput.click();
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page.keyboard.press("Delete");
|
||||
await page.keyboard.type("25:99", { delay: 10 });
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
// Editor must STAY open so the user can fix the typo. Toast surfaces
|
||||
// the parse error.
|
||||
await expect(startInput).toBeVisible();
|
||||
await expect(startInput).toBeFocused();
|
||||
// Match the toast title specifically. The toast also surfaces an
|
||||
// ARIA live-region copy for screen readers, so without a scope the
|
||||
// pattern resolves to two elements and trips strict mode.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-component-name="ToastTitle"]')
|
||||
.filter({ hasText: /Time format isn't valid|اكتب الوقت مثل/ }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// No PATCH should have been sent for the bad value.
|
||||
await page.waitForTimeout(300);
|
||||
expect(patchSeen).toBe(false);
|
||||
|
||||
// DB stayed at the seeded values.
|
||||
const { rows } = await pool.query(
|
||||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||||
[meetingId],
|
||||
);
|
||||
expect(String(rows[0].start_time)).toBe("08:00:00");
|
||||
expect(String(rows[0].end_time)).toBe("09:00:00");
|
||||
});
|
||||
|
||||
test("Schedule keyboard: Tab between start and end preserves the typed start value, Enter on end saves both", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setLangEn(page);
|
||||
const date = uniqueFutureDate(15);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `KbdTab ${Date.now().toString(36)}`,
|
||||
titleAr: `تابات ${Date.now().toString(36)}`,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
});
|
||||
|
||||
await gotoMeetingRow(page, meetingId, date);
|
||||
|
||||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||||
await timeCell.evaluate((el) => el.focus());
|
||||
await page.keyboard.press("Enter");
|
||||
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
|
||||
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
|
||||
await expect(startInput).toBeFocused();
|
||||
|
||||
// Type into start, then Tab forward and type into end. The Tab
|
||||
// commit path is what historically lost the start value when the
|
||||
// input was type="time" because no canonical onChange had fired yet.
|
||||
await page.keyboard.type("7:15", { delay: 10 });
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(endInput).toBeFocused();
|
||||
await page.keyboard.type("8:45", { delay: 10 });
|
||||
|
||||
const savePromise = page.waitForResponse(
|
||||
(resp) => {
|
||||
const u = new URL(resp.url());
|
||||
return (
|
||||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||||
resp.request().method() === "PATCH" &&
|
||||
resp.ok()
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const refetchPromise = waitForDayRefetch(page, date);
|
||||
await page.keyboard.press("Enter");
|
||||
await Promise.all([savePromise, refetchPromise]);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||||
[meetingId],
|
||||
);
|
||||
expect(String(rows[0].start_time)).toBe("07:15:00");
|
||||
expect(String(rows[0].end_time)).toBe("08:45:00");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user