diff --git a/README.md b/README.md index 1cc44173..59b125c0 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ The router follows three basic principles: -- The URL is just another member of the state tree. -- URL changes are just plain actions. -- Route matching should be simple and extendable. +* The URL is just another member of the state tree. +* URL changes are just plain actions. +* Route matching should be simple and extendable. While the core router does not depend on any view library, it provides flexible React bindings and components. @@ -73,16 +73,12 @@ const routes = { // Install the router into the store for a browser-only environment. // routerForBrowser is a factory method that returns a store // enhancer and a middleware. -const { - reducer, - middleware, - enhancer -} = routerForBrowser({ +const { reducer, middleware, enhancer } = routerForBrowser({ // The configured routes. Required. routes, // The basename for all routes. Optional. basename: '/example' -}) +}); const clientOnlyStore = createStore( combineReducers({ router: reducer, yourReducer }), @@ -111,7 +107,7 @@ if (initialLocation) { import { push, replace, go, goBack, goForward } from 'redux-little-router'; // `push` and `replace` -// +// // Equivalent to pushState and replaceState in the History API. // If you installed the router with a basename, `push` and `replace` // know to automatically prepend paths with it. Both action creators @@ -132,14 +128,17 @@ replace({ // Optional second argument accepts a `persistQuery` field. When true, // reuse the query object from the previous location instead of replacing // or emptying it. -push({ - pathname: '/messages', - query: { - filter: 'business' +push( + { + pathname: '/messages', + query: { + filter: 'business' + } + }, + { + persistQuery: true } -}, { - persistQuery: true -}); +); // Navigates forward or backward a specified number of locations go(3); @@ -150,6 +149,19 @@ goBack(); // Equivalent to the browser forward button goForward(); + +// Creates a function that blocks navigation with window.confirm when returning a string. +// You can customize how the prompt works by passing a `historyOptions` option with a +// `getUserConfirmation` function to `routerForBrowser`, `routerForExpress`, etc. +// See https://www.npmjs.com/package/history#blocking-transitions +block((location, action) => { + if (location.pathname === '/messages') { + return 'Are you sure you want to leave the messages view?'; + } +}); + +// Removes the previous `block()`. +unblock(); ``` Note: if you used the vanilla action types prior to `v13`, you'll need to migrate to using the public action creators. @@ -227,8 +239,8 @@ Your custom reducers or selectors can derive a large portion of your app's state `redux-little-router` provides the following to make React integration easier: -- A `` component that conditionally renders children based on current route and/or location conditions. -- A `` component that sends navigation actions to the middleware when tapped or clicked. `` respects default modifier key and right-click behavior. A sibling component, ``, persists the existing query string on navigation. +* A `` component that conditionally renders children based on current route and/or location conditions. +* A `` component that sends navigation actions to the middleware when tapped or clicked. `` respects default modifier key and right-click behavior. A sibling component, ``, persists the existing query string on navigation. Instances of each component automatically `connect()` to the router state with `react-redux`. @@ -246,7 +258,7 @@ Think of `` as the midpoint of a "flexibility continuum" that starts w The simplest fragment is one that displays when a route is active: ```jsx - +

This is the team messages page!

``` @@ -266,13 +278,13 @@ To show a `Fragment` when no other `Fragment`s match a route, use `` lets you nest fragments to match your UI hierarchy to your route hierarchy, much like the `` component does in `react-router@v3`. Given a URL of `/about/bio/dat-boi`, and the following elements: ```jsx - +

About

- +

Bios

- +

Dat Boi

Something something whaddup

@@ -302,12 +314,20 @@ To show a `Fragment` when no other `Fragment`s match a route, use `` makes basic component-per-page navigation easy: ```jsx - +
- - - - + + + + + + + + + + + +
``` @@ -317,7 +337,7 @@ To show a `Fragment` when no other `Fragment`s match a route, use `` component is simple: ```jsx - + Share Order ``` @@ -325,12 +345,15 @@ Using the `` component is simple: Alternatively, you can pass in a location object to `href`. This is useful for passing query objects: ```jsx - + Share Order ``` @@ -339,8 +362,8 @@ To change how `` renders when its `href` matches the current location (i.e ```jsx Wat @@ -379,5 +402,5 @@ We consider `redux-little-router` to be **stable**. Any API changes will be incr ## Community -- [react-redux-boiler](https://github.com/justrossthings/react-redux-boiler) -- [hoc-little-router](https://github.com/Trampss/hoc-little-router) +* [react-redux-boiler](https://github.com/justrossthings/react-redux-boiler) +* [hoc-little-router](https://github.com/Trampss/hoc-little-router) diff --git a/demo/client/app.js b/demo/client/app.js index ee21c948..badfa0bb 100644 --- a/demo/client/app.js +++ b/demo/client/app.js @@ -5,7 +5,12 @@ import { render } from 'react-dom'; /* Invert comments for immutable */ import { createStore, combineReducers, compose, applyMiddleware } from 'redux'; -import { routerForBrowser, initializeCurrentLocation } from '../../src'; +import { + routerForBrowser, + initializeCurrentLocation, + block, + unblock +} from '../../src'; // import { createStore, compose, applyMiddleware } from 'redux'; // import { combineReducers } from 'redux-immutable'; // import { Map, fromJS } from 'immutable'; @@ -15,6 +20,8 @@ import routes from './routes'; import wrap from './wrap'; import Demo from './demo'; +const UNBLOCK_DELAY = 10000; + /* Invert comments for immutable */ const { reducer, enhancer, middleware } = routerForBrowser({ routes }); const initialState = window.__INITIAL_STATE || {}; @@ -37,6 +44,17 @@ const store = createStore( const initialLocation = store.getState().router; // const initialLocation = store.getState().get('router').toJS(); +store.dispatch( + // eslint-disable-next-line consistent-return + block(() => { + if (location.pathname.indexOf('cheese') !== -1) { + return `Are you sure you want to see other pages? It's really all downhill from here.`; + } + }) +); + +setTimeout(() => store.dispatch(unblock()), UNBLOCK_DELAY); + if (initialLocation) { store.dispatch(initializeCurrentLocation(initialLocation)); } diff --git a/interfaces/history.js b/interfaces/history.js index c34dbe02..c9bdf5fa 100644 --- a/interfaces/history.js +++ b/interfaces/history.js @@ -43,17 +43,19 @@ declare module 'history' { declare type GetUserConfirmation = ( message: string, - callback: (continueTransition: bool) => void + callback: (continueTransition: boolean) => void ) => void; declare type BrowserHistoryOptions = {| basename?: string, - forceRefresh?: bool, + forceRefresh?: boolean, keyLength?: number, - getUserConfirmation: GetUserConfirmation; + getUserConfirmation?: GetUserConfirmation |}; - declare function createBrowserHistory(options?: BrowserHistoryOptions): History; + declare function createBrowserHistory( + options?: BrowserHistoryOptions + ): History; declare type MemoryHistoryOptions = {| initialEntries?: Array, @@ -62,7 +64,9 @@ declare module 'history' { getUserConfirmation?: GetUserConfirmation |}; - declare function createMemoryHistory(options?: MemoryHistoryOptions): MemoryHistory; + declare function createMemoryHistory( + options?: MemoryHistoryOptions + ): MemoryHistory; declare type HashType = 'slash' | 'noslash' | 'hashbang'; @@ -81,7 +85,7 @@ declare module 'history' { currentLocation?: Location ): Location; - declare function locationsAreEqual(a: Location, b: Location): bool; + declare function locationsAreEqual(a: Location, b: Location): boolean; declare function parsePath(path: string): Location; declare function createPath(location: Location): string; diff --git a/src/actions.js b/src/actions.js index d721e176..1b1daef2 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,4 +1,5 @@ // @flow +import type { BlockCallback } from 'history'; import type { Location, LocationOptions, Href } from './types'; import { @@ -7,6 +8,8 @@ import { GO, GO_BACK, GO_FORWARD, + BLOCK, + UNBLOCK, LOCATION_CHANGED, REPLACE_ROUTES, DID_REPLACE_ROUTES @@ -33,6 +36,13 @@ export const go = (index: number) => ({ export const goBack = () => ({ type: GO_BACK }); export const goForward = () => ({ type: GO_FORWARD }); +export const block = (historyShouldBlock: BlockCallback) => ({ + type: BLOCK, + payload: historyShouldBlock +}); + +export const unblock = () => ({ type: UNBLOCK }); + export const locationDidChange = (location: Location) => ({ type: LOCATION_CHANGED, payload: location diff --git a/src/environment/browser-router.js b/src/environment/browser-router.js index 88d3545c..3fa11697 100644 --- a/src/environment/browser-router.js +++ b/src/environment/browser-router.js @@ -1,5 +1,5 @@ // @flow -import type { History } from 'history'; +import type { History, BrowserHistoryOptions } from 'history'; import createBrowserHistory from 'history/createBrowserHistory'; @@ -9,34 +9,36 @@ import install from '../install'; type BrowserRouterArgs = { routes: Object, basename: string, + historyOptions: BrowserHistoryOptions, history: History }; -export const createBrowserRouter = (installer: Function) => - ({ - routes, - basename, - history = createBrowserHistory({ basename }) - }: BrowserRouterArgs) => { - const { - pathname: fullPathname, - search, - hash, - state: { key, state } = {} - } = history.location; - - // Strip the basename from the initial pathname - const pathname = fullPathname.indexOf(basename) === 0 +export const createBrowserRouter = (installer: Function) => ({ + routes, + basename, + historyOptions = {}, + history = createBrowserHistory({ basename, ...historyOptions }) +}: BrowserRouterArgs) => { + const { + pathname: fullPathname, + search, + hash, + state: { key, state } = {} + } = history.location; + + // Strip the basename from the initial pathname + const pathname = + fullPathname.indexOf(basename) === 0 ? fullPathname.slice(basename.length) : fullPathname; - const descriptor = basename - ? { pathname, basename, search, hash, key, state } - : { pathname, search, hash, key, state }; + const descriptor = basename + ? { pathname, basename, search, hash, key, state } + : { pathname, search, hash, key, state }; - const location = normalizeHref(descriptor); + const location = normalizeHref(descriptor); - return installer({ routes, history, location }); - }; + return installer({ routes, history, location }); +}; export default createBrowserRouter(install); diff --git a/src/environment/express-router.js b/src/environment/express-router.js index 044e17e8..509df55d 100644 --- a/src/environment/express-router.js +++ b/src/environment/express-router.js @@ -1,4 +1,6 @@ // @flow +import type { MemoryHistoryOptions } from 'history'; + import createMemoryHistory from 'history/createMemoryHistory'; import normalizeHref from '../util/normalize-href'; @@ -12,7 +14,7 @@ type ServerRouterArgs = { url: string, query: { [key: string]: string } }, - passRouterStateToReducer?: boolean + historyOptions: MemoryHistoryOptions }; const locationForRequest = request => { @@ -23,12 +25,15 @@ const locationForRequest = request => { return normalizeHref(descriptor); }; -export const createExpressRouter = (installer: Function) => - ({ routes, request }: ServerRouterArgs) => { - const history = createMemoryHistory(); - const location = locationForRequest(request); +export const createExpressRouter = (installer: Function) => ({ + routes, + request, + historyOptions = {} +}: ServerRouterArgs) => { + const history = createMemoryHistory(historyOptions); + const location = locationForRequest(request); - return installer({ routes, history, location }); - }; + return installer({ routes, history, location }); +}; export default createExpressRouter(install); diff --git a/src/environment/hapi-router.js b/src/environment/hapi-router.js index a466a115..674566d9 100644 --- a/src/environment/hapi-router.js +++ b/src/environment/hapi-router.js @@ -1,4 +1,6 @@ // @flow +import type { MemoryHistoryOptions } from 'history'; + import createMemoryHistory from 'history/createMemoryHistory'; import normalizeHref from '../util/normalize-href'; @@ -10,19 +12,23 @@ type ServerRouterArgs = { path: string, url: string, query: { [key: string]: string } - } + }, + historyOptions: MemoryHistoryOptions }; -export const createHapiRouter = (installer: Function) => - ({ routes, request }: ServerRouterArgs) => { - const history = createMemoryHistory(); +export const createHapiRouter = (installer: Function) => ({ + routes, + request, + historyOptions = {} +}: ServerRouterArgs) => { + const history = createMemoryHistory(historyOptions); - const location = normalizeHref({ - pathname: request.path, - query: request.query - }); + const location = normalizeHref({ + pathname: request.path, + query: request.query + }); - return installer({ routes, history, location }); - }; + return installer({ routes, history, location }); +}; export default createHapiRouter(install); diff --git a/src/environment/hash-router.js b/src/environment/hash-router.js index c8c49c65..74fd1ff5 100644 --- a/src/environment/hash-router.js +++ b/src/environment/hash-router.js @@ -1,5 +1,5 @@ // @flow -import type { History } from 'history'; +import type { History, HashHistoryOptions } from 'history'; import createHashHistory from 'history/createHashHistory'; import normalizeHref from '../util/normalize-href'; @@ -9,23 +9,24 @@ type HashRouterArgs = { routes: Object, basename: string, hashType: string, + historyOptions: HashHistoryOptions, history: History }; -export const createHashRouter = (installer: Function) => - ({ - routes, - basename, - hashType = 'slash', - history = createHashHistory({ basename, hashType }) - }: HashRouterArgs) => { - const descriptor = basename - ? { basename, ...history.location } - : history.location; +export const createHashRouter = (installer: Function) => ({ + routes, + basename, + hashType = 'slash', + historyOptions, + history = createHashHistory({ basename, hashType, ...historyOptions }) +}: HashRouterArgs) => { + const descriptor = basename + ? { basename, ...history.location } + : history.location; - const location = normalizeHref(descriptor); + const location = normalizeHref(descriptor); - return installer({ routes, history, location }); - }; + return installer({ routes, history, location }); +}; export default createHashRouter(install); diff --git a/src/index.js b/src/index.js index a9ce8f03..f2e18ca2 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,9 @@ import { GO, GO_BACK, GO_FORWARD, + POP, + BLOCK, + UNBLOCK, REPLACE_ROUTES, DID_REPLACE_ROUTES } from './types'; @@ -16,6 +19,8 @@ import { go, goBack, goForward, + block, + unblock, replaceRoutes, initializeCurrentLocation } from './actions'; @@ -63,6 +68,8 @@ export { go, goBack, goForward, + block, + unblock, replaceRoutes, // Public action types LOCATION_CHANGED, @@ -71,6 +78,9 @@ export { GO, GO_FORWARD, GO_BACK, + POP, + BLOCK, + UNBLOCK, REPLACE_ROUTES, DID_REPLACE_ROUTES }; diff --git a/src/middleware.js b/src/middleware.js index c8a065df..b3494244 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -10,6 +10,8 @@ import { GO, GO_BACK, GO_FORWARD, + BLOCK, + UNBLOCK, isNavigationAction } from './types'; @@ -26,6 +28,8 @@ type HandleNavArgs = { query?: Query }; +let unblock = null; + const navigate = (history, action) => { switch (action.type) { case PUSH: @@ -43,12 +47,25 @@ const navigate = (history, action) => { case GO_FORWARD: history.goForward(); break; + case BLOCK: + unblock = history.block(action.payload); + break; + case UNBLOCK: + if (unblock) { + unblock(); + } + break; default: break; } }; -export const handleNavigationAction = ({ next, action, history, query }: HandleNavArgs) => { +export const handleNavigationAction = ({ + next, + action, + history, + query +}: HandleNavArgs) => { // Synchronously dispatch the original action so that the // reducer can add it to its location queue const originalDispatch = next(action); @@ -72,12 +89,11 @@ export const handleNavigationAction = ({ next, action, history, query }: HandleN return originalDispatch; }; -export default ({ history }: MiddlewareArgs) => - ({ getState }: Store) => - (next: Dispatch<*>) => - (action: RouterAction) => { - const { query } = getState().router; - return isNavigationAction(action) ? - handleNavigationAction({ next, action, history, query }) : - next(action); - }; +export default ({ history }: MiddlewareArgs) => ({ + getState +}: Store) => (next: Dispatch<*>) => (action: RouterAction) => { + const { query } = getState().router; + return isNavigationAction(action) + ? handleNavigationAction({ next, action, history, query }) + : next(action); +}; diff --git a/src/types.js b/src/types.js index ef9ac148..e9811e1b 100644 --- a/src/types.js +++ b/src/types.js @@ -1,5 +1,5 @@ // @flow -import type { Location as HistoryLocation } from 'history'; +import type { Location as HistoryLocation, BlockCallback } from 'history'; export type Query = { [key: string]: string }; export type Params = { [key: string]: string }; @@ -35,20 +35,35 @@ export const GO = 'ROUTER_GO'; export const GO_BACK = 'ROUTER_GO_BACK'; export const GO_FORWARD = 'ROUTER_GO_FORWARD'; export const POP = 'ROUTER_POP'; +export const BLOCK = 'ROUTER_BLOCK'; +export const UNBLOCK = 'ROUTER_UNBLOCK'; export const REPLACE_ROUTES = 'ROUTER_REPLACE_ROUTES'; export const DID_REPLACE_ROUTES = 'ROUTER_DID_REPLACE_ROUTES'; const actionsWithPayload = [PUSH, REPLACE, GO, POP]; -const actions = [...actionsWithPayload, GO_FORWARD, GO_BACK, POP]; +const actions = [ + ...actionsWithPayload, + GO_FORWARD, + GO_BACK, + POP, + BLOCK, + UNBLOCK +]; export const isNavigationAction = (action: { type: $Subtype }) => actions.indexOf(action.type) !== -1; -export const isNavigationActionWithPayload = (action: { type: $Subtype }) => - actionsWithPayload.indexOf(action.type) !== -1; +export const isNavigationActionWithPayload = (action: { + type: $Subtype +}) => actionsWithPayload.indexOf(action.type) !== -1; export type BareAction = { - type: 'ROUTER_GO_BACK' | 'ROUTER_GO_FORWARD' + type: 'ROUTER_GO_BACK' | 'ROUTER_GO_FORWARD' | 'ROUTER_UNBLOCK' +}; + +export type FunctionAction = { + type: 'ROUTER_BLOCK', + payload: BlockCallback }; export type IndexedAction = { @@ -61,4 +76,8 @@ export type LocationAction = { payload: Location }; -export type RouterAction = BareAction | IndexedAction | LocationAction; +export type RouterAction = + | BareAction + | FunctionAction + | IndexedAction + | LocationAction; diff --git a/test/actions.spec.js b/test/actions.spec.js index a0411d1a..d7d04cda 100644 --- a/test/actions.spec.js +++ b/test/actions.spec.js @@ -7,6 +7,8 @@ import { GO, GO_BACK, GO_FORWARD, + BLOCK, + UNBLOCK, LOCATION_CHANGED, DID_REPLACE_ROUTES } from '../src/types'; @@ -17,6 +19,8 @@ import { go, goBack, goForward, + block, + unblock, replaceRoutes, didReplaceRoutes, locationDidChange, @@ -80,6 +84,16 @@ describe('Action creators', () => { expect(goForward()).to.deep.equal({ type: GO_FORWARD }); }); + it('creates a BLOCK action', () => { + const action = block(() => {}); + expect(action.type).to.equal(BLOCK); + expect(action.payload).to.be.a('Function'); + }); + + it('creates an UNBLOCK action', () => { + expect(unblock()).to.deep.equal({ type: UNBLOCK }); + }); + it('creates a REPLACE_ROUTES action with flattened route payloads and options', () => { const routes = { '/': { '/this': { '/is': { '/nested': '' } } } }; const action = replaceRoutes(routes); diff --git a/test/middleware.spec.js b/test/middleware.spec.js index beafb1c4..840605bd 100644 --- a/test/middleware.spec.js +++ b/test/middleware.spec.js @@ -4,7 +4,7 @@ import sinonChai from 'sinon-chai'; import { fromJS } from 'immutable'; import { createStore, applyMiddleware } from 'redux'; -import { PUSH, REPLACE, GO, GO_BACK, GO_FORWARD } from '../src/types'; +import { PUSH, REPLACE, GO, GO_BACK, GO_FORWARD, BLOCK } from '../src/types'; import routerMiddleware from '../src/middleware'; import immutableMiddleware from '../src/immutable/middleware'; @@ -33,7 +33,8 @@ const actionMethodMap = { [REPLACE]: 'replace', [GO]: 'go', [GO_BACK]: 'goBack', - [GO_FORWARD]: 'goForward' + [GO_FORWARD]: 'goForward', + [BLOCK]: 'block' }; const middlewareTest = { @@ -47,103 +48,106 @@ const immutableMiddlewareTest = { testLabel: 'immutable router middleware' }; -[middlewareTest, immutableMiddlewareTest].forEach(({ - middleware, - toState, - testLabel -}) => { - describe(`${testLabel}`, () => { - let store; - let historyStub; - - beforeEach(() => { - historyStub = { - push: sandbox.stub(), - replace: sandbox.stub(), - go: sandbox.stub(), - goBack: sandbox.stub(), - goForward: sandbox.stub(), - listen: sandbox.stub() - }; - const reducer = () => toState({ - router: { - query: { - is: 'cool' - } - } +[middlewareTest, immutableMiddlewareTest].forEach( + ({ middleware, toState, testLabel }) => { + describe(`${testLabel}`, () => { + let store; + let historyStub; + + beforeEach(() => { + historyStub = { + push: sandbox.stub(), + replace: sandbox.stub(), + go: sandbox.stub(), + goBack: sandbox.stub(), + goForward: sandbox.stub(), + listen: sandbox.stub(), + block: sandbox.stub() + }; + const reducer = () => + toState({ + router: { + query: { + is: 'cool' + } + } + }); + const initialState = toState({}); + + store = createStore( + reducer, + initialState, + applyMiddleware( + middleware({ history: historyStub }), + consumerMiddleware + ) + ); + + sandbox.spy(store, 'dispatch'); }); - const initialState = toState({}); - - store = createStore( - reducer, - initialState, - applyMiddleware(middleware({ history: historyStub }), consumerMiddleware) - ); - sandbox.spy(store, 'dispatch'); - }); + Object.keys(actionMethodMap).forEach(actionType => { + const method = actionMethodMap[actionType]; - Object.keys(actionMethodMap).forEach(actionType => { - const method = actionMethodMap[actionType]; + it(`calls history.${method} when intercepting ${actionType}`, () => { + store.dispatch({ + type: actionType, + payload: {} + }); - it(`calls history.${method} when intercepting ${actionType}`, () => { - store.dispatch({ - type: actionType, - payload: {} + expect(historyStub[method]).to.have.been.calledOnce; }); - - expect(historyStub[method]).to.have.been.calledOnce; }); - }); - [PUSH, REPLACE].forEach(actionType => { - const method = actionMethodMap[actionType]; + [PUSH, REPLACE].forEach(actionType => { + const method = actionMethodMap[actionType]; - it(`calls history.${method} with merged queries when requesting persistence`, () => { - store.dispatch({ - type: actionType, - payload: { + it(`calls history.${method} with merged queries when requesting persistence`, () => { + store.dispatch({ + type: actionType, + payload: { + query: { + has: 'socks' + }, + options: { + persistQuery: true + } + } + }); + + expect(historyStub[method]).to.have.been.calledWith({ query: { + is: 'cool', has: 'socks' }, + search: '?has=socks&is=cool', options: { persistQuery: true } - } + }); }); - - expect(historyStub[method]).to.have.been.calledWith({ - query: { - is: 'cool', - has: 'socks' - }, - search: '?has=socks&is=cool', - options: { - persistQuery: true - } - }) }); - }); - it('passes normal actions through the dispatch chain', () => { - store.dispatch({ - type: 'NOT_MY_ACTION_NOT_MY_PROBLEM', - payload: {} - }); + it('passes normal actions through the dispatch chain', () => { + store.dispatch({ + type: 'NOT_MY_ACTION_NOT_MY_PROBLEM', + payload: {} + }); - Object.keys(actionMethodMap).forEach(actionType => { - const method = actionMethodMap[actionType]; - expect(historyStub[method]).to.not.have.been.called; + Object.keys(actionMethodMap).forEach(actionType => { + const method = actionMethodMap[actionType]; + expect(historyStub[method]).to.not.have.been.called; + }); }); - }); - it('allows for dispatching router actions in consumer middleware', () => { - store.dispatch({ - type: REFRAGULATE, - payload: {} - }); + it('allows for dispatching router actions in consumer middleware', () => { + store.dispatch({ + type: REFRAGULATE, + payload: {} + }); - expect(historyStub.push).to.have.been.calledOnce; + expect(historyStub.push).to.have.been.calledOnce; + }); }); - }); -}); + } +);