From c7268c837fb280198f9852ade6609e871b8ee313 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:00:33 +0000 Subject: [PATCH] [jules] enhance: Add confirmation dialog system for destructive actions - Created `web/contexts/ConfirmContext.tsx` with Promise-based API - Created `web/components/ui/ConfirmDialog.tsx` with dual-theme support - Integrated `ConfirmProvider` into `web/App.tsx` - Refactored `web/pages/GroupDetails.tsx` to replace `window.confirm` with `useConfirm` - Improved `Modal` accessibility by adding `role="dialog"` and `aria-modal="true"` - Verified with Playwright tests --- .Jules/changelog.md | 1 + .Jules/knowledge.md | 23 ++++++++++ .Jules/todo.md | 12 +++--- web/App.tsx | 19 ++++---- web/components/ui/ConfirmDialog.tsx | 44 +++++++++++++++++++ web/components/ui/Modal.tsx | 2 + web/contexts/ConfirmContext.tsx | 67 +++++++++++++++++++++++++++++ web/pages/GroupDetails.tsx | 30 +++++++++++-- 8 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 web/components/ui/ConfirmDialog.tsx create mode 100644 web/contexts/ConfirmContext.tsx diff --git a/.Jules/changelog.md b/.Jules/changelog.md index 16a7b0c..5f26e67 100644 --- a/.Jules/changelog.md +++ b/.Jules/changelog.md @@ -20,6 +20,7 @@ - Comprehensive `EmptyState` component for Groups and Friends pages to better guide new users. - Toast notification system (`ToastContext`, `Toast` component) for providing non-blocking user feedback. - Keyboard navigation support for Groups page, enabling accessibility for power users. +- Confirmation Dialog System (`ConfirmContext`, `ConfirmDialog`) replacing native browser alerts for destructive actions in `GroupDetails`. ### Changed - Updated JULES_PROMPT.md based on review of successful PRs: diff --git a/.Jules/knowledge.md b/.Jules/knowledge.md index ad48a44..090b071 100644 --- a/.Jules/knowledge.md +++ b/.Jules/knowledge.md @@ -205,6 +205,29 @@ addToast('Message', 'success|error|info'); - Auto-dismisses after 3 seconds - Stacks vertically in bottom-right +### Confirmation Dialog Pattern + +**Date:** 2026-01-14 +**Context:** Replacing window.confirm with custom modal + +```tsx +const { confirm } = useConfirm(); + +// In async handler +if (await confirm({ + title: 'Delete Item', + message: 'Are you sure?', + variant: 'danger', + confirmText: 'Delete' +})) { + // Proceed with deletion +} +``` + +- Promise-based API avoids callback hell +- Supports `danger` and `primary` variants +- Fully accessible with `role="dialog"` + ### Form Validation Pattern **Date:** 2026-01-01 diff --git a/.Jules/todo.md b/.Jules/todo.md index 4539a8a..2fdac3f 100644 --- a/.Jules/todo.md +++ b/.Jules/todo.md @@ -77,12 +77,12 @@ - Size: ~35 lines - Added: 2026-01-01 -- [ ] **[ux]** Confirmation dialog for destructive actions - - Files: Create `web/components/ui/ConfirmDialog.tsx`, integrate - - Context: Confirm before deleting groups/expenses - - Impact: Prevents accidental data loss - - Size: ~70 lines - - Added: 2026-01-01 +- [x] **[ux]** Confirmation dialog for destructive actions + - Completed: 2026-01-14 + - Files: `web/components/ui/ConfirmDialog.tsx`, `web/contexts/ConfirmContext.tsx`, `web/pages/GroupDetails.tsx` + - Context: Replaced native `window.confirm` with custom dual-theme modal + - Impact: Prevents accidental data loss with consistent UI + - Size: ~100 lines ### Mobile diff --git a/web/App.tsx b/web/App.tsx index 0a6d4c6..964388b 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -3,6 +3,7 @@ import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; import { Layout } from './components/layout/Layout'; import { ThemeWrapper } from './components/layout/ThemeWrapper'; import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { ConfirmProvider } from './contexts/ConfirmContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { ToastProvider } from './contexts/ToastContext'; import { ToastContainer } from './components/ui/Toast'; @@ -50,14 +51,16 @@ const App = () => { return ( - - - - - - - - + + + + + + + + + + ); diff --git a/web/components/ui/ConfirmDialog.tsx b/web/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..1c916f4 --- /dev/null +++ b/web/components/ui/ConfirmDialog.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { ConfirmOptions } from '../../contexts/ConfirmContext'; +import { Button } from './Button'; +import { Modal } from './Modal'; + +interface ConfirmDialogProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; + options: ConfirmOptions; +} + +export const ConfirmDialog: React.FC = ({ + isOpen, + onConfirm, + onCancel, + options +}) => { + const { title, message, confirmText = 'Confirm', cancelText = 'Cancel', variant = 'primary' } = options; + + return ( + + + + + } + > +

{message}

+
+ ); +}; diff --git a/web/components/ui/Modal.tsx b/web/components/ui/Modal.tsx index a70621c..072c8e6 100644 --- a/web/components/ui/Modal.tsx +++ b/web/components/ui/Modal.tsx @@ -53,6 +53,8 @@ export const Modal: React.FC = ({ isOpen, onClose, title, children, onClick={onClose} /> Promise; +} + +const ConfirmContext = createContext(undefined); + +export const useConfirm = () => { + const context = useContext(ConfirmContext); + if (!context) { + throw new Error('useConfirm must be used within a ConfirmProvider'); + } + return context; +}; + +export const ConfirmProvider = ({ children }: { children: ReactNode }) => { + const [isOpen, setIsOpen] = useState(false); + const [options, setOptions] = useState({ title: '', message: '' }); + const [resolveRef, setResolveRef] = useState<(value: boolean) => void>(() => {}); + + const confirm = useCallback((opts: ConfirmOptions) => { + setOptions({ + confirmText: 'Confirm', + cancelText: 'Cancel', + variant: 'primary', + ...opts + }); + setIsOpen(true); + return new Promise((resolve) => { + setResolveRef(() => resolve); + }); + }, []); + + const handleConfirm = useCallback(() => { + setIsOpen(false); + resolveRef(true); + }, [resolveRef]); + + const handleCancel = useCallback(() => { + setIsOpen(false); + resolveRef(false); + }, [resolveRef]); + + return ( + + {children} + {isOpen && ( + + )} + + ); +}; diff --git a/web/pages/GroupDetails.tsx b/web/pages/GroupDetails.tsx index d47ebc0..9235cbc 100644 --- a/web/pages/GroupDetails.tsx +++ b/web/pages/GroupDetails.tsx @@ -8,6 +8,7 @@ import { Modal } from '../components/ui/Modal'; import { Skeleton } from '../components/ui/Skeleton'; import { THEMES } from '../constants'; import { useAuth } from '../contexts/AuthContext'; +import { useConfirm } from '../contexts/ConfirmContext'; import { useTheme } from '../contexts/ThemeContext'; import { useToast } from '../contexts/ToastContext'; import { @@ -41,6 +42,7 @@ export const GroupDetails = () => { const { user } = useAuth(); const { style } = useTheme(); const { addToast } = useToast(); + const { confirm } = useConfirm(); const [group, setGroup] = useState(null); const [expenses, setExpenses] = useState([]); @@ -257,7 +259,12 @@ export const GroupDetails = () => { const handleDeleteExpense = async () => { if (!editingExpenseId || !id) return; - if (window.confirm("Are you sure you want to delete this expense?")) { + if (await confirm({ + title: 'Delete Expense', + message: 'Are you sure you want to delete this expense?', + variant: 'danger', + confirmText: 'Delete' + })) { try { await deleteExpense(id, editingExpenseId); setIsExpenseModalOpen(false); @@ -313,7 +320,12 @@ export const GroupDetails = () => { const handleDeleteGroup = async () => { if (!id) return; - if (window.confirm("Are you sure? This cannot be undone.")) { + if (await confirm({ + title: 'Delete Group', + message: 'Are you sure? This cannot be undone.', + variant: 'danger', + confirmText: 'Delete Group' + })) { try { await deleteGroup(id); navigate('/groups'); @@ -326,7 +338,12 @@ export const GroupDetails = () => { const handleLeaveGroup = async () => { if (!id) return; - if (window.confirm("You can only leave when your balances are settled. Continue?")) { + if (await confirm({ + title: 'Leave Group', + message: 'You can only leave when your balances are settled. Continue?', + variant: 'danger', + confirmText: 'Leave' + })) { try { await leaveGroup(id); addToast('You have left the group', 'success'); @@ -341,7 +358,12 @@ export const GroupDetails = () => { if (!id || !isAdmin) return; if (memberId === user?._id) return; - if (window.confirm(`Are you sure you want to remove ${memberName} from the group?`)) { + if (await confirm({ + title: 'Remove Member', + message: `Are you sure you want to remove ${memberName} from the group?`, + variant: 'danger', + confirmText: 'Remove' + })) { try { const hasUnsettled = settlements.some( s => (s.fromUserId === memberId || s.toUserId === memberId) && (s.amount || 0) > 0