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:
riyadhafraa
2026-05-02 08:39:00 +00:00
parent 5748ec773b
commit e4aa4fa280
7 changed files with 552 additions and 7 deletions
@@ -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);
});
+92
View File
@@ -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}`;
}
+1
View File
@@ -1163,6 +1163,7 @@
"timeStartShort": "البدء",
"timeEndShort": "الانتهاء",
"timeOrderError": "وقت النهاية قبل وقت البداية",
"timeParseError": "صيغة الوقت غير صحيحة. اكتب الوقت مثل: 09:30 أو 9:30 ص",
"addAttendee": "أضف حاضر",
"addSubheading": "+ عنوان فرعي",
"addVirtualAttendee": "+ حاضر عبر الاتصال المرئي",
+1
View File
@@ -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");
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB