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 #2 #6467

Closed
wants to merge 1 commit into from
Closed
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
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) {
if (this.props.onClick) this.props.onClick(event);

if (
Expand All @@ -23,9 +23,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 @@ -37,16 +41,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 @@ -58,7 +63,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]);

Link.propTypes = {
Expand Down
22 changes: 14 additions & 8 deletions packages/react-router-dom/modules/NavLink.js
Expand Up @@ -3,11 +3,22 @@ import { Route } from "react-router";
import PropTypes from "prop-types";

import Link from "./Link";
import { resolveToLocation, normalizeToLocation } from "./utils/locationUtils";

function joinClassnames(...classnames) {
return classnames.filter(i => i).join(" ");
}

function resolvePath(to, location) {
const { pathname } = normalizeToLocation(
resolveToLocation(to, location),
location
);

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

/**
* A <Link> wrapper that knows if it's "active" or not.
*/
Expand All @@ -18,23 +29,18 @@ function NavLink({
className: classNameProp,
exact,
isActive: isActiveProp,
location,
location: locationProp,
strict,
style: styleProp,
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 (
<Route
path={escapedPath}
path={location => resolvePath(to, locationProp || location)}
exact={exact}
strict={strict}
location={location}
location={locationProp}
children={({ location, match }) => {
const isActive = !!(isActiveProp
? isActiveProp(match, location)
Expand Down
61 changes: 60 additions & 1 deletion packages/react-router-dom/modules/__tests__/Link-test.js
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import { MemoryRouter, HashRouter, Link } from "react-router-dom";
import { MemoryRouter, HashRouter, Link, Route } from "react-router-dom";
import { Simulate } from "react-dom/test-utils";

import renderStrict from "./utils/renderStrict";

Expand Down Expand Up @@ -74,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 @@ -109,6 +138,36 @@ describe("A <Link>", () => {
);
});

describe("When a <Link> is clicked", () => {
it("changes the location with function `to` prop", () => {
const to = location => ({
...location,
pathname: "hello",
search: "world"
});

renderStrict(
<MemoryRouter initialEntries={["/hi"]}>
<div>
<Route path="/hi" render={() => <Link to={to}>link</Link>} />
<Route
path="/hello"
render={({ location }) => (
<div>{`Hello, ${location.search.substr(1)}`}</div>
)}
/>
</div>
</MemoryRouter>,
node
);

expect(node.textContent).not.toBe("Hello, world");
const a = node.getElementsByTagName("a")[0];
Simulate.click(a, { button: 0 });
expect(node.textContent).toBe("Hello, world");
});
});

describe("with a <HashRouter>", () => {
afterEach(() => {
window.history.replaceState(null, "", "#");
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
11 changes: 11 additions & 0 deletions packages/react-router-dom/modules/utils/locationUtils.js
@@ -0,0 +1,11 @@
import { createLocation } from "history";

export function resolveToLocation(to, currentLocation) {
return typeof to === "function" ? to(currentLocation) : to;
}

export function normalizeToLocation(to, currentLocation) {
return typeof to === "string"
? createLocation(to, null, null, currentLocation)
: to;
}
7 changes: 5 additions & 2 deletions packages/react-router/modules/Route.js
Expand Up @@ -7,17 +7,19 @@ import warning from "tiny-warning";
import RouterContext from "./RouterContext";
import matchPath from "./matchPath";
import warnAboutGettingProperty from "./utils/warnAboutGettingProperty";
import resolvePath from "./utils/resolvePath";

function isEmptyChildren(children) {
return React.Children.count(children) === 0;
}

function getContext(props, context) {
const location = props.location || context.location;
const path = resolvePath(props.path, location);
const match = props.computedMatch
? props.computedMatch // <Switch> already computed the match for us
: props.path
? matchPath(location.pathname, props)
: path
? matchPath(location.pathname, { ...props, path })
: context.match;

return { ...context, location, match };
Expand Down Expand Up @@ -139,6 +141,7 @@ if (__DEV__) {
location: PropTypes.object,
path: PropTypes.oneOfType([
PropTypes.string,
PropTypes.func,
PropTypes.arrayOf(PropTypes.string)
]),
render: PropTypes.func,
Expand Down
4 changes: 3 additions & 1 deletion packages/react-router/modules/Switch.js
Expand Up @@ -5,6 +5,7 @@ import warning from "tiny-warning";

import RouterContext from "./RouterContext";
import matchPath from "./matchPath";
import resolvePath from "./utils/resolvePath";

/**
* The public API for rendering the first <Route> that matches.
Expand All @@ -28,7 +29,8 @@ class Switch extends React.Component {
if (match == null && React.isValidElement(child)) {
element = child;

const path = child.props.path || child.props.from;
const path =
resolvePath(child.props.path, location) || child.props.from;

match = path
? matchPath(location.pathname, { ...child.props, path })
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router/modules/utils/resolvePath.js
@@ -0,0 +1,3 @@
export default function resolvePath(path, location) {
return typeof path === "function" ? path(location) : path;
}