Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make Link and NavLink components accept "to" property as a function #5368

Merged
merged 1 commit into from Jun 12, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 11 additions & 11 deletions packages/react-router-dom/.size-snapshot.json
@@ -1,26 +1,26 @@
{
"esm/react-router-dom.js": {
"bundled": 8362,
"minified": 5058,
"gzipped": 1651,
"bundled": 8874,
"minified": 5312,
"gzipped": 1711,
"treeshaked": {
"rollup": {
"code": 453,
"code": 508,
"import_statements": 417
},
"webpack": {
"code": 1661
"code": 1800
}
}
},
"umd/react-router-dom.js": {
"bundled": 159395,
"minified": 56787,
"gzipped": 16387
"bundled": 159933,
"minified": 56923,
"gzipped": 16433
},
"umd/react-router-dom.min.js": {
"bundled": 96151,
"minified": 33747,
"gzipped": 9951
"bundled": 96671,
"minified": 33875,
"gzipped": 9980
}
}
12 changes: 12 additions & 0 deletions packages/react-router-dom/docs/api/Link.md
Expand Up @@ -36,6 +36,18 @@ An object that can have any of the following properties:
/>
```

## to: function

A function to which current location is passed as an argument and which should return location representation as a string or as an object

```jsx
<Link to={location => ({ ...location, pathname: "/courses" })} />
```

```jsx
<Link to={location => `${location.pathname}?sort=name`} />
```

## replace: bool

When `true`, clicking the link will replace the current entry in the history stack instead of adding a new one.
Expand Down
29 changes: 19 additions & 10 deletions packages/react-router-dom/modules/Link.js
@@ -1,8 +1,8 @@
import React from "react";
import { __RouterContext as RouterContext } from "react-router";
import { createLocation } from "history";
import PropTypes from "prop-types";
import invariant from "tiny-invariant";
import { resolveToLocation, normalizeToLocation } from "./utils/locationUtils";

function isModifiedEvent(event) {
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
Expand All @@ -12,7 +12,7 @@ function isModifiedEvent(event) {
* The public API for rendering a history-aware <a>.
*/
class Link extends React.Component {
handleClick(event, history) {
handleClick(event, context) {
try {
if (this.props.onClick) this.props.onClick(event);
} catch (ex) {
Expand All @@ -28,9 +28,13 @@ class Link extends React.Component {
) {
event.preventDefault();

const method = this.props.replace ? history.replace : history.push;
const location = resolveToLocation(this.props.to, context.location);

method(this.props.to);
const method = this.props.replace
? context.history.replace
: context.history.push;

method(location);
}
}

Expand All @@ -42,16 +46,17 @@ class Link extends React.Component {
{context => {
invariant(context, "You should not use <Link> outside a <Router>");

const location =
typeof to === "string"
? createLocation(to, null, null, context.location)
: to;
const location = normalizeToLocation(
resolveToLocation(to, context.location),
context.location
);

const href = location ? context.history.createHref(location) : "";

return (
<a
{...rest}
onClick={event => this.handleClick(event, context.history)}
onClick={event => this.handleClick(event, context)}
href={href}
ref={innerRef}
/>
Expand All @@ -63,7 +68,11 @@ class Link extends React.Component {
}

if (__DEV__) {
const toType = PropTypes.oneOfType([PropTypes.string, PropTypes.object]);
const toType = PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
PropTypes.func
]);
const innerRefType = PropTypes.oneOfType([
PropTypes.string,
PropTypes.func,
Expand Down
24 changes: 14 additions & 10 deletions packages/react-router-dom/modules/NavLink.js
@@ -1,8 +1,9 @@
import React from "react";
import { __RouterContext as RouterContext, matchPath } from "react-router";
import PropTypes from "prop-types";
import Link from "./Link";
import invariant from "tiny-invariant";
import Link from "./Link";
import { resolveToLocation, normalizeToLocation } from "./utils/locationUtils";

function joinClassnames(...classnames) {
return classnames.filter(i => i).join(" ");
Expand All @@ -24,19 +25,22 @@ function NavLink({
to,
...rest
}) {
const path = typeof to === "object" ? to.pathname : to;

// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
const escapedPath = path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");

return (
<RouterContext.Consumer>
{context => {
invariant(context, "You should not use <NavLink> outside a <Router>");

const pathToMatch = locationProp
? locationProp.pathname
: context.location.pathname;
const currentLocation = locationProp || context.location;
const { pathname: pathToMatch } = currentLocation;
const toLocation = normalizeToLocation(
resolveToLocation(to, currentLocation),
currentLocation
);
const { pathname: path } = toLocation;
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
const escapedPath =
path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");

const match = escapedPath
? matchPath(pathToMatch, { path: escapedPath, exact, strict })
: null;
Expand All @@ -54,7 +58,7 @@ function NavLink({
aria-current={(isActive && ariaCurrent) || null}
className={className}
style={style}
to={to}
to={toLocation}
{...rest}
/>
);
Expand Down
64 changes: 64 additions & 0 deletions packages/react-router-dom/modules/__tests__/Link-test.js
Expand Up @@ -75,6 +75,34 @@ describe("A <Link>", () => {
expect(a.getAttribute("href")).toEqual("/the/path?the=query#the-hash");
});

it("accepts an object returning function `to` prop", () => {
const to = location => ({ ...location, search: "foo=bar" });

renderStrict(
<MemoryRouter initialEntries={["/hello"]}>
<Link to={to}>link</Link>
</MemoryRouter>,
node
);

const a = node.querySelector("a");
expect(a.getAttribute("href")).toEqual("/hello?foo=bar");
});

it("accepts a string returning function `to` prop", () => {
const to = location => `${location.pathname}?foo=bar`;

ReactDOM.render(
<MemoryRouter initialEntries={["/hello"]}>
<Link to={to}>link</Link>
</MemoryRouter>,
node
);

const a = node.querySelector("a");
expect(a.getAttribute("href")).toEqual("/hello?foo=bar");
});

describe("with no pathname", () => {
it("resolves using the current location", () => {
renderStrict(
Expand Down Expand Up @@ -218,6 +246,42 @@ describe("A <Link>", () => {
expect(memoryHistory.push).toBeCalledWith(to);
});

it("calls onClick eventhandler and history.push with function `to` prop", () => {
const memoryHistoryFoo = createMemoryHistory({
initialEntries: ["/foo"]
});
memoryHistoryFoo.push = jest.fn();
const clickHandler = jest.fn();
let to = null;
const toFn = location => {
to = {
...location,
pathname: "hello",
search: "world"
};
return to;
};

renderStrict(
<Router history={memoryHistoryFoo}>
<Link to={toFn} onClick={clickHandler}>
link
</Link>
</Router>,
node
);

const a = node.querySelector("a");
ReactTestUtils.Simulate.click(a, {
defaultPrevented: false,
button: 0
});

expect(clickHandler).toBeCalledTimes(1);
expect(memoryHistoryFoo.push).toBeCalledTimes(1);
expect(memoryHistoryFoo.push).toBeCalledWith(to);
});

it("does not call history.push on right click", () => {
const to = "/the/path?the=query#the-hash";

Expand Down
34 changes: 34 additions & 0 deletions packages/react-router-dom/modules/__tests__/NavLink-test.js
Expand Up @@ -25,6 +25,21 @@ describe("A <NavLink>", () => {
expect(a.className).toContain("active");
});

it("applies its default activeClassName with function `to` prop", () => {
renderStrict(
<MemoryRouter initialEntries={["/pizza"]}>
<NavLink to={location => ({ ...location, pathname: "/pizza" })}>
Pizza!
</NavLink>
</MemoryRouter>,
node
);

const a = node.querySelector("a");

expect(a.className).toContain("active");
});

it("applies a custom activeClassName instead of the default", () => {
renderStrict(
<MemoryRouter initialEntries={["/pizza"]}>
Expand Down Expand Up @@ -472,6 +487,25 @@ describe("A <NavLink>", () => {
expect(a.className).toContain("active");
});

it("is passed as an argument to function `to` prop", () => {
renderStrict(
<MemoryRouter initialEntries={["/pizza"]}>
<NavLink
to={location => location}
activeClassName="selected"
location={{ pathname: "/pasta" }}
>
Pasta!
</NavLink>
</MemoryRouter>,
node
);

const a = node.querySelector("a");
expect(a.className).not.toContain("active");
expect(a.className).toContain("selected");
});

it("is not overwritten by the current location", () => {
renderStrict(
<MemoryRouter initialEntries={["/pasta"]}>
Expand Down
10 changes: 10 additions & 0 deletions packages/react-router-dom/modules/utils/locationUtils.js
@@ -0,0 +1,10 @@
import { createLocation } from "history";

export const resolveToLocation = (to, currentLocation) =>
typeof to === "function" ? to(currentLocation) : to;

export const normalizeToLocation = (to, currentLocation) => {
return typeof to === "string"
? createLocation(to, null, null, currentLocation)
: to;
};