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:
@@ -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>) {
|
||||
|
||||
Reference in New Issue
Block a user