Skip to content

Commit

Permalink
fix(useSref): Call go() with the actual user provided state string
Browse files Browse the repository at this point in the history
Pass fully qualified state name to parent UISrefActive
  • Loading branch information
christopherthielen authored and mergify[bot] committed Jan 6, 2020
1 parent 4c6b0f0 commit fba5321
Show file tree
Hide file tree
Showing 2 changed files with 72 additions and 68 deletions.
96 changes: 49 additions & 47 deletions src/hooks/__tests__/useSref.test.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,37 @@
import * as React from 'react';
import { act } from 'react-dom/test-utils';
import { makeTestRouter, muteConsoleErrors } from '../../__tests__/util';
import { UIView } from '../../components';
import { ReactStateDeclaration } from '../../interface';
import { useSref } from '../useSref';
import { UISrefActive, UISrefActiveContext } from '../../components/UISrefActive';

const state = { name: 'state', url: '/state' };
const state2 = { name: 'state2', url: '/state2' };
const state3 = { name: 'state3', url: '/state3/:param' };

const Link = ({ to, params = undefined, children = undefined }) => {
const sref = useSref(to, params);
return <a {...sref}>{children}</a>;
};

describe('useUiSref', () => {
let { router, routerGo, mountInRouter } = makeTestRouter([]);
beforeEach(() => ({ router, routerGo, mountInRouter } = makeTestRouter([state, state2, state3])));

it('throws if to is not a string', () => {
const Component = () => {
const sref = useSref(5 as any, {});
return <a {...sref} />;
};

muteConsoleErrors();
expect(() => mountInRouter(<Component />)).toThrow(/must be a string/);
expect(() => mountInRouter(<Link to={5} />)).toThrow(/must be a string/);
});

it('returns an href for the target state', () => {
const Component = () => {
const uiSref = useSref('state2', {});
return <a {...uiSref}>state2</a>;
};
const wrapper = mountInRouter(<Component />);
const wrapper = mountInRouter(<Link to="state2">state2</Link>);
expect(wrapper.html()).toBe('<a href="/state2">state2</a>');
});

it('returns an onClick function which activates the target state', () => {
const spy = jest.spyOn(router.stateService, 'go');
const Component = () => {
const uiSref = useSref('state');
return <a {...uiSref}>state</a>;
};

const wrapper = mountInRouter(<Component />);
const wrapper = mountInRouter(<Link to="state" />);
wrapper.simulate('click');

expect(spy).toBeCalledTimes(1);
Expand All @@ -47,50 +40,30 @@ describe('useUiSref', () => {

it('returns an onClick function which respects defaultPrevented', () => {
const spy = jest.spyOn(router.stateService, 'go');
const Component = () => {
const uiSref = useSref('state');
return <a {...uiSref}>state</a>;
};

const wrapper = mountInRouter(<Component />);
const wrapper = mountInRouter(<Link to="state" />);
wrapper.simulate('click', { defaultPrevented: true });

expect(spy).not.toHaveBeenCalled();
});

it('updates the href when the stateName changes', () => {
const Component = props => {
const uiSref = useSref(props.state);
return <a {...uiSref} />;
};

const wrapper = mountInRouter(<Component state="state" />);
const wrapper = mountInRouter(<Link to="state" />);
expect(wrapper.html()).toBe('<a href="/state"></a>');

wrapper.setProps({ state: 'state2' });
wrapper.setProps({ to: 'state2' });
expect(wrapper.html()).toBe('<a href="/state2"></a>');
});

it('updates the href when the params changes', () => {
const State3Link = props => {
const sref = useSref('state3', { param: props.param });
return <a {...sref} />;
};

const wrapper = mountInRouter(<State3Link param="123" />);
const wrapper = mountInRouter(<Link to="state3" params={{ param: '123' }} />);
expect(wrapper.html()).toBe('<a href="/state3/123"></a>');

wrapper.setProps({ param: '456' });
wrapper.setProps({ params: { param: '456' } });
expect(wrapper.html()).toBe('<a href="/state3/456"></a>');
});

it('updates href when the registered state is swapped out', async () => {
const State2Link = () => {
const sref = useSref('state2');
return <a {...sref} />;
};

const wrapper = mountInRouter(<State2Link />);
const wrapper = mountInRouter(<Link to="state2" />);
expect(wrapper.html()).toBe('<a href="/state2"></a>');
act(() => {
router.stateRegistry.deregister('state2');
Expand All @@ -113,16 +86,27 @@ describe('useUiSref', () => {
});
router.stateRegistry.register({ name: 'future.**', url: '/future', lazyLoad: lazyLoadFutureStates });

const wrapper = mountInRouter(<Link to="future.child" />);
expect(wrapper.html()).toBe('<a href="/future"></a>');

await routerGo('future.child');
expect(wrapper.update().html()).toBe('<a href="/future/child"></a>');
});

it('calls go() with the actual string provided (not with the name of the matched future state)', async () => {
router.stateRegistry.register({ name: 'future.**', url: '/future' });

const Link = props => {
const sref = useSref('future.child', { param: props.param });
return <a {...sref} />;
};

spyOn(router.stateService, 'go');
const wrapper = mountInRouter(<Link />);
expect(wrapper.html()).toBe('<a href="/future"></a>');

await routerGo('future.child');
expect(wrapper.update().html()).toBe('<a href="/future/child"></a>');
wrapper.find('a').simulate('click');
expect(router.stateService.go).toHaveBeenCalledTimes(1);
expect(router.stateService.go).toHaveBeenCalledWith('future.child', expect.anything(), expect.anything());
});

it('participates in parent UISrefActive component active state', async () => {
Expand All @@ -131,7 +115,7 @@ describe('useUiSref', () => {
const State2Link = props => {
const sref = useSref('state2');
return (
<a {...sref} className={props.className}>
<a {...sref} {...props}>
state2
</a>
);
Expand All @@ -145,6 +129,24 @@ describe('useUiSref', () => {
expect(wrapper.html()).toBe('<a href="/state2" class="active">state2</a>');
});

it('provides a fully qualified state name to the parent UISrefActive', async () => {
const spy = jest.fn();
const State2Link = () => {
return (
<UISrefActiveContext.Provider value={spy}>
<Link to={'.child'} />
</UISrefActiveContext.Provider>
);
};

router.stateRegistry.register({ name: 'parent', component: State2Link } as ReactStateDeclaration);
router.stateRegistry.register({ name: 'parent.child', component: UIView } as ReactStateDeclaration);

await routerGo('parent');
mountInRouter(<UIView />);
expect(spy).toHaveBeenCalledWith('parent.child', expect.anything());
});

it('participates in grandparent UISrefActive component active state', async () => {
await routerGo('state2');

Expand Down
44 changes: 23 additions & 21 deletions src/hooks/useSref.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { isString, StateDeclaration, TransitionOptions } from '@uirouter/core';
import { isString, RawParams, StateDeclaration, TransitionOptions } from '@uirouter/core';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { StateRegistry, UIRouterReact, UIViewAddress } from '../index';
import { UISrefActiveContext, UIViewContext } from '../components';
Expand All @@ -14,26 +14,29 @@ export interface LinkProps {
/** @hidden */
export const IncorrectStateNameTypeError = `The state name passed to useSref must be a string.`;

/** Gets all StateDeclarations that are registered in the StateRegistry. */
function useListOfAllStates(router: UIRouterReact) {
const initial = useMemo(() => router.stateRegistry.get(), []);
const [states, setStates] = useState(initial);
useEffect(() => router.stateRegistry.onStatesChanged(() => setStates(router.stateRegistry.get())), []);
return states;
}

// The state the sref was defined in. Used to resolve relative srefs.
/** Gets the StateDeclaration that this sref was defined in. Used to resolve relative refs. */
function useSrefContextState(router: UIRouterReact): StateDeclaration {
const parentUIViewAddress = useContext(UIViewContext);
return useMemo(() => {
return parentUIViewAddress ? parentUIViewAddress.context : router.stateRegistry.root();
}, [parentUIViewAddress, router]);
}

// returns the StateDeclaration that this sref targets, or undefined
/** Gets the StateDeclaration that this sref targets */
function useTargetState(router: UIRouterReact, stateName: string, context: StateDeclaration): StateDeclaration {
// Whenever allStates changes, get the target state again
// Whenever any states are added/removed from the registry, get the target state again
const allStates = useListOfAllStates(router);
return useMemo(() => router.stateRegistry.get(stateName, context), [stateName, context, allStates]);
return useMemo(() => {
return router.stateRegistry.get(stateName, context);
}, [router, stateName, context, allStates]);
}

/**
Expand All @@ -45,28 +48,23 @@ function useTargetState(router: UIRouterReact, stateName: string, context: State
* @param params Any parameter values
* @param options Transition options
*/
export function useSref(relativeStateName: string, params: object = {}, options: TransitionOptions = {}): LinkProps {
if (!isString(relativeStateName)) {
export function useSref(stateName: string, params: object = {}, options: TransitionOptions = {}): LinkProps {
if (!isString(stateName)) {
throw new Error(IncorrectStateNameTypeError);
}

const router = useRouter();
const parentUISrefActiveAddStateInfo = useContext(UISrefActiveContext);
const { stateService } = router;

const contextState: StateDeclaration = useSrefContextState(router);
const targetState: StateDeclaration = useTargetState(router, relativeStateName, contextState);
const stateName = targetState && targetState.name;
// Keep a memoized reference to the initial params object until the nested values actually change
// memoize the params object until the nested values actually change so they can be used as deps
const paramsMemo = useMemo(() => params, [useDeepObjectDiff(params)]);

const optionsMemo = useMemo(() => {
return { relative: contextState, inherit: true, ...options };
}, [contextState, options]);

const contextState: StateDeclaration = useSrefContextState(router);
const optionsMemo = useMemo(() => ({ relative: contextState, inherit: true, ...options }), [contextState, options]);
const targetState = useTargetState(router, stateName, contextState);
// Update href when the target StateDeclaration changes (in case the the state definition itself changes)
// This is necessary to handle things like future states
const href = useMemo(() => {
return router.stateService.href(stateName, paramsMemo, optionsMemo);
}, [router, stateName, paramsMemo, optionsMemo]);
return router.stateService.href(stateName, params, options);
}, [router, stateName, params, options, targetState]);

const onClick = useCallback(
(e: React.MouseEvent) => {
Expand All @@ -78,7 +76,11 @@ export function useSref(relativeStateName: string, params: object = {}, options:
[router, stateName, paramsMemo, optionsMemo]
);

useEffect(() => parentUISrefActiveAddStateInfo(stateName, paramsMemo), [stateName, paramsMemo]);
// Participate in any parent UISrefActive
const parentUISrefActiveAddStateInfo = useContext(UISrefActiveContext);
useEffect(() => {
return parentUISrefActiveAddStateInfo(targetState && targetState.name, paramsMemo);
}, [targetState, paramsMemo]);

return { onClick, href };
}

0 comments on commit fba5321

Please sign in to comment.