feat(executive-meetings): replace native window.confirm with in-app AlertDialog (#342)

Native window.confirm exposes the Repl hostname and ignores the app's
RTL/branding. The user (Arabic-speaking) flagged it as visually jarring
inside Executive Meetings.

Changes (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Add a small ConfirmProvider + useConfirm() backed by the existing
  shadcn AlertDialog (`@/components/ui/alert-dialog`). The hook returns
  Promise<boolean> so call sites stay one-liners:
    if (!(await confirm({ message, destructive: true }))) return;
- Mount the provider once around the page (split the default export
  into a thin wrapper + ExecutiveMeetingsPageInner so the inner tree
  can call useConfirm()).
- Centered title + description, destructive-styled confirm button,
  mobile-safe sizing (max-w-md, w-[calc(100%-2rem)], max-h-[90vh],
  overflow-y-auto), centered footer that stacks on small screens via
  the existing AlertDialogFooter classes.
- Replace all 5 window.confirm sites:
  1. Schedule single-row delete (deleteMeeting)
  2. Schedule bulk delete
  3. Manage bulk delete
  4. Manage single delete (confirmDelete)
  5. MeetingFormDialog "remove all attendees" (function turned async)
- Add `confirm` to the relevant useCallback deps (deleteMeeting,
  bulk delete) and inject useConfirm() inside ScheduleSection,
  ManageSection, MeetingFormDialog (all rendered inside the provider).

Reuses existing translation keys (deleteRowConfirm, bulkDeleteConfirm,
manage.deleteConfirm, attendees.removeAllConfirm). Title/labels fall
back via tWithFallback to Arabic strings if the i18n keys are missing.

Verified `pnpm -C artifacts/tx-os exec tsc --noEmit` is clean.
Pre-existing api-server / test workflow failures are unrelated to this
UI change (also failing before the task started).
This commit is contained in:
riyadhafraa
2026-05-03 12:14:03 +00:00
parent 5a004925d5
commit 7f5b662a28
@@ -1,5 +1,7 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
@@ -54,6 +56,16 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
@@ -575,7 +587,137 @@ function normalizeColumns(value: unknown): ColumnSetting[] {
return out;
}
// In-app replacement for `window.confirm`. The native browser dialog
// shows the Repl's hostname in its header and ignores the app's RTL/
// branding, which felt jarring inside the bilingual Executive Meetings
// page (#342). We expose an imperative `confirm({...})` that returns a
// Promise<boolean> so call sites read identically to the old
// `if (!window.confirm(msg)) return;` pattern — only the `await`
// changes. The dialog itself is a single shadcn `AlertDialog` mounted
// once at the page root via `ConfirmProvider`, so any nested component
// (Schedule, Manage, MeetingFormDialog) can call `useConfirm()` without
// prop drilling.
type ConfirmOptions = {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
};
const ConfirmContext = createContext<
((opts: ConfirmOptions) => Promise<boolean>) | null
>(null);
function useConfirm(): (opts: ConfirmOptions) => Promise<boolean> {
const ctx = useContext(ConfirmContext);
if (!ctx) {
throw new Error("useConfirm must be used inside <ConfirmProvider>");
}
return ctx;
}
function ConfirmProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
const isRtl = i18n.language === "ar";
const [pending, setPending] = useState<{
options: ConfirmOptions;
resolve: (v: boolean) => void;
} | null>(null);
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
// Single-flight: if a previous confirm is still pending (e.g. a
// second handler fired before the user clicked anything on the
// first dialog), settle the older Promise as `false` so its
// caller doesn't hang forever, then replace it with the new one.
setPending((prev) => {
if (prev) prev.resolve(false);
return { options, resolve };
});
});
}, []);
// Resolves the open promise exactly once and tears down the dialog.
// Guards against double-resolve when the user clicks Cancel while
// Radix is also firing onOpenChange(false) for the same close.
const settle = useCallback(
(value: boolean) => {
setPending((cur) => {
if (cur) cur.resolve(value);
return null;
});
},
[],
);
const opts = pending?.options;
const title =
opts?.title ??
tWithFallback(t, "executiveMeetings.common.confirmTitle", "تأكيد");
const confirmLabel =
opts?.confirmLabel ??
(opts?.destructive
? tWithFallback(t, "executiveMeetings.common.delete", "حذف")
: tWithFallback(t, "executiveMeetings.common.confirm", "تأكيد"));
const cancelLabel =
opts?.cancelLabel ??
tWithFallback(t, "executiveMeetings.common.cancel", "إلغاء");
return (
<ConfirmContext.Provider value={confirm}>
{children}
<AlertDialog
open={pending !== null}
onOpenChange={(open) => {
if (!open) settle(false);
}}
>
<AlertDialogContent
data-testid="em-confirm-dialog"
dir={isRtl ? "rtl" : "ltr"}
className="max-w-md w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto"
>
<AlertDialogHeader>
<AlertDialogTitle className="text-center">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-center text-base text-foreground whitespace-pre-line break-words">
{opts?.message ?? ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-center gap-2">
<AlertDialogCancel
data-testid="em-confirm-cancel"
onClick={() => settle(false)}
>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
data-testid="em-confirm-ok"
onClick={() => settle(true)}
className={
opts?.destructive
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: undefined
}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ConfirmContext.Provider>
);
}
export default function ExecutiveMeetingsPage() {
return (
<ConfirmProvider>
<ExecutiveMeetingsPageInner />
</ConfirmProvider>
);
}
function ExecutiveMeetingsPageInner() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
const isRtl = i18n.language === "ar";
@@ -824,6 +966,7 @@ function ScheduleSection({
}) {
const qc = useQueryClient();
const { toast } = useToast();
const confirm = useConfirm();
const tableStyle = buildFontStyle(font);
const setColumns = onColumnsChange;
// #288: Row highlight colours are now stored on the meeting row in the
@@ -1416,7 +1559,7 @@ function ScheduleSection({
"{{title}}",
plainTitle,
);
if (!window.confirm(message)) return;
if (!(await confirm({ message, destructive: true }))) return;
setDeletingMeetingId(meeting.id);
try {
await apiJson(`/api/executive-meetings/${meeting.id}`, {
@@ -1435,7 +1578,7 @@ function ScheduleSection({
setDeletingMeetingId(null);
}
},
[deletingMeetingId, isRtl, refreshDay, t, toast],
[confirm, deletingMeetingId, isRtl, refreshDay, t, toast],
);
// ─── Multi-select + bulk delete (edit mode only) ────────────────────
@@ -1678,7 +1821,7 @@ function ScheduleSection({
const message = t(
"executiveMeetings.schedule.bulkDeleteConfirm",
).replace("{{n}}", String(ids.length));
if (!window.confirm(message)) return;
if (!(await confirm({ message, destructive: true }))) return;
// Snapshot before deletion so undo can reconstruct the rows.
const idsSet = new Set(ids);
const snapshots = meetings.filter((m) => idsSet.has(m.id));
@@ -1774,6 +1917,7 @@ function ScheduleSection({
}
}, [
bulkDeleting,
confirm,
selectedMeetingIds,
filteredMeetings,
meetings,
@@ -5068,6 +5212,7 @@ function ManageSection({
}) {
const qc = useQueryClient();
const { toast } = useToast();
const confirm = useConfirm();
const [editing, setEditing] = useState<MeetingFormState | null>(null);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
@@ -5225,7 +5370,7 @@ function ManageSection({
const message = t(
"executiveMeetings.schedule.bulkDeleteConfirm",
).replace("{{n}}", String(ids.length));
if (!window.confirm(message)) return;
if (!(await confirm({ message, destructive: true }))) return;
setBulkDeleting(true);
try {
const results = await Promise.allSettled(
@@ -5407,7 +5552,13 @@ function ManageSection({
}
async function confirmDelete(id: number) {
if (!window.confirm(t("executiveMeetings.manage.deleteConfirm"))) return;
if (
!(await confirm({
message: t("executiveMeetings.manage.deleteConfirm"),
destructive: true,
}))
)
return;
setDeletingId(id);
try {
await apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" });
@@ -5967,6 +6118,7 @@ function MeetingFormDialog({
t: (k: string) => string;
}) {
const { toast } = useToast();
const confirm = useConfirm();
// Imperative handles to the start/end pickers so the Save click
// can read the live draft (typed text + AM/PM toggle) and surface
// ambiguous/invalid input as a toast rather than silently saving
@@ -6069,13 +6221,13 @@ function MeetingFormDialog({
// the user clicks the dialog's Save button (just like the per-row
// remove). Confirmation surfaces the count so users don't nuke a long
// list by accident.
function removeAllAttendees() {
async function removeAllAttendees() {
const count = state.attendees.length;
if (count === 0) return;
const message = t(
"executiveMeetings.manage.attendees.removeAllConfirm",
).replace("{{count}}", String(count));
if (!window.confirm(message)) return;
if (!(await confirm({ message, destructive: true }))) return;
onChange({ ...state, attendees: [] });
}
function setAttendee(i: number, patch: Partial<Attendee>) {