Skip to content

Commit 271fbad

Browse files
authored
Merge pull request #70 from DouglasNeuroInformatics/destructive-action
feat: add new destructive action usages
2 parents 40e77a6 + 3a31817 commit 271fbad

File tree

6 files changed

+404
-64
lines changed

6 files changed

+404
-64
lines changed
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { useDestructiveAction } from './useDestructiveAction';
5+
import { useDestructiveActionStore } from './useDestructiveActionStore';
6+
7+
import type { DestructiveAction, DestructiveActionOptions, DestructiveActionParams } from './useDestructiveActionStore';
8+
9+
vi.mock('./useDestructiveActionStore', () => ({
10+
useDestructiveActionStore: vi.fn()
11+
}));
12+
13+
describe('useDestructiveAction', () => {
14+
const mockAddPendingDestructiveAction = vi.fn();
15+
16+
beforeEach(() => {
17+
vi.clearAllMocks();
18+
(useDestructiveActionStore as any).mockImplementation((selector: any) => {
19+
const store = {
20+
addPendingDestructiveAction: mockAddPendingDestructiveAction
21+
};
22+
return selector(store);
23+
});
24+
});
25+
26+
describe('useDestructiveAction()', () => {
27+
it('should return a function that accepts action and options', () => {
28+
const { result } = renderHook(() => useDestructiveAction());
29+
expect(typeof result.current).toBe('function');
30+
});
31+
32+
it('should call addPendingDestructiveAction with action only', () => {
33+
const { result } = renderHook(() => useDestructiveAction());
34+
const testAction: DestructiveAction = vi.fn();
35+
36+
act(() => {
37+
result.current(testAction);
38+
});
39+
40+
expect(mockAddPendingDestructiveAction).toHaveBeenCalledWith(testAction, undefined);
41+
});
42+
43+
it('should call addPendingDestructiveAction with action and options', () => {
44+
const { result } = renderHook(() => useDestructiveAction());
45+
const testAction: DestructiveAction = vi.fn();
46+
const options: DestructiveActionOptions = {
47+
description: 'This is a test',
48+
title: 'Test Action'
49+
};
50+
51+
act(() => {
52+
result.current(testAction, options);
53+
});
54+
55+
expect(mockAddPendingDestructiveAction).toHaveBeenCalledWith(testAction, options);
56+
});
57+
});
58+
59+
describe('useDestructiveAction(action)', () => {
60+
it('should return a function that calls the action with provided args', () => {
61+
const testAction = vi.fn();
62+
const { result } = renderHook(() => useDestructiveAction(testAction));
63+
64+
const arg1 = 'test';
65+
const arg2 = 123;
66+
67+
act(() => {
68+
result.current(arg1, arg2);
69+
});
70+
71+
expect(mockAddPendingDestructiveAction).toHaveBeenCalledWith(expect.any(Function), {});
72+
73+
// Test that the wrapped function calls the original action with args
74+
const wrappedAction = mockAddPendingDestructiveAction.mock.calls[0]![0];
75+
wrappedAction();
76+
expect(testAction).toHaveBeenCalledWith(arg1, arg2);
77+
});
78+
79+
it('should work with no arguments', () => {
80+
const testAction = vi.fn();
81+
const { result } = renderHook(() => useDestructiveAction(testAction));
82+
83+
act(() => {
84+
result.current();
85+
});
86+
87+
expect(mockAddPendingDestructiveAction).toHaveBeenCalledWith(expect.any(Function), {});
88+
89+
const wrappedAction = mockAddPendingDestructiveAction.mock.calls[0]![0];
90+
wrappedAction();
91+
expect(testAction).toHaveBeenCalledWith();
92+
});
93+
});
94+
95+
describe('useDestructiveAction(params)', () => {
96+
it('should return a function that calls the action with provided args and passes options', () => {
97+
const testAction = vi.fn();
98+
const params: DestructiveActionParams<[string, number]> = {
99+
action: testAction,
100+
description: 'This is a test',
101+
title: 'Test Action'
102+
};
103+
const { result } = renderHook(() => useDestructiveAction(params));
104+
105+
const arg1 = 'test';
106+
const arg2 = 123;
107+
108+
act(() => {
109+
result.current(arg1, arg2);
110+
});
111+
112+
expect(mockAddPendingDestructiveAction).toHaveBeenCalledWith(expect.any(Function), {
113+
description: 'This is a test',
114+
title: 'Test Action'
115+
});
116+
117+
// test that the wrapped function calls the original action with args
118+
const wrappedAction = mockAddPendingDestructiveAction.mock.calls[0]![0];
119+
wrappedAction();
120+
expect(testAction).toHaveBeenCalledWith(arg1, arg2);
121+
});
122+
123+
it('should work with only action in params', () => {
124+
const testAction = vi.fn();
125+
const params: DestructiveActionParams<[]> = {
126+
action: testAction
127+
};
128+
const { result } = renderHook(() => useDestructiveAction(params));
129+
130+
act(() => {
131+
result.current();
132+
});
133+
134+
expect(mockAddPendingDestructiveAction).toHaveBeenCalledWith(expect.any(Function), {});
135+
136+
const wrappedAction = mockAddPendingDestructiveAction.mock.calls[0]![0];
137+
wrappedAction();
138+
expect(testAction).toHaveBeenCalledWith();
139+
});
140+
141+
it('should work with partial options in params', () => {
142+
const testAction = vi.fn();
143+
const params: DestructiveActionParams<[string]> = {
144+
action: testAction,
145+
title: 'Only Title'
146+
};
147+
const { result } = renderHook(() => useDestructiveAction(params));
148+
149+
act(() => {
150+
result.current('test');
151+
});
152+
153+
expect(mockAddPendingDestructiveAction).toHaveBeenCalledWith(expect.any(Function), {
154+
title: 'Only Title'
155+
});
156+
});
157+
});
158+
159+
describe('callback stability', () => {
160+
it('should return the same callback function when dependencies do not change', () => {
161+
const testAction = vi.fn();
162+
const { rerender, result } = renderHook(() => useDestructiveAction(testAction));
163+
164+
const firstCallback = result.current;
165+
rerender();
166+
const secondCallback = result.current;
167+
168+
expect(firstCallback).toBe(secondCallback);
169+
});
170+
171+
it('should return a new callback function when action changes', () => {
172+
const testAction1 = vi.fn();
173+
const testAction2 = vi.fn();
174+
let action = testAction1;
175+
176+
const { rerender, result } = renderHook(() => useDestructiveAction(action));
177+
178+
const firstCallback = result.current;
179+
180+
action = testAction2;
181+
rerender();
182+
183+
const secondCallback = result.current;
184+
185+
expect(firstCallback).not.toBe(secondCallback);
186+
});
187+
188+
it('should return a new callback function when params change', () => {
189+
const testAction = vi.fn();
190+
let params: DestructiveActionParams<[]> = { action: testAction, title: 'First' };
191+
192+
const { rerender, result } = renderHook(() => useDestructiveAction(params));
193+
194+
const firstCallback = result.current;
195+
196+
params = { action: testAction, title: 'Second' };
197+
rerender();
198+
199+
const secondCallback = result.current;
200+
201+
expect(firstCallback).not.toBe(secondCallback);
202+
});
203+
});
204+
205+
describe('store integration', () => {
206+
it('should call useDestructiveActionStore with correct selector', () => {
207+
renderHook(() => useDestructiveAction());
208+
209+
expect(useDestructiveActionStore).toHaveBeenCalledWith(expect.any(Function));
210+
211+
// Test the selector function
212+
const selector = (useDestructiveActionStore as any).mock.calls[0][0];
213+
const mockStore = {
214+
addPendingDestructiveAction: mockAddPendingDestructiveAction,
215+
otherProperty: 'should not be selected'
216+
};
217+
218+
expect(selector(mockStore)).toBe(mockAddPendingDestructiveAction);
219+
});
220+
});
221+
});
Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
11
import { useCallback } from 'react';
22

3-
import type { Promisable } from 'type-fest';
4-
53
import { useDestructiveActionStore } from './useDestructiveActionStore';
64

7-
export function useDestructiveAction<TArgs extends any[]>(destructiveAction: (...args: TArgs) => Promisable<void>) {
5+
import type { DestructiveAction, DestructiveActionOptions, DestructiveActionParams } from './useDestructiveActionStore';
6+
7+
/**
8+
* Returns a function that accepts a destructive action and optional configuration.
9+
* @returns A function that takes an action and options to add to the destructive action queue
10+
*/
11+
export function useDestructiveAction(): (action: DestructiveAction, options?: DestructiveActionOptions) => void;
12+
/**
13+
* Returns a function that wraps the provided action for destructive confirmation.
14+
* @param action - The destructive action to wrap
15+
* @returns A function that takes the action's arguments and adds it to the destructive action queue
16+
*/
17+
export function useDestructiveAction<TArgs extends any[]>(action: DestructiveAction<TArgs>): (...args: TArgs) => void;
18+
/**
19+
* Returns a function that wraps the provided action with configuration for destructive confirmation.
20+
* @param params - The action and its configuration (title, description)
21+
* @returns A function that takes the action's arguments and adds it to the destructive action queue
22+
*/
23+
export function useDestructiveAction<TArgs extends any[]>(
24+
params: DestructiveActionParams<TArgs>
25+
): (...args: TArgs) => void;
26+
export function useDestructiveAction<TArgs extends any[]>(
27+
arg?: DestructiveAction<TArgs> | DestructiveActionParams<TArgs>
28+
): (...args: TArgs) => void {
829
const addPendingDestructiveAction = useDestructiveActionStore((store) => store.addPendingDestructiveAction);
930
return useCallback(
1031
(...args: TArgs) => {
11-
addPendingDestructiveAction(() => destructiveAction(...args));
32+
if (arg === undefined) {
33+
const [action, options] = args as unknown as [DestructiveAction, DestructiveActionOptions | undefined];
34+
addPendingDestructiveAction(action, options);
35+
return;
36+
}
37+
const { action, ...options } = typeof arg === 'function' ? { action: arg } : arg;
38+
addPendingDestructiveAction(() => action(...args), options);
1239
},
13-
[destructiveAction, addPendingDestructiveAction]
40+
[arg, addPendingDestructiveAction]
1441
);
1542
}

src/hooks/useDestructiveAction/useDestructiveActionStore.test.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as zustand from 'zustand';
44

55
import { useDestructiveActionStore } from './useDestructiveActionStore';
66

7-
import type { DestructiveAction } from './useDestructiveActionStore';
7+
import type { DestructiveAction, DestructiveActionOptions } from './useDestructiveActionStore';
88

99
describe('useDestructiveActionStore', () => {
1010
beforeAll(() => {
@@ -32,7 +32,9 @@ describe('useDestructiveActionStore', () => {
3232
act(() => {
3333
result.current.addPendingDestructiveAction(testAction);
3434
});
35-
expect(result.current.pendingDestructiveActions).toEqual([testAction]);
35+
expect(result.current.pendingDestructiveActions).toHaveLength(1);
36+
expect(result.current.pendingDestructiveActions[0]!.action).toBe(testAction);
37+
expect(result.current.pendingDestructiveActions[0]!.id).toBeDefined();
3638
});
3739

3840
it('should add multiple actions to the array', () => {
@@ -43,7 +45,25 @@ describe('useDestructiveActionStore', () => {
4345
result.current.addPendingDestructiveAction(testAction1);
4446
result.current.addPendingDestructiveAction(testAction2);
4547
});
46-
expect(result.current.pendingDestructiveActions).toEqual([testAction1, testAction2]);
48+
expect(result.current.pendingDestructiveActions).toHaveLength(2);
49+
expect(result.current.pendingDestructiveActions[0]!.action).toBe(testAction1);
50+
expect(result.current.pendingDestructiveActions[1]!.action).toBe(testAction2);
51+
});
52+
53+
it('should add action with options', () => {
54+
const { result } = renderHook(() => useDestructiveActionStore());
55+
const testAction: DestructiveAction = vi.fn();
56+
const options: DestructiveActionOptions = {
57+
description: 'This is a test action',
58+
title: 'Test Action'
59+
};
60+
act(() => {
61+
result.current.addPendingDestructiveAction(testAction, options);
62+
});
63+
expect(result.current.pendingDestructiveActions).toHaveLength(1);
64+
expect(result.current.pendingDestructiveActions[0]!.action).toBe(testAction);
65+
expect(result.current.pendingDestructiveActions[0]!.title).toBe('Test Action');
66+
expect(result.current.pendingDestructiveActions[0]!.description).toBe('This is a test action');
4767
});
4868
});
4969

@@ -60,18 +80,21 @@ describe('useDestructiveActionStore', () => {
6080
result.current.addPendingDestructiveAction(testAction3);
6181
});
6282

83+
const idToDelete = result.current.pendingDestructiveActions[1]!.id;
84+
6385
act(() => {
64-
result.current.deletePendingDestructiveAction(testAction2);
86+
result.current.deletePendingDestructiveAction(idToDelete);
6587
});
6688

67-
expect(result.current.pendingDestructiveActions).toEqual([testAction1, testAction3]);
89+
expect(result.current.pendingDestructiveActions).toHaveLength(2);
90+
expect(result.current.pendingDestructiveActions[0]!.action).toBe(testAction1);
91+
expect(result.current.pendingDestructiveActions[1]!.action).toBe(testAction3);
6892
});
6993

7094
it('should handle removing from empty array', () => {
7195
const { result } = renderHook(() => useDestructiveActionStore());
72-
const testAction: DestructiveAction = vi.fn();
7396
act(() => {
74-
result.current.deletePendingDestructiveAction(testAction);
97+
result.current.deletePendingDestructiveAction('non-existent-id');
7598
});
7699
expect(result.current.pendingDestructiveActions).toEqual([]);
77100
});
Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,40 @@
11
import type { Promisable } from 'type-fest';
22
import { create } from 'zustand';
33

4-
export type DestructiveAction = () => Promisable<void>;
4+
type DestructiveAction<TArgs extends any[] = any[]> = (...args: TArgs) => Promisable<void>;
5+
6+
type DestructiveActionOptions = {
7+
description?: string;
8+
title?: string;
9+
};
10+
11+
type DestructiveActionParams<TArgs extends any[] = any[]> = DestructiveActionOptions & {
12+
action: DestructiveAction<TArgs>;
13+
};
14+
15+
type DestructiveActionDef<TArgs extends any[] = any[]> = DestructiveActionParams<TArgs> & {
16+
id: string;
17+
};
518

619
export type DestructiveActionStore = {
7-
addPendingDestructiveAction: (action: DestructiveAction) => void;
8-
deletePendingDestructiveAction: (action: DestructiveAction) => void;
9-
pendingDestructiveActions: DestructiveAction[];
20+
addPendingDestructiveAction: (action: DestructiveAction, options?: DestructiveActionOptions) => void;
21+
deletePendingDestructiveAction: (id: string) => void;
22+
pendingDestructiveActions: DestructiveActionDef[];
1023
};
1124

1225
export const useDestructiveActionStore = create<DestructiveActionStore>((set) => ({
13-
addPendingDestructiveAction: (action) => {
26+
addPendingDestructiveAction: (action, options) => {
1427
set((state) => ({
15-
pendingDestructiveActions: [...state.pendingDestructiveActions, action]
28+
pendingDestructiveActions: [...state.pendingDestructiveActions, { action, id: crypto.randomUUID(), ...options }]
1629
}));
1730
},
18-
deletePendingDestructiveAction: (action) => {
31+
deletePendingDestructiveAction: (id) => {
1932
set((state) => ({
2033
...state,
21-
pendingDestructiveActions: state.pendingDestructiveActions.filter((_action) => _action !== action)
34+
pendingDestructiveActions: state.pendingDestructiveActions.filter((def) => def.id !== id)
2235
}));
2336
},
2437
pendingDestructiveActions: []
2538
}));
39+
40+
export type { DestructiveAction, DestructiveActionDef, DestructiveActionOptions, DestructiveActionParams };

0 commit comments

Comments
 (0)