├── .gitignore ├── .idea ├── encodings.xml ├── jsLibraryMappings.xml ├── misc.xml ├── modules.xml ├── react-spoon.iml ├── typescript-compiler.xml └── vcs.xml ├── .npmignore ├── LICENSE ├── MIT ├── README.md ├── jestconfig.json ├── package-lock.json ├── package.json ├── src ├── index.tsx ├── tests │ ├── all.test.ts │ └── util │ │ └── TestClasses.ts └── types.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | 4 | # IntelliJ 5 | .idea 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # next.js build output 67 | .next 68 | 69 | dist/ -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/react-spoon.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/typescript-compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | 65 | .idea 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Iyobo Eki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MIT: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2019 Iyobo Eki. class-nodes. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React-Spoon 2 | 3 | A sane front-end Routing library for React 4 | Github: https://github.com/iyobo/react-spoon 5 | 6 | 7 | 8 | ## Features 9 | * Making the router is as easy as instantiating a class. 10 | * Simple JSON-based configuration. 11 | * Nested routing 12 | * Component-level Navigation Hooks (static + reliable triggering) 13 | * Navigation Links (w/ Hierarchial active state) 14 | * Store and retrieve state from URL as Key:Object or key:Value pairs 15 | 16 | 17 | ## Caveat Emptor 18 | * Only hash-based routing is supported at this time. 19 | 20 | 21 | ## How to use 22 | 23 | ```typescript 24 | npm install --save react-spoon 25 | ``` 26 | 27 | 28 | in html 29 | ```typescript 30 |
31 | ``` 32 | 33 | In main file: 34 | ```typescript 35 | 36 | import React from 'react'; 37 | import {ReactSpoon} from 'react-spoon'; 38 | 39 | const SomeProvider = {...} //Redux, MobX or any provider 40 | const store = {...} //any props the provider needs 41 | 42 | new ReactSpoon([ 43 | { 44 | name: '', 45 | path: '*', 46 | handler: AppLayout, 47 | children: [ 48 | { path: '', redirectTo: 'dashboard' }, 49 | { path: 'dashboard', name: 'dashboard', handler: DashboardPage }, 50 | { path: 'about', name: 'about', handler: () =>

About

}, 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
{this.props.children}
; 75 | // } 76 | // } 77 | 78 | 79 | export class ReactSpoon { 80 | private domId: any; 81 | private routes: any; 82 | private namedRoutes: any; 83 | private providers: any; 84 | private currentRouteHierarchy: any; 85 | private currentHierarchyProps: any; 86 | private state: { router: { buildLink: (name, params) => string; currentParams: {}; go: (name?: string, params?: {}, opts?: {}) => void; location: string; activeRoutes: {} } }; 87 | 88 | /** 89 | * 90 | * @param routes 91 | * @param opts 92 | * @param opts.domId 93 | * @param opts.providers 94 | */ 95 | constructor(routes: RouteDef, opts: SpoonOptions) { 96 | 97 | this.domId = opts.domId || 'app'; 98 | this.routes = routes; 99 | this.namedRoutes = {}; 100 | 101 | //Providers 102 | //this.providerEl = opts.provider; 103 | //this.providerProps = opts.providerProps; 104 | 105 | this.providers = opts.providers || []; 106 | 107 | 108 | this.currentRouteHierarchy = null; 109 | this.currentHierarchyProps = {}; 110 | 111 | this.state = { 112 | router: { 113 | location: getWindowHash(), 114 | activeRoutes: {}, 115 | go: (name = '', params = {}, opts = {}) => { 116 | const route = this.namedRoutes[name]; 117 | if (route) { 118 | 119 | let path = route.path; 120 | 121 | const pattern = new UrlPattern(path); 122 | 123 | window.location.hash = '/' + pattern.stringify(params); 124 | 125 | this.state.router.currentParams = params; 126 | } else { 127 | throw new Error('Unknown route name: ' + name); 128 | } 129 | }, 130 | buildLink: (name, params) => { 131 | 132 | const route = this.namedRoutes[name]; 133 | let link = '#/'; 134 | if (route) { 135 | const pattern = new UrlPattern(route.path); 136 | link = '#/' + pattern.stringify(params); 137 | } 138 | 139 | //console.log('creating link', name, params, link) 140 | 141 | return link; 142 | }, 143 | currentParams: {} 144 | } 145 | }; 146 | 147 | 148 | //console.log('RouteState:', this.state); 149 | 150 | const setupRoutes = (routeTree: RouteDef[]) => { 151 | routeTree.forEach((it) => { 152 | 153 | /** 154 | * Register names 155 | */ 156 | if (it.name) 157 | this.namedRoutes[it.name] = it; 158 | 159 | /** 160 | * Create context 161 | */ 162 | if (it.handler) { 163 | const h = ((it.handler as any).wrappedComponent || it.handler); 164 | h.contextTypes = { 165 | router: Proptypes.any 166 | }; 167 | h.contextType = SpoonContext; 168 | } 169 | 170 | if (it.children) { 171 | setupRoutes(it.children); 172 | } 173 | 174 | 175 | }); 176 | }; 177 | 178 | setupRoutes(this.routes); 179 | 180 | 181 | this.onRouteChanged(); 182 | 183 | // Handle browser navigation events 184 | window.addEventListener('hashchange', this.onRouteChanged, false); 185 | } 186 | 187 | goToName = (name = '', params = {}, opts = {}) => { 188 | const route = this.namedRoutes[name]; 189 | if (route) { 190 | 191 | let path = route.path; 192 | 193 | const pattern = new UrlPattern(path); 194 | 195 | 196 | window.location.hash = '/' + pattern.stringify(params); 197 | 198 | this.state.router.currentParams = params; 199 | } else { 200 | throw new Error('Unknown route name: ' + name); 201 | } 202 | }; 203 | 204 | 205 | /** 206 | * 207 | * @param changes 208 | * @param changes.location - pure hash of current window url 209 | */ 210 | changeRoute(changes) { 211 | 212 | /*** 213 | * First thing to do on route change is to call onLeave on all active routes' handler components, from last to first. 214 | */ 215 | if (this.currentRouteHierarchy) { 216 | 217 | for (let i = this.currentRouteHierarchy.length - 1; i >= 0; i--) { 218 | const it = this.currentRouteHierarchy[i]; 219 | 220 | if ((it.handler.wrappedComponent || it.handler).onLeave) { 221 | it.handler.wrappedComponent.onLeave(this.currentHierarchyProps); 222 | } 223 | 224 | } 225 | } 226 | 227 | 228 | this.state = {router: {...this.state.router, ...changes, activeRoutes: {}}}; 229 | 230 | var routeHierarchy = []; 231 | //this.state.router.activeRoutes = {}; 232 | 233 | var routeParams = null; 234 | var activeRoute = null; 235 | 236 | const checkRoute = (routes, location, depth) => { 237 | for (let i in routes) { 238 | const route = routes[i]; 239 | 240 | routeParams = matchRoute(route.path, location); 241 | 242 | //console.log(route.name, '(', route.path, ')', ' --> ', routeParams); 243 | if (routeParams) { 244 | //The route is a match 245 | this.state.router.currentParams = routeParams; 246 | 247 | //are we supposed to redirect? 248 | if (route.redirectTo) { 249 | const toRoute = this.namedRoutes[route.redirectTo]; 250 | 251 | //console.log('redirecting to ' + toRoute.path); 252 | //TODO: Add redirect wih params option 253 | window.location.hash = '/' + toRoute.path; 254 | 255 | return; 256 | } 257 | 258 | /** 259 | * This is an active route node 260 | */ 261 | activeRoute = route; 262 | routeHierarchy.push(activeRoute); 263 | 264 | if (activeRoute.name) { 265 | this.state.router.activeRoutes[activeRoute.name] = activeRoute; 266 | } 267 | 268 | 269 | /** 270 | * Okay now lets check if this route has children 271 | */ 272 | if (activeRoute.children) { 273 | 274 | //check the children too 275 | checkRoute(activeRoute.children, location, i); 276 | } 277 | 278 | break; 279 | } 280 | } 281 | }; 282 | 283 | //loop through routes and output the right match 284 | checkRoute(this.routes, this.state.router.location, 0); 285 | 286 | 287 | var component; 288 | /** 289 | * Build component hierarchy from route hierarchy. 290 | */ 291 | const rhLength = routeHierarchy.length; 292 | 293 | //Determine the combined props, which is a merger of all the props in this router instance 294 | const providerProps = this.providers.reduce((result, next) => { 295 | return {...result, ...next.props}; 296 | }, {}); 297 | 298 | const combinedProps = {...this.state, ...routeParams, ...providerProps}; 299 | 300 | 301 | for (let i = rhLength - 1; i >= 0; i--) { 302 | 303 | const route = routeHierarchy[i]; 304 | const handler = route.handler; 305 | 306 | if (component) { 307 | component = React.createElement(handler, {...combinedProps, key: 'route' + i}, [component]); 308 | //React.createElement(route.handler, props, [component]); 309 | } else { 310 | /** 311 | * Nested 404s 312 | * If the last item in the route hierarchy has a children field, then this is obviously a nested 404 313 | */ 314 | if (i === rhLength - 1 && route.children) { 315 | component = React.createElement(handler, {...combinedProps, key: 'route' + i}, [

404 317 | Nothing 318 | Here

]); 319 | } else { 320 | //component = ; 321 | component = React.createElement(handler, {...combinedProps, key: 'route' + i}); 322 | } 323 | } 324 | 325 | } 326 | 327 | //Wrap with Given provider elements e.g. Mobx, muitheme etc 328 | for (let i = this.providers.length - 1; i >= 0; i--) { 329 | const provider = this.providers[i]; 330 | //console.log(provider) 331 | component = React.createElement(provider.component, provider.props || {}, component); 332 | } 333 | 334 | //Wrap with Routing context 335 | // @ts-ignore 336 | component = React.createElement(SpoonContext.Provider as any, {value: this.state} as any, component); 337 | 338 | /** 339 | * generic 404 340 | */ 341 | if (!component) { 342 | component =

404 Nothing Here

; 343 | } 344 | 345 | /** 346 | * Hooks 347 | * Run all static onEnters in the handlers. 348 | */ 349 | routeHierarchy.forEach((it) => { 350 | 351 | if ((it.handler.wrappedComponent || it.handler).onEnter) { 352 | it.handler.wrappedComponent.onEnter(combinedProps); 353 | } 354 | }); 355 | 356 | 357 | /** 358 | * At this point, this is the current route hierarchy 359 | * 360 | */ 361 | this.currentRouteHierarchy = routeHierarchy; 362 | this.currentHierarchyProps = combinedProps; 363 | 364 | ReactDom.render(component, document.getElementById(this.domId)); 365 | } 366 | 367 | /** 368 | * Extract the url state and load it. 369 | * Url state is a json string of everything after the very first @ of window.location.href 370 | */ 371 | loadStateFromUrl() { 372 | 373 | /** 374 | * AGAIN, the purpose of URLState is not to store persistent state, but rather to DISPLAY a URL that better matches your page's current/reproducable state...reproducability that you define. 375 | * @type {{}} 376 | */ 377 | urlState = {}; 378 | 379 | //console.log(window.location.href) 380 | const stateString = stringAfter(window.location.href, URL_STATE_DELIM); 381 | 382 | if (!stateString) { 383 | //console.log('no url state to extract'); 384 | return; 385 | } 386 | 387 | try { 388 | urlState = JSON.parse(stateString); 389 | //console.log('State pulled from URL', urlState) 390 | } catch (err) { 391 | console.error('Error loading data from state', err, stateString); 392 | } 393 | } 394 | 395 | onRouteChanged = () => { 396 | //console.log('route changing') 397 | 398 | //First load URL state 399 | this.loadStateFromUrl(); 400 | 401 | //Now navigate 402 | this.changeRoute({location: getWindowHash()}); 403 | 404 | 405 | }; 406 | 407 | } 408 | 409 | function stringAfter(str, delim) { 410 | if (str.indexOf(URL_STATE_DELIM) > -1) { 411 | return str.substring(str.indexOf(delim) + 1); 412 | } else { 413 | return null; 414 | } 415 | } 416 | 417 | function stringBefore(str, delim) { 418 | return str.split(delim)[0]; 419 | } 420 | 421 | /** 422 | * 423 | */ 424 | export type ILink = { toName?: string; to?: string; params?: any; onClick?: (event) => void; target?: string } 425 | 426 | export class Link extends Component { 427 | 428 | state = {}; 429 | 430 | static contextTypes = { 431 | router: Proptypes.object 432 | }; 433 | static contextType = SpoonContext; 434 | private isActive: boolean; 435 | private href: string; 436 | 437 | /** 438 | * Upon creating a link, we need to attach it to a route. 439 | * We must find the best route that matches it's 'to's 440 | */ 441 | componentWillMount() { 442 | var router = this.context.router; 443 | 444 | if (!router) { 445 | throw new Error('You are trying to define a outside of a Laddle Router context. link: ' + this.props.to || this.props.toName); 446 | } 447 | 448 | //build href for this link 449 | 450 | this.href = this.props.toName ? router.buildLink(this.props.toName, this.props.params) : this.props.to || '#/'; 451 | 452 | } 453 | 454 | /** 455 | * Not used! Links will now build hrefs on Mount instead of per-click. Performance boost. 456 | * @param event 457 | */ 458 | onClick = (event) => { 459 | if (this.props.onClick) this.props.onClick(event); 460 | 461 | if (event.defaultPrevented) return; 462 | 463 | var router = this.context.router; 464 | 465 | if (!router) { 466 | throw new Error('You are trying to use a outside of a Laddle Router context. Link: ' + this.props.to || this.props.toName); 467 | } 468 | 469 | //!router ? process.env.NODE_ENV !== 'production' ? invariant(false, 's rendered outside of a router context cannot navigate.') : invariant(false) : void 0; 470 | 471 | //if (isModifiedEvent(event) || !isLeftClickEvent(event)) return; 472 | 473 | // If target prop is set (e.g. to "_blank"), let browser handle link. 474 | /* istanbul ignore if: untestable with Karma */ 475 | if (this.props.target) return; 476 | 477 | event.preventDefault(); 478 | 479 | //router.push(resolveToLocation(this.props.to, router)); 480 | 481 | if (this.props.to) { 482 | window.location.hash = '#' + this.props.to; 483 | } else if (this.props.toName) { 484 | router.go(this.props.toName, this.props.params); 485 | } 486 | 487 | }; 488 | 489 | checkIfActive() { 490 | this.isActive = false; 491 | 492 | //console.log(this.props.to || this.props.toName, 'this params', this.props.params, ' current route params', this.context.router.currentParams, this.context.router.activeRoutes); 493 | 494 | ////The basic requirement for being active is that a toName prop exists 495 | //const basicRequirement = !!this.props.activeClassName; 496 | // 497 | ////Next, t 498 | 499 | if (this.props.toName && this.context.router.activeRoutes[this.props.toName] && objectsAreEqual(this.props.params, this.context.router.currentParams)) { 500 | //console.log(this.props.to || this.props.toName, 'is active') 501 | //console.log(this.props.to || this.props.toName,'this params', this.props.params, ' last params', this.context.router.currentParams) 502 | this.isActive = true; 503 | } 504 | } 505 | 506 | //componentWillUpdate() { 507 | // this.checkIfActive(); 508 | //} 509 | 510 | render() { 511 | this.checkIfActive(); 512 | 513 | return ( 514 | 516 | {this.props.children} 517 | 518 | ); 519 | } 520 | } 521 | 522 | // querySync feature 523 | let urlState = {}; 524 | 525 | /** 526 | * Only works if pushState is supported 527 | */ 528 | function replaceUrlStateInUrl() { 529 | 530 | const realHref = stringBefore(window.location.href, URL_STATE_DELIM); 531 | const stateHref = realHref + URL_STATE_DELIM + JSON.stringify(urlState); 532 | 533 | window.history.replaceState(null, null, stateHref); 534 | } 535 | 536 | export const storeState = (key, value) => { 537 | if (value) { 538 | //console.log('storing url state', key, value); 539 | urlState[key] = value; 540 | } else if (urlState[key]) { 541 | //pass in null value to wipe key:val from urlState 542 | delete urlState[key]; 543 | } 544 | 545 | replaceUrlStateInUrl(); 546 | }; 547 | export const getState = (key) => { 548 | return urlState[key]; 549 | 550 | }; -------------------------------------------------------------------------------- /src/tests/all.test.ts: -------------------------------------------------------------------------------- 1 | // import {BadParentClass, GoodParentClass, SoloClass} from './util/TestClasses'; 2 | // 3 | // describe('Without uncircled', () => { 4 | // it('when serializing, throws Circular structure error if not inheriting ParentClassNode', () => { 5 | // try { 6 | // const parent = new BadParentClass(); 7 | // const serializedClass = JSON.stringify(parent); 8 | // 9 | // fail('The last line should have thrown an error'); 10 | // } catch (err) { 11 | // // This will either be a circular reference error or a maximum call stack error 12 | // } 13 | // }); 14 | // }); 15 | // 16 | // describe('With Uncircle', () => { 17 | // it('serializes', () => { 18 | // 19 | // const parent = new GoodParentClass(); 20 | // const serializedClass = JSON.stringify(parent).replace(/\\/g, ''); 21 | // 22 | // expect(serializedClass).toEqual('{"foo":"bar","childStore":{"ab":"wonton","myDate":"1970-01-01T00:16:40.000Z","child":{"mn":"Fiery","op":"jutsu"}}}'); 23 | // }); 24 | // 25 | // it('deserializes JSON object', () => { 26 | // const parent: GoodParentClass = new GoodParentClass(); 27 | // 28 | // parent.deserialize({foo: 'wopo'}); 29 | // 30 | // expect(parent.foo).toBe('wopo'); 31 | // }); 32 | // it('deserializes JSON String', () => { 33 | // const parent: GoodParentClass = new GoodParentClass(); 34 | // 35 | // parent.deserialize(`{"foo":"wopo"}`); 36 | // 37 | // expect(parent.foo).toBe('wopo'); 38 | // }); 39 | // it('deserializes JSON nested Object', () => { 40 | // const parent: GoodParentClass = new GoodParentClass(); 41 | // 42 | // parent.deserialize({ 43 | // foo: 'wopo', 44 | // childStore: { 45 | // ab: 'why' 46 | // } 47 | // }); 48 | // 49 | // expect(parent.foo).toBe('wopo'); 50 | // expect(parent.childStore.ab).toBe('why'); 51 | // }); 52 | // it('deserializes JSON nested string', () => { 53 | // const parent: GoodParentClass = new GoodParentClass(); 54 | // 55 | // parent.deserialize(`{ 56 | // "foo": "wopo", 57 | // "childStore": { 58 | // "ab": "why" 59 | // } 60 | // }`); 61 | // 62 | // expect(parent.foo).toBe('wopo'); 63 | // expect(parent.childStore.ab).toBe('why'); 64 | // }); 65 | // it('can deserialize serialize output', () => { 66 | // const parent: GoodParentClass = new GoodParentClass(); 67 | // parent.foo = 'super'; 68 | // parent.childStore.ab = 'duper'; 69 | // const serializedTree = JSON.stringify(parent); 70 | // 71 | // parent.deserialize(serializedTree); 72 | // 73 | // expect(parent.foo).toBe('super'); 74 | // expect(parent.childStore.ab).toBe('duper'); 75 | // }); 76 | // 77 | // it('can deserialize serialize output for solo class', () => { 78 | // const solo: SoloClass = new SoloClass(); 79 | // solo.spider = 'girl'; 80 | // solo.justiceLeague.spiderMan = true; 81 | // const serializedTree = JSON.stringify(solo); 82 | // 83 | // const newSolo = new SoloClass(); 84 | // expect(newSolo.spider).toBe('man'); 85 | // 86 | // newSolo.deserialize(serializedTree); 87 | // 88 | // expect(newSolo.spider).toBe('girl'); 89 | // expect(newSolo.justiceLeague.spiderMan).toBe(true); 90 | // }); 91 | // }); 92 | // 93 | 94 | it('meh', () => { 95 | expect(true).toBe(true); 96 | }); -------------------------------------------------------------------------------- /src/tests/util/TestClasses.ts: -------------------------------------------------------------------------------- 1 | // import {DateField, ParentField, Deserializer} from '../../index'; 2 | // 3 | // export class BadParentClass { 4 | // cow = 'moo'; 5 | // childStore: BadLeafClass; 6 | // 7 | // constructor() { 8 | // this.childStore = new BadLeafClass(this); 9 | // } 10 | // } 11 | // 12 | // export class BadLeafClass { 13 | // ab = 'wonton'; 14 | // cd = 'faro'; 15 | // parent: any; 16 | // 17 | // constructor(parentNode: any) { 18 | // this.parent = parentNode; 19 | // } 20 | // } 21 | // 22 | // // ---- good 23 | // @Deserializer 24 | // export class GoodParentClass { 25 | // foo = 'bar'; 26 | // childStore: GoodChildClass; 27 | // 28 | // constructor() { 29 | // this.childStore = new GoodChildClass(this); 30 | // } 31 | // 32 | // deserialize(rawObject: string | any) { 33 | // } 34 | // } 35 | // 36 | // export class GoodChildClass { 37 | // ab = 'wonton'; 38 | // @DateField myDate = new Date(1000000); 39 | // @ParentField parent: GoodParentClass; 40 | // 41 | // child: NestedLeafClass; 42 | // 43 | // constructor(parentNode: GoodParentClass) { 44 | // this.parent = parentNode; 45 | // this.child = new NestedLeafClass(this); 46 | // } 47 | // } 48 | // 49 | // export class NestedLeafClass { 50 | // mn = 'Fiery'; 51 | // op = 'jutsu'; 52 | // @ParentField parent: GoodChildClass; 53 | // 54 | // constructor(parentNode: GoodChildClass) { 55 | // this.parent = parentNode; 56 | // } 57 | // } 58 | // 59 | // @Deserializer 60 | // export class SoloClass { 61 | // spider = 'man'; 62 | // wonder = 'woman'; 63 | // 64 | // justiceLeague = { 65 | // superMan: true, 66 | // spiderMan: false 67 | // }; 68 | // 69 | // deserialize(raw) {} 70 | // } 71 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {Component} from 'react'; 2 | 3 | 4 | export interface SpoonOptions { 5 | domId: string; 6 | providers: { 7 | domId: string, 8 | providers: { component: Component, props: { [key: string]: any } }[] 9 | } 10 | } 11 | 12 | export interface RouteDef { 13 | name: string; 14 | path: string; 15 | handler: Component; 16 | children: RouteDef[] 17 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "lib": [ 7 | "es6", 8 | "dom", 9 | "dom.iterable", 10 | "scripthost" 11 | ], 12 | "declaration": true, 13 | "sourceMap": true, 14 | "outDir": "dist", 15 | "baseUrl": ".", 16 | "esModuleInterop": true, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true 19 | }, 20 | "include": [ 21 | "src/**/*" 22 | ], 23 | "exclude": [ 24 | "dist" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "one-line": [ 13 | true, 14 | "check-open-brace", 15 | "check-whitespace" 16 | ], 17 | "no-var-keyword": true, 18 | "quotemark": [ 19 | true, 20 | "single", 21 | "avoid-escape" 22 | ], 23 | "semicolon": [ 24 | true, 25 | "always", 26 | "ignore-bound-class-methods" 27 | ], 28 | // "whitespace": [ 29 | // true, 30 | // "check-branch", 31 | // "check-decl", 32 | // "check-operator", 33 | // "check-module", 34 | // "check-separator", 35 | // "check-type" 36 | // ], 37 | "typedef-whitespace": [ 38 | true, 39 | { 40 | "call-signature": "nospace", 41 | "index-signature": "nospace", 42 | "parameter": "nospace", 43 | "property-declaration": "nospace", 44 | "variable-declaration": "nospace" 45 | }, 46 | { 47 | "call-signature": "onespace", 48 | "index-signature": "onespace", 49 | "parameter": "onespace", 50 | "property-declaration": "onespace", 51 | "variable-declaration": "onespace" 52 | } 53 | ], 54 | "no-internal-module": true, 55 | // "no-trailing-whitespace": true, 56 | // "no-null-keyword": true, 57 | "prefer-const": true, 58 | "jsdoc-format": true 59 | } 60 | } 61 | --------------------------------------------------------------------------------