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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
64
client/src/pages/meeting-detail/use-delete-meeting.ts
Normal file
64
client/src/pages/meeting-detail/use-delete-meeting.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user