Skip to content

Commit 29d080c

Browse files
christopherthielenmergify[bot]
authored andcommitted
feat(hooks): Add useCanExit hook to block transitions from exiting a state
1 parent 0478e80 commit 29d080c

File tree

2 files changed

+174
-0
lines changed

2 files changed

+174
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import * as React from 'react';
2+
import { TransitionHookFn } from '@uirouter/core';
3+
import { makeTestRouter } from '../../__tests__/util';
4+
import { UIView } from '../../components';
5+
import { ReactStateDeclaration } from '../../interface';
6+
import { useCanExit } from '../useCanExit';
7+
8+
const state1 = { name: 'state1', url: '/state1', component: UIView };
9+
10+
function TestComponent({ callback }: { callback: TransitionHookFn }) {
11+
useCanExit(callback);
12+
return <div />;
13+
}
14+
15+
describe('useCanExit', () => {
16+
let { router, routerGo, mountInRouter } = makeTestRouter([]);
17+
beforeEach(() => ({ router, routerGo, mountInRouter } = makeTestRouter([state1])));
18+
19+
async function registerAndGo(state: ReactStateDeclaration) {
20+
router.stateRegistry.register(state);
21+
await routerGo(state);
22+
}
23+
24+
it('can block a transition that exits the state it was used in', async () => {
25+
const callback = jest.fn(() => false);
26+
await registerAndGo({ name: 'state2', component: () => <TestComponent callback={callback} /> });
27+
mountInRouter(<UIView />);
28+
expect(routerGo('state1')).rejects.toMatchObject({
29+
message: 'The transition has been aborted',
30+
});
31+
expect(callback).toHaveBeenCalled();
32+
expect(router.globals.current.name).toBe('state2');
33+
});
34+
35+
it('can block a transition using a Promise that resolves to false', async () => {
36+
const callback = jest.fn(() => Promise.resolve(false));
37+
await registerAndGo({ name: 'state2', component: () => <TestComponent callback={callback} /> });
38+
mountInRouter(<UIView />);
39+
expect(routerGo('state1')).rejects.toMatchObject({
40+
message: 'The transition has been aborted',
41+
});
42+
expect(callback).toHaveBeenCalled();
43+
expect(router.globals.current.name).toBe('state2');
44+
});
45+
46+
it('can allow a transition using a Promise that resolves to true', async () => {
47+
const callback = jest.fn(() => Promise.resolve(true));
48+
await registerAndGo({ name: 'state2', component: () => <TestComponent callback={callback} /> });
49+
mountInRouter(<UIView />);
50+
await routerGo('state1');
51+
expect(router.globals.current.name).toBe('state1');
52+
});
53+
54+
it('can allow a transition using a Promise that resolves to undefined', async () => {
55+
const callback = jest.fn(() => Promise.resolve(undefined));
56+
await registerAndGo({ name: 'state2', component: () => <TestComponent callback={callback} /> });
57+
mountInRouter(<UIView />);
58+
await routerGo('state1');
59+
expect(router.globals.current.name).toBe('state1');
60+
});
61+
62+
it('can allow a transition that exits the state it was used in', async () => {
63+
const callback = jest.fn(() => true);
64+
await registerAndGo({ name: 'state2', component: () => <TestComponent callback={callback} /> });
65+
mountInRouter(<UIView />);
66+
await routerGo('state1');
67+
expect(callback).toHaveBeenCalled();
68+
expect(router.globals.current.name).toBe('state1');
69+
});
70+
71+
it('can block a transition that goes to the parent state', async () => {
72+
const callback = jest.fn(() => false);
73+
await registerAndGo({ name: 'state1.child', component: () => <TestComponent callback={callback} /> });
74+
mountInRouter(<UIView />);
75+
expect(routerGo('state1')).rejects.toMatchObject({
76+
message: 'The transition has been aborted',
77+
});
78+
expect(callback).toHaveBeenCalled();
79+
expect(router.globals.current.name).toBe('state1.child');
80+
});
81+
82+
it('does not block a transition which retains (does not exit) the state the hook was used in', async () => {
83+
const callback = jest.fn(() => false);
84+
const state2Component = () => (
85+
<>
86+
<TestComponent callback={callback} />
87+
<UIView />
88+
</>
89+
);
90+
91+
// hook used in state2
92+
router.stateRegistry.register({ name: 'state2', component: state2Component } as ReactStateDeclaration);
93+
router.stateRegistry.register({ name: 'state2.child', component: () => <div /> } as ReactStateDeclaration);
94+
95+
await routerGo('state2.child');
96+
mountInRouter(<UIView />);
97+
// exit state2.child but retain state2
98+
await routerGo('state2');
99+
100+
expect(callback).not.toHaveBeenCalled();
101+
expect(router.globals.current.name).toBe('state2');
102+
});
103+
104+
describe('implementation detail', () => {
105+
it('registers an onBefore transition hook', async () => {
106+
const callback = () => true;
107+
await registerAndGo({ name: 'state2', component: () => <TestComponent callback={callback} /> });
108+
const spy = jest.spyOn(router.transitionService, 'onBefore');
109+
mountInRouter(<UIView />);
110+
expect(spy).toHaveBeenCalledTimes(1);
111+
expect(spy).toHaveBeenCalledWith({ exiting: 'state2' }, expect.any(Function), undefined);
112+
});
113+
114+
it('deregisters the onBefore transition hook when unmounted', async () => {
115+
const deregisterSpy = jest.fn();
116+
await registerAndGo({ name: 'state2', component: () => <TestComponent callback={() => true} /> });
117+
jest.spyOn(router.transitionService, 'onBefore').mockImplementation(() => deregisterSpy);
118+
mountInRouter(<UIView />);
119+
expect(deregisterSpy).toHaveBeenCalledTimes(0);
120+
121+
await routerGo('state1');
122+
expect(router.globals.current.name).toBe('state1');
123+
expect(deregisterSpy).toHaveBeenCalledTimes(1);
124+
});
125+
});
126+
});

src/hooks/useCanExit.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/** @packageDocumentation @reactapi @module react_hooks */
2+
3+
import { HookRegOptions, TransitionHookFn } from '@uirouter/core';
4+
import { useParentView } from './useParentView';
5+
import { useTransitionHook } from './useTransitionHook';
6+
7+
/**
8+
* A hook that can stop the router from exiting the state the hook is used in.
9+
*
10+
* This hook can be used to check preconditions before the router is allowed exit the state that the hook was used in.
11+
* If the hook returns true/undefined (or a Promise that resolves to true/undefined), the Transition will be allowed to continue.
12+
* If the hook returns false (or a Promise that resolves to false), the Transition will be cancelled.
13+
*
14+
* For example, you may use the hook in an edit screen.
15+
* If the user navigates to a different state which would exit the edit screen,
16+
* you may check for unsaved data and prompt for confirmation.
17+
*
18+
* Example:
19+
* ```jsx
20+
* function EditScreen(props) {
21+
* const [initialValue, setInitialValue] = useState(props.initialValue);
22+
* const [value, setValue] = useState(initialValue);
23+
* const isDirty = useMemo(() => value !== initialValue, [value, initialValue]);
24+
*
25+
* async function save() {
26+
* await saveValue(value);
27+
* setInitialValue(value); // reset initial value to current value
28+
* }
29+
*
30+
* useCanExit(() => {
31+
* return isDirty ? window.confirm('Input is not saved. Are you sure you want to leave?') : true;
32+
* });
33+
*
34+
* return <div> <input type="text" value={value} onChange={setValue} /> <button onClick={save}>Save</button> </div>
35+
* }
36+
* ```
37+
*
38+
* Note that this hook adds a transition hook with [[HookMatchCriteria]] of ```{ exiting: thisState }```
39+
* where `thisState` is the state that the hook was rendered in.
40+
* See also: [[TransitionHookFn]]
41+
*
42+
* @param canExitCallback a callback that returns true/false, or a Promise for a true/false
43+
* @param options transition hook registration options
44+
*/
45+
export function useCanExit(canExitCallback: TransitionHookFn, options?: HookRegOptions) {
46+
const stateName = useParentView().context.name;
47+
useTransitionHook('onBefore', { exiting: stateName }, canExitCallback, options);
48+
}

0 commit comments

Comments
 (0)