},
51 | {
52 | path: 'models/:modelName*', name: 'models', handler: ModelLayout, children: [
53 | { path: 'models/:modelName', name: 'models.list', handler: ModelListPage },
54 | { path: 'models/:modelName/create', name: 'models.create', handler: ModelEditPage },
55 | { path: 'models/:modelName/:id', name: 'models.edit', handler: ModelEditPage }
56 | ]
57 | }
58 | ]
59 | }
60 |
61 | ],
62 | {
63 | domId: 'app',
64 | providers: [
65 | {component: SomeProvider, props: { store }},
66 | {component: AnotherProvider}
67 | ]
68 | });
69 | ```
70 |
71 | You are also able to have multiple ReactSpoon instances on a page with multiple anchor points/domIds.
72 |
73 | ### The ReactSpoon Class
74 | Its signature: new ReactSpoon(routeTree, opts)
75 |
76 | * routeTree: a JSON object to define hierarchial routes. See above.
77 | * opts: a JSON object with config options
78 | * opts.domId: The DOM id of the object to mount your routed app on.
79 | * opts.providers: An array of components and their props which you intend to wrap around your routed components.
80 | * opts.providers.*.component: The provider component to instantiate
81 | * opts.providers.*.props: The props to attach to this provider component when instantiating it.
82 | The order of defining providers is important. In the above example, this is the JSX equivalent
83 |
84 |
85 |
86 | // ... your routed components
87 |
88 |
89 |
90 | These have been replaced by opts.providers and are now **decommisioned**. Please upgrade by using the new opts.providers syntax:
91 | ~~* opts.provider: The component class declaration or equivalent of a provider to wrap your app with (In the future, this will alternatively be an array for nesting multiple provdiders)~~
92 | ~~* opts.providerProps: A prop map of attributes/properties to attach to the equivalent provider. (In the future, this will alternatively be an array of prop maps)~~
93 |
94 |
95 | ## True Nested Routing
96 | A layout rendering nested pages is really just rendering children.
97 | ```typescript
98 | import {Component} from 'react';
99 |
100 | class AppLayout extends Component {
101 |
102 | render(){
103 | ...
104 |
105 | {this.props.children}
106 |
107 | ...
108 | }
109 | }
110 | ```
111 |
112 |
113 | ## Nav Links (Named Routes)
114 |
115 | ```typescript
116 | import {Link} from 'react-spoon';
117 |
118 | ...
119 |
120 |
121 |
122 | //This will have class="active" when we are on the route named "dashboard".
123 | //
124 |
125 |
Dashboard
126 |
127 |
128 |
129 | //Passing params to named Routes
130 |
131 |
132 |
List All Users
133 |
134 |
135 |
136 |
Edit Role
137 |
138 |
139 |
140 | ```
141 |
142 | ## Programmatic navigation
143 |
144 | Every instance of React-Spoon makes use of the react context API for making itself visible in any component.
145 | You grab a reference to this instance by statically defining contextTypes in your component:
146 |
147 | ```typescript
148 | class MyComponent extends React.Component{
149 |
150 | ...
151 |
152 | static contextTypes = {
153 | router: PropTypes.any
154 | }
155 |
156 | ...
157 |
158 | someFunction = () => {
159 |
160 | //navigates to destination
161 | this.context.router.go('app.myRouteName', {routeParams} )
162 |
163 | //or if you just want the url without actually navigating to it
164 | const path = this.context.router.buildLink('app.myRouteName', {routeParams} )
165 | }
166 |
167 | ```
168 |
169 |
170 |
171 | It is highly recommended to navigate this way as opposed to just trying to change window.location.href... (even though that should still work).
172 |
173 | Also, only **named routes** can be programmatically navigated to at this time. (It's a better pattern/structure to navigate with named routes anyway!)
174 |
175 |
176 | ## On-Enter Hook
177 |
178 | Spoon will look for a static OnEnter(props) function declaration in your React Component and call it whenever it is navigating to that component.
179 | This respects nested routing too, with the topmost component's onEnter triggered first and the next in sequence. (First to Last)
180 | In the future, this function might be made to handle (returned) promises.
181 |
182 | ```typescript
183 | class DashboardPage extends React.Component{
184 |
185 | ...
186 |
187 | static onEnter(props){
188 | console.log('Entering Dashboard Page');
189 | }
190 | ...
191 |
192 | }
193 | ```
194 |
195 | ~~If you'd like an onLeave(...) hook, create an issue on github. I am actively using RSpoon for open-source dev so chances are I'll add that before you create it. Race ya :P~~
196 |
197 | ## On-Leave hook (Added since v1.4)
198 |
199 | Beat you to it! :D
200 |
201 | You can now create a static onLeave(props) function that RSpoon will call whenever it is navigating away from a route's component.
202 | This, like onEnter, respects nested routing. Only difference is that it reverses the order of activation, i.e. Last to First.
203 |
204 | ```typescript
205 | class DashboardPage extends React.Component{
206 |
207 | ...
208 |
209 | static onLeave(props){
210 | console.log('Leaving Dashboard Page');
211 | }
212 | ...
213 |
214 | }
215 | ```
216 |
217 | ## URL State (Added since v1.3)
218 |
219 | Use these to store and retrieve values or objects from the URL.
220 | It should not be used for persistence, but more so as a way for your users to be able to return to a very particular/predictable state of your app by using the exact same URL.
221 | e.g See what Google Maps does to it's URL while you navigate. Then try copy that URL in some other tab (or send it to a friend ) and see how it takes you back to that exact map center/zoom state.
222 |
223 | ### Storing State
224 |
225 | ```typescript
226 | import {storeState} from 'react-spoon';
227 |
228 |
229 | var myState = {
230 | paging: {
231 | page: 1,
232 | limit: 10
233 | },
234 | sort: { dateCreated: -1 },
235 | filters: []
236 | }
237 | }
238 |
239 | storeState('modelFilter', myState);
240 |
241 | ```
242 |
243 | Simple!
244 | You'll notice your URL changes after you call this function:
245 | e.g #/models/Foo**@{"modelFilter":{"paging":{"page":1,"limit":10},"sort":{"dateCreated":-1},"filters":[]}}**
246 |
247 |
248 | The First '@' is the delimeter that seperates your path from your data.
249 | The data is stored as a JSON string.
250 |
251 | You'll want to do this, for example, when the state you are trying to persist in your url changes
252 |
253 | ### Retrieving State
254 | Retrieving state is just as easy
255 | ```typescript
256 | import {getState} from 'react-spoon';
257 |
258 | ...
259 |
260 | myState = getState('modelFilter');
261 |
262 | ```
263 | And now myState is chuck full of all that state that is currently in your URL.
264 |
265 | Clearly, You probably want to do this when loading/mounting/onEntering something that needs this state.
266 |
267 |
268 | ## Q&A
269 |
270 | ### Is that JSON? Ewwww Why?
271 |
272 | JSON is more flexible to configure than ~~XML~~ JSX :D.
273 | For one, It's simply the best vehicle for conditional or even distributed route configuration.
274 | You can have different modules/functions/whatever define what they want for a route and it can all then be converged into one JS object that RSpoon consumes.
275 |
276 |
277 | ### Why another React Router?
278 |
279 | So while building [JollofJS](http://github.com/iyobo/jollofjs), I found that the routing libraries in the React ecosystem **just didn't seem to get** what routing libraries do IMO.
280 | They either had too much ceremony around actually using them, or they kept stripping themselves of useful features with every major release (fascinating right?).
281 | I also remember the devs of one claiming that "Named routes are an anti-pattern". :P
282 |
283 | Anyway! It became clear that if I was ever going to continue onwards without having to keep revisiting this routing issues, I'd just have to grab the bull by the horns and create one that worked "for me".
284 | I'm sharing it in hopes that it works for someone out there too.
285 |
286 | ### Okay but what's with 'Spoon'?
287 |
288 | I created React-Spoon while creating [JollofJS](http://github.com/iyobo/jollofjs) (Think Django for NodeJS - Still in Development).
289 | I created its built-in admin tool with React.
290 | JollofJS is named after Jollof Rice, a delicious Nigerian rice dish.
291 | You'd usually use a spoon when serving rice... which made React-Spoon an appropriate router name.
292 |
293 | ...Yes, I'm a Foodie :P
294 |
295 |
296 | ### Why use a hook? React has life-cycle functions!
297 |
298 | Because React's life-cycle functions can be unpredictable, especially when doing nested routing.
299 | Say you wanted to print the name of each page the router was rendering in the EXACT NESTED ORDER each time you route there i.e AppLayout > ModelLayout > ModelListPage ,
300 |
301 | You may find that they don't show up in this order or sometimes don't show up at all. Sometimes, one route will show up multiple times while others never do.
302 |
303 | Defining/using the onEnter hook just gives you one solid way to reliably control your react app's page-nesting sequencing with improved precision.
304 | And it is WAY more cleaner and elegant to define this static hook at the class/component level, and not in your main file or wherever you are defining the route tree.
305 |
306 | Besides wanting an elegant, full-featured **frontend** routing library...needing one with Reliable, Object-Level route-event handling was one of the key reasons why I created React-Spoon
307 |
308 |
309 |
310 |
311 | ## Development
312 | * `npm i`
313 |
314 | * Make sure babel is installed globally
315 |
316 | * Build with `npm run build`;
317 |
--------------------------------------------------------------------------------
/jestconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "transform": {
3 | "^.+\\.test\\.ts$": "ts-jest"
4 | },
5 | "testRegex": "^.+\\.test\\.ts$",
6 | "moduleFileExtensions": ["ts", "js", "json", "node"],
7 | "preset": "ts-jest/presets/js-with-ts",
8 | "testEnvironment": "node",
9 | "testMatch": null
10 | }
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-spoon",
3 | "version": "1.8.1",
4 | "description": "A simple and sane router for React",
5 | "types": "dist/index.d.ts",
6 | "main": "dist/index.js",
7 | "keywords": [
8 | "react",
9 | "reactjs",
10 | "router",
11 | "routing",
12 | "jollof",
13 | "iyobo"
14 | ],
15 | "repository": {
16 | "type": "github",
17 | "url": "https://github.com/iyobo/react-spoon"
18 | },
19 | "scripts": {
20 | "test": "jest --config jestconfig.json",
21 | "test:watch": "jest --config jestconfig.json --watch",
22 | "build": "tsc",
23 | "prepublish": "tsc && npm test",
24 | "dev": "tsc -w",
25 | "lint": "tslint -c tslint.json -p tsconfig.json"
26 | },
27 | "files": [
28 | "dist/**/*"
29 | ],
30 | "author": "Iyobo Eki",
31 | "license": "MIT",
32 | "dependencies": {
33 | "prop-types": "^15.7.2",
34 | "react": "16.11.0",
35 | "react-dom": "16.11.0",
36 | "url-pattern": "^1.0.3"
37 | },
38 | "peerDependencies": {},
39 | "devDependencies": {
40 | "@types/jest": "^24.0.25",
41 | "@types/react": "16.9.17",
42 | "@types/react-dom": "16.9.4",
43 | "jest": "^24.9.0",
44 | "supertest": "^4.0.2",
45 | "ts-jest": "^24.3.0",
46 | "tslint": "^5.20.1",
47 | "typescript": "^3.5.3"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by iyobo on 2017-03-21.
3 | */
4 | import React, {Component} from 'react';
5 | import Proptypes from 'prop-types';
6 | import * as ReactDom from 'react-dom';
7 | import UrlPattern from 'url-pattern';
8 | import {RouteDef, SpoonOptions} from './types';
9 |
10 | const URL_STATE_DELIM = '@';
11 |
12 | function getWindowHash() {
13 | let hash = window.location.hash.replace(/^#\/?|\/$/g, '');
14 |
15 | //Do not include state
16 | hash = hash.split(URL_STATE_DELIM)[0];
17 |
18 | //console.log('hash:', hash);
19 | return hash;
20 | }
21 |
22 | /**
23 | * If pattern match, return map of params.
24 | * @param routePath
25 | * @param location
26 | */
27 | function matchRoute(routePath, location) {
28 | //console.log( 'checking', routePath, 'with location',location);
29 |
30 | //This matcher is dumb and doesn't accept empty strings
31 | if (routePath === '' && location === '') {
32 | return {}; //match as empty string
33 | } else if (routePath !== '') {
34 |
35 | const pattern = new UrlPattern(routePath);
36 | return pattern.match(location);
37 | } else {
38 | return null;
39 | }
40 | }
41 |
42 | function objectsAreEqual(a, b) {
43 | for (var prop in a) {
44 | if (a.hasOwnProperty(prop)) {
45 | if (b.hasOwnProperty(prop)) {
46 | if (typeof a[prop] === 'object') {
47 | if (!objectsAreEqual(a[prop], b[prop])) return false;
48 | } else {
49 | if (a[prop] !== b[prop]) return false;
50 | }
51 | } else {
52 | return false;
53 | }
54 | }
55 | }
56 | return true;
57 | }
58 |
59 | /**
60 | * Routing context
61 | */
62 | export const SpoonContext = React.createContext({route: {}});
63 |
64 | // export class SpoonContextProvider extends Component {
65 | // static childContextTypes = {
66 | // router: React.PropTypes.object
67 | // };
68 | //
69 | // getChildContext() {
70 | // return {router: this.props.router};
71 | // }
72 | //
73 | // render() {
74 | // return