Skip to content

Commit ada8cbf

Browse files
committed
feat(web): add undo/redo for destructive actions (task-45)
- Create useUndoRedo hook with 10-action history limit - Add UndoRedoContext for global state management - Create UndoRedoControls component with keyboard shortcuts - Integrate undo/redo into useGraphList (removeNode, clearGraphList) - Add undo/redo controls to MainLayout header - Support Cmd+Z (undo) and Cmd+Shift+Z (redo) shortcuts - Add clear history option with action count display
1 parent 744c0e2 commit ada8cbf

File tree

6 files changed

+379
-12
lines changed

6 files changed

+379
-12
lines changed

apps/web/src/components/layout/MainLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { HeaderSearchInput } from "./HeaderSearchInput";
4747
import { HistorySidebar } from "./HistorySidebar";
4848
import { LeftRibbon } from "./LeftRibbon";
4949
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
50+
import { UndoRedoControls } from "@/components/undo-redo/UndoRedoControls";
5051
import { RightRibbon } from "./RightRibbon";
5152
import { RightSidebarContent } from "./RightSidebarContent";
5253
// import { ThemeDropdown } from "./ThemeDropdown";
@@ -309,6 +310,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
309310

310311
{/* Sidebar controls - simplified on mobile */}
311312
<Group gap="xs" visibleFrom="sm">
313+
<UndoRedoControls />
312314
<NotificationCenter />
313315
<ActionIcon
314316
onClick={toggleLeftSidebar}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Undo/Redo Controls Component
3+
*
4+
* Provides undo/redo buttons with keyboard shortcuts display.
5+
* Integrates with global UndoRedoContext.
6+
*/
7+
8+
import { useUndoRedoContext } from '@/contexts/UndoRedoContext';
9+
import { ICON_SIZE } from '@/config/style-constants';
10+
import {
11+
ActionIcon,
12+
Box,
13+
Group,
14+
Menu,
15+
Stack,
16+
Text,
17+
Tooltip,
18+
} from '@mantine/core';
19+
import { IconHistory, IconRefresh, IconTrash, IconArrowBackUp, IconArrowForwardUp } from '@tabler/icons-react';
20+
import { memo } from 'react';
21+
22+
export const UndoRedoControls = memo(() => {
23+
const { canUndo, canRedo, undo, redo, clearHistory, historySize } = useUndoRedoContext();
24+
25+
return (
26+
<Group gap="xs" wrap="nowrap">
27+
<Tooltip label="Undo (Cmd+Z)" withinPortal>
28+
<ActionIcon
29+
onClick={() => void undo()}
30+
disabled={!canUndo}
31+
variant="subtle"
32+
size="lg"
33+
aria-label="Undo"
34+
>
35+
<IconArrowBackUp size={ICON_SIZE.MD} />
36+
</ActionIcon>
37+
</Tooltip>
38+
39+
<Tooltip label="Redo (Cmd+Shift+Z or Cmd+Y)" withinPortal>
40+
<ActionIcon
41+
onClick={() => void redo()}
42+
disabled={!canRedo}
43+
variant="subtle"
44+
size="lg"
45+
aria-label="Redo"
46+
>
47+
<IconArrowForwardUp size={ICON_SIZE.MD} />
48+
</ActionIcon>
49+
</Tooltip>
50+
51+
{historySize > 0 && (
52+
<Menu shadow="md" width={200} position="bottom-end">
53+
<Menu.Target>
54+
<ActionIcon
55+
variant="subtle"
56+
size="lg"
57+
aria-label="History options"
58+
>
59+
<IconHistory size={ICON_SIZE.MD} />
60+
</ActionIcon>
61+
</Menu.Target>
62+
63+
<Menu.Dropdown>
64+
<Menu.Item
65+
leftSection={<IconTrash size={ICON_SIZE.SM} />}
66+
onClick={clearHistory}
67+
color="red"
68+
>
69+
Clear history ({historySize} actions)
70+
</Menu.Item>
71+
72+
<Menu.Label>
73+
<Stack gap={0}>
74+
<Text size="xs" c="dimmed">Keyboard shortcuts:</Text>
75+
<Text size="xs" c="dimmed">Cmd+Z: Undo</Text>
76+
<Text size="xs" c="dimmed">Cmd+Shift+Z: Redo</Text>
77+
</Stack>
78+
</Menu.Label>
79+
</Menu.Dropdown>
80+
</Menu>
81+
)}
82+
</Group>
83+
);
84+
});
85+
86+
UndoRedoControls.displayName = 'UndoRedoControls';
87+
88+
export default UndoRedoControls;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Global Undo/Redo Context
3+
*
4+
* Provides application-wide undo/redo functionality for destructive actions.
5+
* Integrates with useUndoRedo hook and manages action history across all hooks.
6+
*/
7+
8+
import React, { createContext, useContext } from 'react';
9+
import useUndoRedo, { type UndoableAction } from '@/hooks/useUndoRedo';
10+
11+
export interface UndoRedoContextValue {
12+
canUndo: boolean;
13+
canRedo: boolean;
14+
undo: () => Promise<void>;
15+
redo: () => Promise<void>;
16+
addAction: (action: UndoableAction) => void;
17+
clearHistory: () => void;
18+
historySize: number;
19+
}
20+
21+
const UndoRedoContext = createContext<UndoRedoContextValue | null>(null);
22+
23+
/**
24+
* Provider component that wraps the application with undo/redo functionality
25+
*/
26+
export const UndoRedoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
27+
const undoRedo = useUndoRedo({
28+
maxHistory: 10, // User decision: 10 actions
29+
enableKeyboardShortcuts: true,
30+
});
31+
32+
return (
33+
<UndoRedoContext.Provider value={undoRedo}>
34+
{children}
35+
</UndoRedoContext.Provider>
36+
);
37+
};
38+
39+
/**
40+
* Hook to access the global undo/redo context
41+
*/
42+
export const useUndoRedoContext = (): UndoRedoContextValue => {
43+
const context = useContext(UndoRedoContext);
44+
if (!context) {
45+
throw new Error('useUndoRedoContext must be used within UndoRedoProvider');
46+
}
47+
return context;
48+
};
49+
50+
export default UndoRedoContext;

apps/web/src/hooks/useGraphList.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Provides reactive graph list state and CRUD operations for the persistent working set
44
*
55
* T029: Graph List hook with storage integration
6+
* T045: Undo/redo integration for destructive actions
67
*/
78

89
import type { AddToGraphListParams, GraphListNode, PruneGraphListResult } from "@bibgraph/types";
@@ -13,6 +14,7 @@ import {
1314
import { useCallback, useEffect, useState } from "react";
1415

1516
import { useStorageProvider } from "@/contexts/storage-provider-context";
17+
import { useUndoRedoContext } from "@/contexts/UndoRedoContext";
1618

1719
const GRAPH_LIST_LOGGER_CONTEXT = "graph-list-hook";
1820

@@ -81,6 +83,7 @@ export interface UseGraphListResult {
8183
*/
8284
export const useGraphList = (): UseGraphListResult => {
8385
const storage = useStorageProvider();
86+
const { addAction } = useUndoRedoContext();
8487

8588
// State
8689
const [nodes, setNodes] = useState<GraphListNode[]>([]);
@@ -268,11 +271,34 @@ export const useGraphList = (): UseGraphListResult => {
268271

269272
// T030: Optimistic update - remove from UI immediately
270273
const previousNodes = nodes;
274+
const removedNode = nodes.find(n => n.entityId === entityId);
271275
setNodes(prevNodes => prevNodes.filter(n => n.entityId !== entityId));
272276

273277
try {
274278
await storage.removeFromGraphList(entityId);
275279

280+
// T045: Add undo/redo action for destructive operation
281+
if (removedNode) {
282+
addAction({
283+
id: crypto.randomUUID(),
284+
timestamp: new Date(),
285+
description: `Remove ${removedNode.label || removedNode.entityId} from graph`,
286+
undo: async () => {
287+
// Restore node to graph list
288+
await storage.addToGraphList({
289+
entityId: removedNode.entityId,
290+
entityType: removedNode.entityType,
291+
label: removedNode.label,
292+
provenance: removedNode.provenance,
293+
});
294+
},
295+
redo: async () => {
296+
// Remove node from graph list again
297+
await storage.removeFromGraphList(entityId);
298+
},
299+
});
300+
}
301+
276302
logger.debug(GRAPH_LIST_LOGGER_CONTEXT, "Node removed from graph list", {
277303
entityId
278304
});
@@ -291,7 +317,7 @@ export const useGraphList = (): UseGraphListResult => {
291317
});
292318
throw errorObj;
293319
}
294-
}, [storage, nodes]);
320+
}, [storage, nodes, addAction]);
295321

296322
// Clear graph list
297323
const clearGraphList = useCallback(async (): Promise<void> => {
@@ -304,6 +330,29 @@ export const useGraphList = (): UseGraphListResult => {
304330
try {
305331
await storage.clearGraphList();
306332

333+
// T045: Add undo/redo action for destructive operation
334+
addAction({
335+
id: crypto.randomUUID(),
336+
timestamp: new Date(),
337+
description: `Clear graph list (${previousNodes.length} nodes)`,
338+
undo: async () => {
339+
// Restore all nodes to graph list
340+
const restorePromises = previousNodes.map(node =>
341+
storage.addToGraphList({
342+
entityId: node.entityId,
343+
entityType: node.entityType,
344+
label: node.label,
345+
provenance: node.provenance,
346+
})
347+
);
348+
await Promise.all(restorePromises);
349+
},
350+
redo: async () => {
351+
// Clear graph list again
352+
await storage.clearGraphList();
353+
},
354+
});
355+
307356
logger.debug(GRAPH_LIST_LOGGER_CONTEXT, "Graph list cleared");
308357

309358
// Refresh will be triggered by catalogueEventEmitter
@@ -319,7 +368,7 @@ export const useGraphList = (): UseGraphListResult => {
319368
});
320369
throw errorObj;
321370
}
322-
}, [storage, nodes]);
371+
}, [storage, nodes, addAction]);
323372

324373
// Check if entity is in graph list
325374
const isInGraphList = useCallback(async (entityId: string): Promise<boolean> => {

0 commit comments

Comments
 (0)