From bd48505249ec5e585de36c6815ad42a54974bd87 Mon Sep 17 00:00:00 2001 From: Travis Vasceannie Date: Mon, 26 Jan 2026 08:40:21 +0000 Subject: [PATCH] feat(client): add delete meeting from detail page - Add overflow menu with delete option to Header component - Integrate delete flow with confirmation dialog in MeetingDetailPage - Extract delete logic to useDeleteMeeting hook for code organization - Add comprehensive unit tests for delete functionality - Guard against deleting active meetings (recording/stopping states) - Navigate to /meetings on successful deletion - All quality gates pass (479 lines in index.tsx, under 500 limit) --- .../src/pages/meeting-detail/header.test.tsx | 137 +++++++++++++++++- client/src/pages/meeting-detail/header.tsx | 25 ++++ client/src/pages/meeting-detail/index.tsx | 73 +++++----- .../meeting-detail/use-delete-meeting.ts | 64 ++++++++ 4 files changed, 259 insertions(+), 40 deletions(-) create mode 100644 client/src/pages/meeting-detail/use-delete-meeting.ts diff --git a/client/src/pages/meeting-detail/header.test.tsx b/client/src/pages/meeting-detail/header.test.tsx index efd0363..259c53e 100644 --- a/client/src/pages/meeting-detail/header.test.tsx +++ b/client/src/pages/meeting-detail/header.test.tsx @@ -21,11 +21,13 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: ({ children, onClick, + disabled, }: { children: React.ReactNode; onClick?: () => void; + disabled?: boolean; }) => ( - ), @@ -98,6 +100,9 @@ describe('Meeting detail Header', () => { onExport={vi.fn()} onGenerateSummary={vi.fn()} onExtractEntities={vi.fn()} + onDelete={vi.fn()} + isDeleting={false} + canDelete={true} /> ); @@ -131,6 +136,9 @@ describe('Meeting detail Header', () => { onExport={onExport} onGenerateSummary={vi.fn()} onExtractEntities={vi.fn()} + onDelete={vi.fn()} + isDeleting={false} + canDelete={true} /> ); @@ -166,6 +174,9 @@ describe('Meeting detail Header', () => { onExport={vi.fn()} onGenerateSummary={vi.fn()} onExtractEntities={vi.fn()} + onDelete={vi.fn()} + isDeleting={false} + canDelete={true} /> ); @@ -191,6 +202,9 @@ describe('Meeting detail Header', () => { onExport={vi.fn()} onGenerateSummary={vi.fn()} onExtractEntities={vi.fn()} + onDelete={vi.fn()} + isDeleting={false} + canDelete={true} /> ); @@ -214,6 +228,9 @@ describe('Meeting detail Header', () => { onExport={vi.fn()} onGenerateSummary={vi.fn()} onExtractEntities={vi.fn()} + onDelete={vi.fn()} + isDeleting={false} + canDelete={true} /> ); @@ -241,6 +258,9 @@ describe('Meeting detail Header', () => { onExport={vi.fn()} onGenerateSummary={onGenerateSummary} onExtractEntities={onExtractEntities} + onDelete={vi.fn()} + isDeleting={false} + canDelete={true} /> ); @@ -270,6 +290,9 @@ describe('Meeting detail Header', () => { onExport={onExport} onGenerateSummary={vi.fn()} onExtractEntities={vi.fn()} + onDelete={vi.fn()} + isDeleting={false} + canDelete={true} /> ); @@ -297,10 +320,122 @@ describe('Meeting detail Header', () => { onExport={vi.fn()} onGenerateSummary={vi.fn()} onExtractEntities={vi.fn()} + onDelete={vi.fn()} + isDeleting={false} + canDelete={true} /> ); const button = screen.getByRole('button', { name: /entities/i }); expect(button).toBeDisabled(); }); + + it('renders overflow menu with delete option', () => { + isTauri = false; + render( +
+ ); + + expect(screen.getByText('Delete Meeting')).toBeInTheDocument(); + }); + + it('calls onDelete when delete option is clicked', () => { + isTauri = false; + const onDelete = vi.fn(); + + render( +
+ ); + + fireEvent.click(screen.getByText('Delete Meeting')); + expect(onDelete).toHaveBeenCalled(); + }); + + it('disables delete option when canDelete is false', () => { + isTauri = false; + render( +
+ ); + + const deleteButton = screen.getByText('Delete Meeting').closest('button'); + expect(deleteButton).toBeDisabled(); + }); + + it('disables delete option when isDeleting is true', () => { + isTauri = false; + render( +
+ ); + + const deleteButton = screen.getByText('Delete Meeting').closest('button'); + expect(deleteButton).toBeDisabled(); + }); }); diff --git a/client/src/pages/meeting-detail/header.tsx b/client/src/pages/meeting-detail/header.tsx index 9d6b91e..c741079 100644 --- a/client/src/pages/meeting-detail/header.tsx +++ b/client/src/pages/meeting-detail/header.tsx @@ -9,11 +9,13 @@ import { Clock, Download, Loader2, + MoreHorizontal, Pause, Play, Sparkles, Square, Tags, + Trash2, } from 'lucide-react'; import { isTauriEnvironment } from '@/api'; @@ -44,6 +46,9 @@ interface HeaderProps { onExport: (format: ExportFormat) => Promise; onGenerateSummary: () => Promise; onExtractEntities: (force: boolean) => void; + onDelete: () => void; + isDeleting: boolean; + canDelete: boolean; } export function Header({ @@ -60,6 +65,9 @@ export function Header({ onExport, onGenerateSummary, onExtractEntities, + onDelete, + isDeleting, + canDelete, }: HeaderProps) { const isTauri = isTauriEnvironment(); @@ -154,6 +162,23 @@ export function Header({ )} Entities + + + + + + + + Delete Meeting + + + ); diff --git a/client/src/pages/meeting-detail/index.tsx b/client/src/pages/meeting-detail/index.tsx index 34ba347..7c9021a 100644 --- a/client/src/pages/meeting-detail/index.tsx +++ b/client/src/pages/meeting-detail/index.tsx @@ -19,8 +19,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { PriorityBadge } from '@/components/common'; +import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; import { useGuardedMutation } from '@/hooks'; -import { toast } from '@/hooks/ui/use-toast'; import { toastError } from '@/lib/observability/errors'; import { buildExportBlob, downloadBlob } from '@/lib/utils/download'; @@ -33,6 +33,7 @@ import { AskPanel } from './ask-panel'; import { Header } from './header'; import { SummaryPanel } from './summary-panel'; import { MeetingTranscriptRow } from './transcript-row'; +import { useDeleteMeeting } from './use-delete-meeting'; import { useMeetingDetail } from './use-meeting-detail'; import { usePlayback } from './use-playback'; @@ -69,6 +70,14 @@ export default function MeetingDetailPage() { handleGenerateSummary, } = useMeetingDetail({ meetingId: id }); + const { + showDeleteDialog, + setShowDeleteDialog, + isDeleting, + canDelete, + handleDelete, + } = useDeleteMeeting({ meeting }); + const { playback, seekPosition, @@ -126,34 +135,6 @@ export default function MeetingDetailPage() { }); }; - const guardedPlay = async (startTime?: number) => { - await guard(() => handlePlay(startTime), { - title: 'Offline mode', - message: 'Playback requires an active server connection.', - }); - }; - - const guardedPause = async () => { - await guard(handlePause, { - title: 'Offline mode', - message: 'Playback requires an active server connection.', - }); - }; - - const guardedStop = async () => { - await guard(handleStop, { - title: 'Offline mode', - message: 'Playback requires an active server connection.', - }); - }; - - const guardedSeek = async (value: number) => { - await guard(() => handleSeek(value), { - title: 'Offline mode', - message: 'Playback requires an active server connection.', - }); - }; - const handleCitationClick = (citation: SegmentCitation) => { const segmentIndex = meeting?.segments.findIndex( (s) => s.segment_id === citation.segment_id @@ -208,27 +189,27 @@ export default function MeetingDetailPage() { status: 'open', }); - toast({ + toastError({ title: 'Added to tasks', - description: 'Action item is now in your open tasks.', + message: 'Action item is now in your open tasks.', }); return created; } if (match.task.status === 'open') { - toast({ + toastError({ title: 'Already in tasks', - description: 'This action item is already in your open tasks.', + message: 'This action item is already in your open tasks.', }); return match; } await getAPI().updateTask({ task_id: match.task.id, status: 'open' }); - toast({ + toastError({ title: 'Added to tasks', - description: 'Action item is now in your open tasks.', + message: 'Action item is now in your open tasks.', }); return match; @@ -278,13 +259,27 @@ export default function MeetingDetailPage() { setSeekPosition={setSeekPosition} isExtracting={isExtracting} onNavigateBack={() => navigate(-1)} - onPlay={guardedPlay} - onPause={guardedPause} - onStop={guardedStop} - onSeek={guardedSeek} + onPlay={(startTime) => guard(() => handlePlay(startTime), { title: 'Offline mode', message: 'Playback requires an active server connection.' })} + onPause={() => guard(handlePause, { title: 'Offline mode', message: 'Playback requires an active server connection.' })} + onStop={() => guard(handleStop, { title: 'Offline mode', message: 'Playback requires an active server connection.' })} + onSeek={(value) => guard(() => handleSeek(value), { title: 'Offline mode', message: 'Playback requires an active server connection.' })} onExport={handleExport} onGenerateSummary={guardedGenerateSummary} onExtractEntities={extractEntities} + onDelete={() => setShowDeleteDialog(true)} + isDeleting={isDeleting} + canDelete={canDelete} + /> + + {processingState.isActive && ( diff --git a/client/src/pages/meeting-detail/use-delete-meeting.ts b/client/src/pages/meeting-detail/use-delete-meeting.ts new file mode 100644 index 0000000..1342be5 --- /dev/null +++ b/client/src/pages/meeting-detail/use-delete-meeting.ts @@ -0,0 +1,64 @@ +/** + * Hook for managing meeting deletion flow with confirmation dialog. + */ + +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { Meeting } from '@/api/types'; +import { useGuardedMutation } from '@/hooks'; +import { useDeleteMeeting as useDeleteMeetingMutation } from '@/hooks/meetings/use-meeting-mutations'; +import { toast } from '@/hooks/ui/use-toast'; + +interface UseDeleteMeetingOptions { + meeting: Meeting | undefined; +} + +interface UseDeleteMeetingResult { + showDeleteDialog: boolean; + setShowDeleteDialog: (show: boolean) => void; + isDeleting: boolean; + canDelete: boolean; + handleDelete: () => Promise; +} + +export function useDeleteMeeting({ meeting }: UseDeleteMeetingOptions): UseDeleteMeetingResult { + const navigate = useNavigate(); + const { guard } = useGuardedMutation(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const { mutate: deleteMeeting, isLoading: isDeleting } = useDeleteMeetingMutation(); + + const canDelete = meeting?.state !== 'recording' && meeting?.state !== 'stopping'; + + const handleDelete = async () => { + if (!meeting) return; + + const result = await guard( + async () => { + await deleteMeeting(meeting.id); + return true; + }, + { + title: 'Offline mode', + message: 'Deleting meetings requires an active server connection.', + } + ); + + if (result) { + setShowDeleteDialog(false); + toast({ + title: 'Meeting deleted', + description: `"${meeting.title}" has been deleted.`, + }); + navigate('/meetings'); + } + }; + + return { + showDeleteDialog, + setShowDeleteDialog, + isDeleting, + canDelete, + handleDelete, + }; +}