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)
This commit is contained in:
2026-01-26 08:40:21 +00:00
parent 8222d66eab
commit bd48505249
4 changed files with 259 additions and 40 deletions

View File

@@ -21,11 +21,13 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuItem: ({
children,
onClick,
disabled,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}) => (
<button type="button" onClick={onClick}>
<button type="button" onClick={onClick} disabled={disabled}>
{children}
</button>
),
@@ -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(
<Header
meeting={meeting}
playback={null}
seekPosition={null}
setSeekPosition={vi.fn()}
isExtracting={false}
onNavigateBack={vi.fn()}
onPlay={vi.fn()}
onPause={vi.fn()}
onStop={vi.fn()}
onSeek={vi.fn()}
onExport={vi.fn()}
onGenerateSummary={vi.fn()}
onExtractEntities={vi.fn()}
onDelete={vi.fn()}
isDeleting={false}
canDelete={true}
/>
);
expect(screen.getByText('Delete Meeting')).toBeInTheDocument();
});
it('calls onDelete when delete option is clicked', () => {
isTauri = false;
const onDelete = vi.fn();
render(
<Header
meeting={meeting}
playback={null}
seekPosition={null}
setSeekPosition={vi.fn()}
isExtracting={false}
onNavigateBack={vi.fn()}
onPlay={vi.fn()}
onPause={vi.fn()}
onStop={vi.fn()}
onSeek={vi.fn()}
onExport={vi.fn()}
onGenerateSummary={vi.fn()}
onExtractEntities={vi.fn()}
onDelete={onDelete}
isDeleting={false}
canDelete={true}
/>
);
fireEvent.click(screen.getByText('Delete Meeting'));
expect(onDelete).toHaveBeenCalled();
});
it('disables delete option when canDelete is false', () => {
isTauri = false;
render(
<Header
meeting={meeting}
playback={null}
seekPosition={null}
setSeekPosition={vi.fn()}
isExtracting={false}
onNavigateBack={vi.fn()}
onPlay={vi.fn()}
onPause={vi.fn()}
onStop={vi.fn()}
onSeek={vi.fn()}
onExport={vi.fn()}
onGenerateSummary={vi.fn()}
onExtractEntities={vi.fn()}
onDelete={vi.fn()}
isDeleting={false}
canDelete={false}
/>
);
const deleteButton = screen.getByText('Delete Meeting').closest('button');
expect(deleteButton).toBeDisabled();
});
it('disables delete option when isDeleting is true', () => {
isTauri = false;
render(
<Header
meeting={meeting}
playback={null}
seekPosition={null}
setSeekPosition={vi.fn()}
isExtracting={false}
onNavigateBack={vi.fn()}
onPlay={vi.fn()}
onPause={vi.fn()}
onStop={vi.fn()}
onSeek={vi.fn()}
onExport={vi.fn()}
onGenerateSummary={vi.fn()}
onExtractEntities={vi.fn()}
onDelete={vi.fn()}
isDeleting={true}
canDelete={true}
/>
);
const deleteButton = screen.getByText('Delete Meeting').closest('button');
expect(deleteButton).toBeDisabled();
});
});

View File

@@ -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<void>;
onGenerateSummary: () => Promise<void>;
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
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={onDelete}
disabled={!canDelete || isDeleting}
className="gap-2 text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" />
Delete Meeting
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);

View File

@@ -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}
/>
<ConfirmationDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
title="Delete Meeting"
description={`Are you sure you want to delete "${meeting?.title}"? This action cannot be undone.`}
confirmText="Delete"
variant="destructive"
isLoading={isDeleting}
onConfirm={handleDelete}
/>
{processingState.isActive && (

View File

@@ -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<void>;
}
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,
};
}