├── .gitignore ├── LICENSE.txt ├── README.md ├── example └── simple-counter │ ├── containers │ └── App.tsx │ ├── index.tsx │ ├── redux │ ├── actions │ │ ├── counter.ts │ │ └── index.ts │ ├── connector.ts │ ├── interfaces │ │ ├── actions.ts │ │ ├── app.ts │ │ └── state.ts │ └── paths.ts │ └── webpack.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── connector.test.tsx ├── connector.ts ├── createApp.ts ├── implementAction.ts ├── index.ts ├── paths.test.ts ├── paths.ts ├── selectors.test.ts ├── selectors.ts ├── types.ts ├── typesafeRedux.ts └── utils │ ├── comparators.test.ts │ ├── comparators.ts │ ├── flow.ts │ ├── get.test.ts │ ├── get.ts │ ├── ofType.ts │ ├── set.test.ts │ ├── set.ts │ ├── unset.test.ts │ └── unset.ts ├── test ├── app.ts ├── composability.test.tsx ├── lib.ts └── testability.test.ts ├── tsconfig.json ├── typings └── lodash-fp.d.ts ├── usage-diagram-lg.png └── usage-diagram-sm.png /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .vscode 4 | compiled 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | npm i types-first-ui 3 | ``` 4 | 5 | # Types-First UI 6 | 7 | Types-First UI is an opinionated framework for building long-lived, maintainable UI codebases. It uses TypeScript and the power of its type system to guarantee the completeness and correctness of a Redux backend that can be connected to React components in a performant way. It places the focus on first defining your system in terms of the data and types that will drive it, then filling in the blanks. This project is inspired heavily by [re-frame](https://github.com/Day8/re-frame). 8 | 9 | ## Who is this for? 10 | 11 | Well, first and foremost, it's for us at Avero. This library represents the codification and enforcement of what we have established as our best practices for building interfaces. However, we believe that there is significant value in this approach and that it is worth sharing. 12 | 13 | Hopefully, there are others who can use and benefit from this work directly. But even if not, we think it is valuable to share our approach and give visibility into how one group of engineers chooses to approach some of the hard problems of building maintainable UI codebases. 14 | 15 | More generally, this project might be for you if you believe... 16 | 17 | - Types are important 18 | - TypeScript is good 19 | - Flat state tree -- all reducers have access to the entire tree 20 | - Actions may have a reducer, an epic, both, or neither. M:N relationship between Actions => Redux primitives 21 | - Epics (backed by redux-observable) as mechanism for side effects/middleware (vs. thunks or sagas) 22 | - Observables are pretty dope 23 | 24 | ## **Philosophy** 25 | 26 | Redux is an event dispatching system that operates on a single global state atom that is only modifiable via actions. It makes little sense to begin building your application before defining two interfaces: the shape of your state and the shape of all actions that operate on that state. From there we can leverage the strict unidirectional flow of Redux in our type system. 27 | 28 | You should be familiar with the basic terminology of Redux (Actions, Reducers, Action Creators, Stores, Middleware) and RxJS (Observables, Epics) before reading further. The documentation for [Redux](https://redux.js.org/) and [Epics](https://redux-observable.js.org/docs/basics/Epics.html) are excellent, so we'd strongly encourage reading it. 29 | 30 | This project aims to facilitate this "types-first" style of application design while providing utilities focused around maximizing type safety and maintaining interop with existing Redux libraries. 31 | 32 | ## **Anti-Pitch** 33 | 34 | If you dislike types "getting in your way" or think of them as secondary to your application, then this is not the framework for you. At Avero we believe in starting with your API first, which in this case is the interfaces of your application. 35 | 36 | If you do not care about the long-term maintainability of your codebase, such as writing a todo app or a school project, this may be overkill for you. This framework is designed and optimized for building large, production UI projects that will have a long lifespan and many contributors. 37 | 38 | You will occasionally run into cryptic error messages (e.g. key inference on Paths) due to relying heavily on type inference. Deep lookup types + mapped types + conditional types + inference makes the compiler work pretty hard... which leads to the next point. 39 | 40 | VS Code is a bit sluggish with this right now. You will not receive the instant 10ms feedback you may be used to, so there is a tradeoff here. Hopefully this will improve as the compiler matures around these newer type features. When I tried Webstorm it was not giving me the same level of inference as VS Code (Types-First UI requires TS 2.9+). 41 | 42 | ## Usage 43 | 44 | Here's a diagram showing the following usage steps. Click to embiggen. 45 | 46 | 47 | 48 | Let's create a Redux counter application with Types-First UI. We will include basic pending/error handling and a mock API call to show slightly more complexity than the normal counter app. 49 | 50 | ```typescript 51 | // 1. Create State Tree interface / initialState 52 | export interface State { 53 | counter: number; 54 | error: string; 55 | pendingRequest: boolean; 56 | }; 57 | 58 | export const initialState: State = { 59 | counter: 0, 60 | error: '', 61 | pendingRequest: false 62 | }; 63 | 64 | // 2. Create ActionTypes enum 65 | export enum ActionTypes = { 66 | ADD_REQUEST = 'ADD_REQUEST', 67 | ADD_FAIL = 'ADD_FAIL' 68 | ADD_SUCCESS = 'ADD_SUCCESS' 69 | }; 70 | 71 | // 3. Create Action interfaces 72 | export interface Actions { 73 | [ActionTypes.ADD_REQUEST]: { type: ActionTypes.ADD_REQUEST, payload: { tryAdd: number } }; 74 | [ActionTypes.ADD_FAIL]: { type: ActionTypes.ADD_FAIL, payload: { error: string } }; 75 | [ActionTypes.ADD_SUCCESS]: { type: ActionTypes.ADD_SUCCESS, payload: { newCount: number} }; 76 | }; 77 | 78 | export type AppActions = Actions[keyof Actions]; // creates Union type of actions that we will pass in to createTypesafeRedux 79 | ``` 80 | 81 | The first thing we want to do is define the interfaces of our application. The entire UI depends on state and action interfaces, so it makes sense to start with them first. Additionally, most maintenance work involves adding or modifying actions, so keeping them in one place is helpful for maintainability. Any changes to these interfaces should propagate down to the rest of the system. When we talk about "unidirectional" types, we are referring to these two interfaces driving downstream functions and utilities. 82 | 83 | ```typescript 84 | // 4. Define any Epic Dependencies 85 | export interface EpicDependencies { 86 | // Represents API call 87 | counterAddSvc: { 88 | add: (tryAdd: number) => Observable<{ newCount: number } | { error: string }>; 89 | }; 90 | } 91 | ``` 92 | 93 | Our Redux application has little knowledge of the outside world. Its focus is around actions and how those actions affect the state atom. We use [Epics](#epics) as a primitive for side effects, but the logic of those side effects should live behind "Services", which generically encapsulate any API that returns an observable. This is helpful for testing and separation of concerns. 94 | 95 | ```typescript 96 | // 5. Pass our interfaces to createTypesafeRedux 97 | import { createTypesafeRedux } from 'types-first-ui'; 98 | 99 | export const { path, selector, action, createApp } = createTypesafeRedux< 100 | State, 101 | AppActions, 102 | EpicDependencies 103 | >(); 104 | ``` 105 | 106 | Now we can use these interfaces to drive our typesafe "utility" functions. These are the functions you will use to build your Redux application. Any changes to the interfaces described above will be immediately reflected in these function signatures. 107 | 108 | ```typescript 109 | // 6a. Create paths using the 'path' utility function from createTypesafeRedux 110 | export const Paths = { 111 | counter: path(['counter'], 0), 112 | error: path(['error'], ''), 113 | pendingRequest: path(['pendingRequest']), 114 | }; 115 | 116 | // 6b. Create selectors using 'selector' util function from createTypesafeRedux 117 | export const doubleCounter = selector(Paths.counter, counter => { 118 | return counter * 2; 119 | }); 120 | ``` 121 | 122 | We now use the utility functions from `createTypesafeRedux` to create our [Paths](#path) and [Selectors](#selector). Generically, these represent observables of derived values from your state tree. Specifically, Paths are a directly referenceable property on your state tree; Selectors are derived values that are computed as a function of input Paths and Selectors. 123 | 124 | Additionally, Paths include utility get, set, & unset functions that will be used in your reducers. 125 | 126 | ```typescript 127 | import { flow } from 'types-first-ui'; 128 | 129 | // 7. Implement Actions: use 'action' util function from createTypesafeRedux 130 | const addRequest = action(ActionTypes.ADD_REQUEST, { 131 | reducer: (state, action) => { 132 | // action inferred as {type: ActionTypes.ADD_REQUEST, payload: {tryAdd: number} } 133 | return Paths.pendingRequest.set(true)(state); 134 | }, 135 | epic: (action$, { counterAddSvc }) => { 136 | return action$.pipe( 137 | mergeMap(action => { 138 | // action inferred as {type: ActionTypes.ADD_REQUEST, payload: {tryAdd: number} } 139 | return counterAddSvc.add(action.payload.tryAdd).pipe( 140 | map(newCount => { 141 | // API call was successful 142 | return addSuccess.creator({ newCount }); 143 | }), 144 | catchError(error => { 145 | // API call failed 146 | return addFail.creator({ error }); 147 | }) 148 | ); 149 | }) 150 | ); 151 | }, 152 | }); 153 | 154 | const addSuccess = action(ActionTypes.ADD_SUCCESS, { 155 | reducer: (state, action) => { 156 | const togglePending = Paths.pendingRequest.set(false); 157 | const updateCounter = Paths.counter.set(action.payload.newCount); 158 | return flow( 159 | togglePending, 160 | updateCounter 161 | )(state); 162 | }, 163 | }); 164 | 165 | const addFail = action(ActionTypes.ADD_FAIL, { 166 | reducer: (state, action) => { 167 | const togglePending = Paths.pendingRequest.set(false); 168 | const updateError = Paths.error.set(action.payload.error); 169 | return flow( 170 | togglePending, 171 | updateError 172 | )(state); 173 | }, 174 | }); 175 | 176 | export const ActionsMap = { 177 | [ActionTypes.ADD_REQUEST]: addRequest, 178 | [ActionTypes.ADD_SUCCESS]: addSuccess, 179 | [ActionTypes.ADD_FAIL]: addFail, 180 | }; 181 | ``` 182 | 183 | This is the primary focus of the developer when creating Redux applications. Given a set of actions and a state tree, we now need to implement the concrete instances of these actions. 184 | This may include reducers, epics, both, or neither. The return type of `action` is an [ActionImplementation](#action-implementation). The exported ActionsMap represents an exhaustive collection of implementations for each action type that we defined in our initial interfaces. It will be passed into our `createApp` function below. 185 | 186 | ```typescript 187 | // 8. Create the app instance 188 | const app = createApp({ 189 | actions: ActionsMap, 190 | initialState, 191 | }); 192 | 193 | // 9. Initialize the app instance by creating & binding to a new redux store 194 | app.createStore({ 195 | epicDependencies: { 196 | // concrete instance of interface 197 | counterAddSvc: CounterAddSvc, 198 | }, 199 | // enables redux dev tools 200 | dev: true, 201 | }); 202 | 203 | export default app; 204 | ``` 205 | 206 | With our concrete instances, we can now create our app instance. This is used to bridge React and Redux--the app exposes a `connect` function that mirrors the Redux variation, except with support for our path and selector primitives. We use an explicit import of the app rather than a `Provider` component using React's context API. 207 | 208 | ```typescript 209 | import { ActionCreator } from 'types-first-ui'; 210 | // helper utility to extract ActionCreator type given the discriminant 211 | // useful to minimize boilerplate in ActionProps 212 | type Creator = ActionCreator>; 213 | 214 | // 10. Define a React component with Types-First framework 215 | interface DataProps { 216 | counter: number; 217 | doubleCounter: number; 218 | } 219 | 220 | interface ActionProps { 221 | addRequest: Creator; 222 | } 223 | 224 | type Props = DataProps & ActionProps; 225 | 226 | export class CounterComponent extends React.PureComponent { 227 | add = () => { 228 | this.props.addRequest({ tryAddBy: 1 }); 229 | }; 230 | 231 | render() { 232 | return ( 233 |
234 |
{this.props.counter}
235 |
{this.props.doubleCounter}
236 | 237 |
238 | ); 239 | } 240 | } 241 | 242 | // 11. Connect your React component to the TFUI app 243 | const observableProps = { 244 | counter: Paths.COUNTER, 245 | doubleCounter: Selectors.doubleCounter, 246 | }; 247 | 248 | const dispatchProps = { 249 | addRequest: app.actionCreator(ActionTypes.COUNTER_ADD_REQUEST), 250 | }; 251 | 252 | export default app.connect( 253 | observableProps, 254 | dispatchProps 255 | )(CounterComponent); 256 | ``` 257 | 258 | ## **Concepts** 259 | 260 | ### Selector 261 | 262 | A selector is an observable representing some directly derivable value from your state atom. Selectors can be recursively combined to create other selectors. These are conceptually similar to [ngrx](https://github.com/ngrx/platform/blob/master/docs/store/selectors.md) selectors, as well as [reselect](https://github.com/reduxjs/reselect). 263 | 264 | Selectors are closed over an observable of the state tree, and include a number of performance optimizations to guarantee that they will be shared, they will not leak subscriptions, they will emit at most once per change to the state tree, and they will only evaluate their projector function when their input values change. Selectors may optionally provide a comparator function to determine when new values should be emitted, for further performance optimizations. This is useful for selectors that return values which are not referentially equal (i.e. mapping over an array). 265 | 266 | Selectors are created using the `selector` utility function. 267 | 268 | ```typescript 269 | interface Todo { 270 | text: string; 271 | completed: boolean; 272 | } 273 | 274 | interface State { 275 | app: { 276 | todos: { 277 | [todoId: string]: Todo; 278 | }; 279 | }; 280 | } 281 | 282 | export const Paths = { 283 | TODOS: path(['app', 'todos']), 284 | }; 285 | 286 | // Example selector usage--usually more than one input selector 287 | export const completedTodos = selector(Paths.TODOS, todos => { 288 | return _.pickBy(todos, todo => { 289 | return todo.completed; 290 | }); 291 | }); 292 | // completedTodos is type Observable<[todoId: string]: Todo> 293 | 294 | // selectors may also be parameterized 295 | // Here is an example that returns a selector curried over a parameter 296 | export const todoById = (id: string) => selector(Paths.TODOS, todos => todos[id]); 297 | ``` 298 | 299 | ### Path 300 | 301 | ```typescript 302 | export interface PathAPI { 303 | get: (state: TState) => TVal; 304 | set: (nextVal: TVal) => (state: TState) => TState; 305 | unset: (state: TState) => TState; 306 | } 307 | export declare type Path = Selector & 308 | PathAPI; 309 | ``` 310 | 311 | A path is a selector with special properties and constraints. Paths represent observables of some subtree of your state atom. A path is constructed by providing the literal path to a subtree of your state interface. It is an observable that will emit whenever the focused piece of the state tree has changed (i.e. it is no longer referentially equal to its previous value). Paths are the primitive from which we compute other derived values, through selectors. For every subtree of your state tree, there should be a corresponding Paths object. 312 | 313 | Because paths are bound directly to a piece of the state tree, they also include typesafe, non-mutating get, set, and unset functions which are used in our reducers. 314 | 315 | Paths are created using the `path` utility function. 316 | 317 | ```typescript 318 | // Example use of path 319 | interface State { 320 | app: { 321 | counter: number; 322 | username: string; 323 | counterById: { 324 | [counterId: string]: number; 325 | }; 326 | }; 327 | } 328 | 329 | export const Paths = { 330 | COUNTER: path(['app', 'counter'], 0), // optional default argument, 331 | // Paths.COUNTER is type Observable & { get: (State) => number, set: (number) => StateTransform, unset: StateTransform } 332 | USERNAME: path(['app', 'username'], ''), 333 | // Paths.USERNAME is type Observable & { get: (State) => string, set: (string) => StateTransform , unset: StateTransform} 334 | BAD: path(['app', 'badPath']), 335 | // ERROR: argument of 'badPath' is not assignable to 'counter' | 'username' 336 | }; 337 | ``` 338 | 339 | ### Action Implementation 340 | 341 | ```typescript 342 | export interface ActionImplementation< 343 | TAction extends TAllActions, 344 | TState extends object, 345 | TAllActions extends Action, 346 | TEpicDependencies extends object 347 | > { 348 | constant: TAction['type']; 349 | creator: ActionCreator; 350 | reducer?: IReducer; 351 | epic?: SingleActionEpic; 352 | } 353 | ``` 354 | 355 | This interface represents the "implementation" of a single action described in the system. It is the return type of the `action` function provided the framework. This provides strict type safety around your Redux primitives, as well as providing a useful grouping of tightly related code. The community has referred to this pattern as the [ducks](https://github.com/erikras/ducks-modular-redux) pattern. Action implementations are where we combine our typesafe utilities into meaningful business logic that will drive the functionality of our application. 356 | 357 | Actions are implemented using the `action` utility function. When implementing an action you may provide a reducer or an epic for that action; you must exhaustively implement every action defined in your action types before you will be able to create an app instance. 358 | 359 | ```typescript 360 | // the return value of the action utility is the full action implementation including 361 | // a typesafe creator function and reference to the type constant 362 | const addRequest = action(ActionTypes.ADD_REQUEST, { 363 | // reducers receive the full, flat state tree 364 | // type of action object is correctly inferred 365 | reducer: (state, action) => { 366 | // We use our path to return an update state tree 367 | return Paths.pendingRequest.set(true)(state); 368 | }, 369 | // action epics are scoped to the specific action being implemented 370 | // notice there is no need to use ofType() here, because it is already 371 | // a stream of the ActionTypes.ADD_REQUEST action 372 | epic: (action$, { counterAddSvc }) => { 373 | return action$.pipe( 374 | // type of action object is correctly inferred 375 | mergeMap(action => { 376 | return counterAddSvc.add(action.payload.tryAdd).pipe( 377 | map(newCount => { 378 | // API call was successful 379 | return addSuccess.creator({ newCount }); 380 | }), 381 | catchError(error => { 382 | // API call failed 383 | return addFail.creator({ error }); 384 | }) 385 | ); 386 | }) 387 | ); 388 | }, 389 | }); 390 | ``` 391 | 392 | ### Epics 393 | 394 | Types-First UI is built on top of redux-observable. We use epics as the primitive for managing asynchrony and side-effects within our system. 395 | 396 | In general, epics meet the contract of actions in, actions out. For a full exploration of the concept, you should refer to the [redux observable guide](https://redux-observable.js.org/docs/basics/Epics.html). However, we also provide a set of more constrained, specific epic definitions: single-action epics, middleware. 397 | 398 | ```typescript 399 | export declare type Epic< 400 | TWatchedAction extends Action, 401 | TReturnedAction extends Action, 402 | TEpicDependencies extends object 403 | > = ( 404 | action$: Observable, 405 | deps: TEpicDependencies 406 | ) => Observable; 407 | ``` 408 | 409 | #### Single-Action Epic 410 | 411 | Single-action epics are provided as part of an action implementation. They are scoped to the specific action being implemented, and only have access to the stream of all actions emitted from the system as the third parameter. As a user, this means you do not have to use the `ofType()` operator within your action epics to narrow the action stream. This also guarantees that action implementations do not bleed concerns. Although most single action epics won't care about the allActions stream, it is useful for use cases such as cancellation. 412 | 413 | ```typescript 414 | export declare type SingleActionEpic< 415 | TAllActions extends Action, 416 | TAction extends TAllActions, 417 | TEpicDependencies extends object 418 | > = Epic; 419 | ``` 420 | 421 | ```typescript 422 | const addRequest = action(ActionTypes.ADD_REQUEST, { 423 | // action epics are scoped to the specific action being implemented 424 | // notice there is no need to use ofType() here, because it is already 425 | // a stream of the ActionTypes.ADD_REQUEST action 426 | epic: action$ => { 427 | return action$.pipe( 428 | // type of action object is correctly inferred 429 | map(action => { 430 | //... 431 | }) 432 | ); 433 | }, 434 | }); 435 | ``` 436 | 437 | #### Middleware 438 | 439 | Not to be confused with [Redux middleware](https://redux.js.org/advanced/middleware), this represents a particular type of "Epic"--one that never returns new actions. This is a good place to put "side effects" in your application (logging, tracing, setting context/storage, persistence, etc). 440 | 441 | ```typescript 442 | export declare type MiddlewareEpic< 443 | TAllActions extends Action, 444 | TEpicDependencies extends object 445 | > = Epic; 446 | ``` 447 | 448 | For example, we could have: 449 | 450 | ```typescript 451 | import { empty } from 'rxjs'; 452 | import { tap, mergeMapTo } from 'rxjs/operators'; 453 | 454 | const logEverything: Middleware = actions$ => { 455 | return actions$.pipe( 456 | tap(console.log), 457 | mergeMapTo(empty()) 458 | ); 459 | }; 460 | ``` 461 | 462 | ### App 463 | 464 | App is the atomic unit of functionality in Types-First UI. Apps are recursively composable, which is cool...but scary and complicated. So I'm going to wait to document this feature. 465 | 466 | ## Foundational Technologies 467 | 468 | - [TypeScript](https://github.com/Microsoft/TypeScript) 469 | - [Redux](https://github.com/reactjs/redux) 470 | - [RxJS](https://github.com/ReactiveX/rxjs) 471 | - [Redux-Observable](https://redux-observable.js.org/) 472 | -------------------------------------------------------------------------------- /example/simple-counter/containers/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import app from '../redux/connector'; 3 | import { ActionTypes, AppActions } from '../redux/interfaces/actions'; 4 | import { Paths } from '../redux/paths'; 5 | import { Creator } from '../redux/interfaces/app'; 6 | 7 | interface DataProps { 8 | counter: number; 9 | } 10 | 11 | interface ActionProps { 12 | add: Creator; 13 | subtract: Creator; 14 | } 15 | 16 | type Props = DataProps & ActionProps; 17 | 18 | export class App extends React.PureComponent { 19 | add = () => { 20 | this.props.add({ tryAddBy: 1 }); 21 | }; 22 | 23 | subtract = () => { 24 | this.props.subtract({ subtractBy: 1 }); 25 | }; 26 | 27 | render() { 28 | return ( 29 |
30 |
{this.props.counter}
31 | 32 | 33 |
34 | ); 35 | } 36 | } 37 | 38 | const observableProps = { 39 | counter: Paths.COUNTER, 40 | }; 41 | 42 | const dispatchProps = { 43 | add: app.actionCreator(ActionTypes.COUNTER_ADD_REQUEST), 44 | subtract: app.actionCreator(ActionTypes.COUNTER_SUBTRACT), 45 | }; 46 | 47 | export default app.connect( 48 | observableProps, 49 | dispatchProps 50 | )(App); 51 | -------------------------------------------------------------------------------- /example/simple-counter/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import App from './containers/App'; 4 | 5 | const root = document.createElement('div'); 6 | 7 | ReactDOM.render(, root); 8 | 9 | document.body.appendChild(root); 10 | -------------------------------------------------------------------------------- /example/simple-counter/redux/actions/counter.ts: -------------------------------------------------------------------------------- 1 | import { delay, mapTo, tap } from 'rxjs/operators'; 2 | import { ActionTypes } from '../interfaces/actions'; 3 | import { action } from '../interfaces/app'; 4 | import { Paths } from '../paths'; 5 | 6 | const addCounterSuccess = action(ActionTypes.COUNTER_ADD_SUCCESS, { 7 | reducer: (state, action) => { 8 | const currentCount = Paths.COUNTER.get(state); 9 | return Paths.COUNTER.set(currentCount + action.payload.addBy)(state); 10 | }, 11 | }); 12 | 13 | const addCounterRequest = action(ActionTypes.COUNTER_ADD_REQUEST, { 14 | epic: action$ => { 15 | return action$.pipe( 16 | tap(action => { 17 | console.log(action); 18 | }), 19 | delay(1000), 20 | mapTo(addCounterSuccess.creator({ addBy: 1 })) 21 | ); 22 | }, 23 | }); 24 | 25 | const subtractCounter = action(ActionTypes.COUNTER_SUBTRACT, { 26 | reducer: (state, action) => { 27 | return { counter: state.counter - action.payload.subtractBy }; 28 | }, 29 | }); 30 | 31 | export const implementations = { 32 | [ActionTypes.COUNTER_ADD_REQUEST]: addCounterRequest, 33 | [ActionTypes.COUNTER_ADD_SUCCESS]: addCounterSuccess, 34 | [ActionTypes.COUNTER_SUBTRACT]: subtractCounter, 35 | }; 36 | -------------------------------------------------------------------------------- /example/simple-counter/redux/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { implementations as counterImplementations } from './counter'; 2 | 3 | const Actions = { 4 | ...counterImplementations, 5 | }; 6 | 7 | export default Actions; 8 | -------------------------------------------------------------------------------- /example/simple-counter/redux/connector.ts: -------------------------------------------------------------------------------- 1 | import actions from './actions'; 2 | import { createApp } from './interfaces/app'; 3 | import { initialState } from './interfaces/state'; 4 | 5 | const app = createApp({ 6 | actions, 7 | initialState, 8 | }); 9 | 10 | app.createStore({ epicDependencies: {}, dev: true }); 11 | 12 | export default app; 13 | -------------------------------------------------------------------------------- /example/simple-counter/redux/interfaces/actions.ts: -------------------------------------------------------------------------------- 1 | export enum ActionTypes { 2 | COUNTER_ADD_REQUEST = 'COUNTER_ADD_REQUEST', 3 | COUNTER_ADD_SUCCESS = 'COUNTER_ADD_SUCCESS', 4 | COUNTER_SUBTRACT = 'COUNTER_SUBTRACT', 5 | } 6 | 7 | export interface ActionInterfaces { 8 | [ActionTypes.COUNTER_ADD_REQUEST]: { 9 | type: ActionTypes.COUNTER_ADD_REQUEST; 10 | payload: { tryAddBy: number }; 11 | }; 12 | [ActionTypes.COUNTER_ADD_SUCCESS]: { 13 | type: ActionTypes.COUNTER_ADD_SUCCESS; 14 | payload: { addBy: number }; 15 | }; 16 | [ActionTypes.COUNTER_SUBTRACT]: { 17 | type: ActionTypes.COUNTER_SUBTRACT; 18 | payload: { subtractBy: number }; 19 | }; 20 | } 21 | 22 | export type AppActions = ActionInterfaces[keyof ActionInterfaces]; 23 | -------------------------------------------------------------------------------- /example/simple-counter/redux/interfaces/app.ts: -------------------------------------------------------------------------------- 1 | import { createTypesafeRedux, ActionCreator } from '../../../../src'; 2 | import { AppActions, ActionTypes } from './actions'; 3 | import { State } from './state'; 4 | 5 | const { path, selector, createApp, action } = createTypesafeRedux(); 6 | 7 | export { createApp, path, selector, action }; 8 | 9 | export type Creator = ActionCreator< 10 | Extract 11 | >; 12 | -------------------------------------------------------------------------------- /example/simple-counter/redux/interfaces/state.ts: -------------------------------------------------------------------------------- 1 | export interface State { 2 | counter: number; 3 | } 4 | 5 | export const initialState: State = { 6 | counter: 0, 7 | }; 8 | -------------------------------------------------------------------------------- /example/simple-counter/redux/paths.ts: -------------------------------------------------------------------------------- 1 | import { path } from './interfaces/app'; 2 | 3 | export const Paths = { 4 | COUNTER: path(['counter']), 5 | }; 6 | -------------------------------------------------------------------------------- /example/simple-counter/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: path.resolve(__dirname, './index.tsx'), 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.tsx?$/, 10 | use: 'ts-loader', 11 | exclude: /node_modules/, 12 | }, 13 | ], 14 | }, 15 | resolve: { 16 | extensions: ['.tsx', '.ts', '.js'], 17 | }, 18 | output: { 19 | filename: 'bundle.js', 20 | path: path.resolve(__dirname, 'dist'), 21 | }, 22 | plugins: [new HtmlWebpackPlugin()], 23 | devServer: { 24 | contentBase: path.join(__dirname, 'dist'), 25 | compress: false, 26 | port: 9000, 27 | historyApiFallback: true, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "types-first-ui", 3 | "version": "2.3.1", 4 | "description": "An opinionated framework for building long-lived, maintainable UI codebases", 5 | "main": "./dist/bundle.js", 6 | "types": "./dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "contributors": [ 11 | { 12 | "name": "Kevin Saldaña", 13 | "email": "ksaldana@averoinc.com", 14 | "url": "https://github.com/ksaldana1" 15 | }, 16 | { 17 | "name": "Erin Noe-Payne", 18 | "email": "enoepayne@averoinc.com", 19 | "url": "https://github.com/autoric" 20 | } 21 | ], 22 | "scripts": { 23 | "test": "jest", 24 | "tsc": "tsc --declaration -outDir compiled", 25 | "clean": "rm -rf dist && rm -rf compiled", 26 | "copyDeclarations": "cd compiled && find . -name '*.d.ts' | cpio -pdm ../dist", 27 | "prepare": "npm run clean && npm run tsc && rollup -c && npm run copyDeclarations", 28 | "example:simple-counter": "webpack-dev-server --mode development --config example/simple-counter/webpack.config.js" 29 | }, 30 | "peerDependencies": { 31 | "react": "^16.0.0", 32 | "rxjs": "^6.0.0" 33 | }, 34 | "dependencies": { 35 | "lodash": "^4.0.0", 36 | "redux-devtools-extension": "^2.0.0", 37 | "redux": "^4.0.0", 38 | "redux-observable": "^1.0.0" 39 | }, 40 | "devDependencies": { 41 | "@types/enzyme": "^3.1.10", 42 | "@types/jest": "^22.2.3", 43 | "@types/lodash": "^4.14.106", 44 | "@types/react": "^16.3.13", 45 | "@types/reduce-reducers": "^0.1.3", 46 | "enzyme": "^3.3.0", 47 | "enzyme-adapter-react-16": "^1.1.1", 48 | "html-webpack-plugin": "^3.2.0", 49 | "jest": "^22.4.4", 50 | "lodash": "^4.17.5", 51 | "react": "^16.3.2", 52 | "react-dom": "^16.4.0", 53 | "redux": "^4.0.0", 54 | "rollup": "^0.62.0", 55 | "rollup-plugin-uglify": "^4.0.0", 56 | "rxjs": "^6.3.3", 57 | "ts-jest": "^22.4.6", 58 | "ts-loader": "^4.2.0", 59 | "typescript": "^3.1.1", 60 | "webpack": "^4.6.0", 61 | "webpack-cli": "^2.1.2", 62 | "webpack-dev-server": "^3.1.4" 63 | }, 64 | "license": "Apache-2.0", 65 | "jest": { 66 | "transform": { 67 | ".(ts|tsx)": "/node_modules/ts-jest/preprocessor.js" 68 | }, 69 | "verbose": true, 70 | "transformIgnorePatterns": [ 71 | "node_modules/(?!(jest-)?react-native|react-navigation)" 72 | ], 73 | "testRegex": "(src|test)/.*\\.test\\.(ts|tsx|js)$", 74 | "moduleFileExtensions": [ 75 | "ts", 76 | "tsx", 77 | "js" 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { uglify } from 'rollup-plugin-uglify'; 2 | 3 | export default { 4 | input: 'compiled/index.js', 5 | output: { 6 | file: 'dist/bundle.js', 7 | format: 'cjs', 8 | }, 9 | plugins: [uglify()], 10 | external: [ 11 | 'rxjs', 12 | 'rxjs/ajax', 13 | 'rxjs/operators', 14 | 'types-first-ui', 15 | 'react', 16 | 'lodash', 17 | 'redux-observable', 18 | 'redux', 19 | 'redux-devtools-extension', 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /src/connector.test.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import * as Enzyme from 'enzyme'; 18 | import Adapter from 'enzyme-adapter-react-16'; 19 | import * as React from 'react'; 20 | import { Observable } from 'rxjs'; 21 | import { map } from 'rxjs/operators'; 22 | import createTypesafeRedux from './typesafeRedux'; 23 | import { ActionCreator } from './implementAction'; 24 | 25 | Enzyme.configure({ adapter: new Adapter() }); 26 | const { shallow, render, mount } = Enzyme; 27 | 28 | interface State { 29 | counter: number; 30 | } 31 | 32 | enum ActionTypes { 33 | INCREMENT = 'INCREMENT', 34 | DECREMENT = 'DECREMENT', 35 | } 36 | 37 | interface ActionsMap { 38 | [ActionTypes.INCREMENT]: { 39 | type: ActionTypes.INCREMENT; 40 | payload: { amount: number }; 41 | }; 42 | [ActionTypes.DECREMENT]: { 43 | type: ActionTypes.DECREMENT; 44 | payload: { amount: number }; 45 | }; 46 | } 47 | 48 | type Actions = ActionsMap[keyof ActionsMap]; 49 | 50 | interface OwnProps { 51 | amount?: number; 52 | } 53 | 54 | interface ObservableProps { 55 | counter: number; 56 | counterPlusAmount: number; 57 | } 58 | 59 | type Creator = ActionCreator>; 60 | 61 | interface ActionCreatorProps { 62 | increment: Creator; 63 | decrement: Creator; 64 | } 65 | 66 | type Props = OwnProps & ObservableProps & ActionCreatorProps; 67 | 68 | function setup() { 69 | const { action, createApp, selector, path } = createTypesafeRedux< 70 | State, 71 | Actions, 72 | {}, 73 | {} 74 | >(); 75 | 76 | const PATHS = { 77 | COUNTER: path(['counter'], 0), 78 | }; 79 | 80 | const increment = action(ActionTypes.INCREMENT, { 81 | reducer: (state, action) => { 82 | const currentVal = PATHS.COUNTER.get(state); 83 | return PATHS.COUNTER.set(currentVal + action.payload.amount)(state); 84 | }, 85 | }); 86 | const decrement = action(ActionTypes.DECREMENT, { 87 | reducer: (state, action) => { 88 | const currentVal = PATHS.COUNTER.get(state); 89 | return PATHS.COUNTER.set(currentVal - action.payload.amount)(state); 90 | }, 91 | }); 92 | 93 | const actionImplementations = { 94 | [ActionTypes.INCREMENT]: increment, 95 | [ActionTypes.DECREMENT]: decrement, 96 | }; 97 | 98 | const app = createApp({ 99 | actions: actionImplementations, 100 | initialState: { counter: 0 }, 101 | }); 102 | 103 | app.createStore({ 104 | epicDependencies: {}, 105 | dev: false, 106 | }); 107 | 108 | const renderSpy = jest.fn(); 109 | 110 | class TestComponent extends React.Component { 111 | increment = () => { 112 | this.props.increment({ amount: 1 }); 113 | }; 114 | 115 | decrement = () => { 116 | this.props.decrement({ amount: 1 }); 117 | }; 118 | 119 | render() { 120 | renderSpy(this.props); 121 | return ( 122 |
123 |
{this.props.counter}
124 | 125 | 126 |
127 | ); 128 | } 129 | } 130 | 131 | return { 132 | TestComponent, 133 | renderSpy, 134 | app, 135 | actionImplementations, 136 | PATHS, 137 | }; 138 | } 139 | 140 | describe('connector', () => { 141 | let { TestComponent, renderSpy, app, actionImplementations, PATHS } = setup(); 142 | 143 | beforeEach(() => { 144 | const t = setup(); 145 | TestComponent = t.TestComponent; 146 | renderSpy = t.renderSpy; 147 | app = t.app; 148 | actionImplementations = t.actionImplementations; 149 | PATHS = t.PATHS; 150 | }); 151 | 152 | describe('with factory functions', () => { 153 | let ConnectedComponent; 154 | let subscribeSpy; 155 | let unsubscribeSpy; 156 | 157 | beforeEach(() => { 158 | subscribeSpy = jest.fn(); 159 | unsubscribeSpy = jest.fn(); 160 | 161 | const observableProp = new Observable(subscriber => { 162 | subscribeSpy(); 163 | 164 | const sub = PATHS.COUNTER.subscribe(subscriber); 165 | 166 | return () => { 167 | sub.unsubscribe(); 168 | unsubscribeSpy(); 169 | }; 170 | }); 171 | 172 | const observablePropsFactory = ownProps => ({ 173 | counter: observableProp, 174 | counterPlusAmount: PATHS.COUNTER.pipe(map(val => val + (ownProps.amount || 0))), 175 | }); 176 | 177 | const actionProps = { 178 | increment: app.actionCreator(ActionTypes.INCREMENT), 179 | decrement: app.actionCreator(ActionTypes.DECREMENT), 180 | }; 181 | ConnectedComponent = app.connect( 182 | observablePropsFactory, 183 | actionProps 184 | )(TestComponent); 185 | }); 186 | 187 | describe('initialization', () => { 188 | it('issues a subscription to the oberservable props', () => { 189 | mount(); 190 | expect(subscribeSpy).toHaveBeenCalledTimes(1); 191 | }); 192 | 193 | it('renders the component with the initial value from state', () => { 194 | mount(); 195 | 196 | expect(renderSpy).toHaveBeenCalledTimes(1); 197 | const props = renderSpy.mock.calls[0][0]; 198 | expect(props.counter).toEqual(0); 199 | }); 200 | 201 | it('renders the component with latest emitted value', () => { 202 | app.dispatch(app.actionCreator(ActionTypes.INCREMENT)({ amount: 10 })); 203 | mount(); 204 | 205 | expect(renderSpy).toHaveBeenCalledTimes(1); 206 | const props = renderSpy.mock.calls[0][0]; 207 | expect(props.counter).toEqual(10); 208 | }); 209 | }); 210 | 211 | describe('observable props', () => { 212 | it('re-renders the component when the observable emits', () => { 213 | mount(); 214 | 215 | expect(renderSpy).toHaveBeenCalledTimes(1); 216 | let props = renderSpy.mock.calls[0][0]; 217 | expect(props.counter).toEqual(0); 218 | 219 | app.dispatch(app.actionCreator(ActionTypes.INCREMENT)({ amount: 12 })); 220 | 221 | expect(renderSpy).toHaveBeenCalledTimes(2); 222 | props = renderSpy.mock.calls[1][0]; 223 | expect(props.counter).toEqual(12); 224 | }); 225 | 226 | it('re-issues subscriptions when ownProps change', () => { 227 | const component = mount(); 228 | 229 | expect(renderSpy).toHaveBeenCalledTimes(1); 230 | let props = renderSpy.mock.calls[0][0]; 231 | expect(props.counter).toEqual(0); 232 | expect(props.amount).toEqual(undefined); 233 | 234 | component.setProps({ amount: 7 }); 235 | 236 | expect(renderSpy).toHaveBeenCalledTimes(2); 237 | props = renderSpy.mock.calls[1][0]; 238 | expect(props.counter).toEqual(0); 239 | expect(props.amount).toEqual(7); 240 | 241 | expect(unsubscribeSpy).toHaveBeenCalledTimes(1); 242 | expect(subscribeSpy).toHaveBeenCalledTimes(2); 243 | }); 244 | 245 | it('should correctly inject own props into observable props factory', () => { 246 | app.dispatch(app.actionCreator(ActionTypes.INCREMENT)({ amount: 1 })); 247 | mount(); 248 | 249 | expect(renderSpy).toHaveBeenCalledTimes(1); 250 | let props = renderSpy.mock.calls[0][0]; 251 | expect(props.counterPlusAmount).toEqual(8); 252 | }); 253 | 254 | it('maintains existing subscriptions when observable rops emit', () => { 255 | mount(); 256 | 257 | app.dispatch(app.actionCreator(ActionTypes.INCREMENT)({ amount: 12 })); 258 | expect(renderSpy).toHaveBeenCalledTimes(2); 259 | 260 | expect(unsubscribeSpy).toHaveBeenCalledTimes(0); 261 | expect(subscribeSpy).toHaveBeenCalledTimes(1); 262 | }); 263 | }); 264 | 265 | describe('action creators', () => { 266 | it('should dispatch actions from action creators', () => { 267 | mount(); 268 | 269 | expect(renderSpy).toHaveBeenCalledTimes(1); 270 | let props = renderSpy.mock.calls[0][0]; 271 | props.increment({ amount: 1 }); 272 | 273 | expect(renderSpy).toHaveBeenCalledTimes(2); 274 | props = renderSpy.mock.calls[1][0]; 275 | expect(props.counter).toEqual(1); 276 | }); 277 | }); 278 | 279 | describe('teardown', () => { 280 | it('unsubscribes a subscription to the oberservable props', () => { 281 | const component = mount(); 282 | expect(subscribeSpy).toHaveBeenCalledTimes(1); 283 | 284 | component.unmount(); 285 | expect(unsubscribeSpy).toHaveBeenCalledTimes(1); 286 | }); 287 | }); 288 | }); 289 | 290 | describe('without factory functions', () => { 291 | let ConnectedComponent; 292 | let subscribeSpy; 293 | let unsubscribeSpy; 294 | 295 | beforeEach(() => { 296 | subscribeSpy = jest.fn(); 297 | unsubscribeSpy = jest.fn(); 298 | 299 | const observableProp = new Observable(subscriber => { 300 | subscribeSpy(); 301 | 302 | const sub = PATHS.COUNTER.subscribe(subscriber); 303 | 304 | return () => { 305 | sub.unsubscribe(); 306 | unsubscribeSpy(); 307 | }; 308 | }); 309 | 310 | const observablePropsFactory = { 311 | counter: observableProp, 312 | }; 313 | 314 | const actionPropsFactory = { 315 | increment: app.actionCreator(ActionTypes.INCREMENT), 316 | }; 317 | 318 | ConnectedComponent = app.connect( 319 | observablePropsFactory, 320 | actionPropsFactory 321 | )(TestComponent); 322 | }); 323 | 324 | describe('initialization', () => { 325 | it('issues a subscription to the oberservable props', () => { 326 | mount(); 327 | expect(subscribeSpy).toHaveBeenCalledTimes(1); 328 | }); 329 | 330 | it('renders the component with the initial value from state', () => { 331 | mount(); 332 | 333 | expect(renderSpy).toHaveBeenCalledTimes(1); 334 | const props = renderSpy.mock.calls[0][0]; 335 | expect(props.counter).toEqual(0); 336 | }); 337 | 338 | it('renders the component with latest emitted value', () => { 339 | app.dispatch(app.actionCreator(ActionTypes.INCREMENT)({ amount: 10 })); 340 | mount(); 341 | 342 | expect(renderSpy).toHaveBeenCalledTimes(1); 343 | const props = renderSpy.mock.calls[0][0]; 344 | expect(props.counter).toEqual(10); 345 | }); 346 | }); 347 | 348 | describe('observable props', () => { 349 | it('re-renders the component when the observable emits', () => { 350 | mount(); 351 | 352 | expect(renderSpy).toHaveBeenCalledTimes(1); 353 | let props = renderSpy.mock.calls[0][0]; 354 | expect(props.counter).toEqual(0); 355 | 356 | app.dispatch(app.actionCreator(ActionTypes.INCREMENT)({ amount: 12 })); 357 | 358 | expect(renderSpy).toHaveBeenCalledTimes(2); 359 | props = renderSpy.mock.calls[1][0]; 360 | expect(props.counter).toEqual(12); 361 | }); 362 | 363 | it('does not re-issue subscriptions when ownProps change', () => { 364 | const component = mount(); 365 | 366 | expect(renderSpy).toHaveBeenCalledTimes(1); 367 | let props = renderSpy.mock.calls[0][0]; 368 | expect(props.counter).toEqual(0); 369 | expect(props.amount).toEqual(undefined); 370 | 371 | component.setProps({ amount: 7 }); 372 | 373 | expect(renderSpy).toHaveBeenCalledTimes(2); 374 | props = renderSpy.mock.calls[1][0]; 375 | expect(props.counter).toEqual(0); 376 | expect(props.amount).toEqual(7); 377 | 378 | expect(unsubscribeSpy).toHaveBeenCalledTimes(0); 379 | expect(subscribeSpy).toHaveBeenCalledTimes(1); 380 | }); 381 | 382 | it('maintains existing subscriptions when observable props emit', () => { 383 | mount(); 384 | 385 | app.dispatch(app.actionCreator(ActionTypes.INCREMENT)({ amount: 12 })); 386 | expect(renderSpy).toHaveBeenCalledTimes(2); 387 | 388 | expect(unsubscribeSpy).toHaveBeenCalledTimes(0); 389 | expect(subscribeSpy).toHaveBeenCalledTimes(1); 390 | }); 391 | 392 | it('re-renders when own props emit', () => { 393 | const component = mount(); 394 | 395 | expect(renderSpy).toHaveBeenCalledTimes(1); 396 | let props = renderSpy.mock.calls[0][0]; 397 | expect(props.amount).toEqual(2); 398 | 399 | component.setProps({ amount: 7 }); 400 | 401 | expect(renderSpy).toHaveBeenCalledTimes(2); 402 | props = renderSpy.mock.calls[1][0]; 403 | expect(props.amount).toEqual(7); 404 | }); 405 | }); 406 | 407 | describe('action creators', () => { 408 | it('should dispatch actions from action creators', () => { 409 | mount(); 410 | 411 | expect(renderSpy).toHaveBeenCalledTimes(1); 412 | let props = renderSpy.mock.calls[0][0]; 413 | props.increment({ amount: 1 }); 414 | 415 | expect(renderSpy).toHaveBeenCalledTimes(2); 416 | props = renderSpy.mock.calls[1][0]; 417 | expect(props.counter).toEqual(1); 418 | }); 419 | }); 420 | 421 | describe('teardown', () => { 422 | it('unsubscribes a subscription to the oberservable props', () => { 423 | const component = mount(); 424 | expect(subscribeSpy).toHaveBeenCalledTimes(1); 425 | 426 | component.unmount(); 427 | expect(unsubscribeSpy).toHaveBeenCalledTimes(1); 428 | }); 429 | }); 430 | }); 431 | 432 | describe('sfc', () => { 433 | it('should work correctly with an SFC', () => { 434 | const component = jest.fn(props => { 435 | return ( 436 |
437 |
{props.counter}
438 |
439 | ); 440 | }); 441 | 442 | const observablePropsFactory = ownProps => ({ 443 | counter: PATHS.COUNTER, 444 | counterPlusAmount: PATHS.COUNTER.pipe(map(val => val + (ownProps.amount || 0))), 445 | }); 446 | 447 | const actionProps = { 448 | increment: app.actionCreator(ActionTypes.INCREMENT), 449 | decrement: app.actionCreator(ActionTypes.DECREMENT), 450 | }; 451 | const ConnectedComponent = app.connect< 452 | ObservableProps, 453 | ActionCreatorProps, 454 | OwnProps 455 | >( 456 | observablePropsFactory, 457 | actionProps 458 | )(component); 459 | 460 | mount(); 461 | expect(component).toHaveBeenCalledTimes(1); 462 | const props = component.mock.calls[0][0]; 463 | expect(props).toMatchObject({ 464 | counter: 0, 465 | counterPlusAmount: 10, 466 | }); 467 | }); 468 | }); 469 | }); 470 | -------------------------------------------------------------------------------- /src/connector.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { isFunction, mapValues } from 'lodash'; 18 | import * as React from 'react'; 19 | import { Observable, Subscription, combineLatest, of } from 'rxjs'; 20 | import { map, sample, catchError } from 'rxjs/operators'; 21 | import { Action, Arg0, Dispatch, SanitizeNull } from './types'; 22 | import { comparators } from './utils/comparators'; 23 | 24 | export type ObservableMap = { 25 | [K in keyof TObservableMap]: Observable 26 | }; 27 | export type ObservableProps = { [P in keyof T]: Observable }; 28 | export type ObservablePropsFactory = ( 29 | ownProps?: TOwnProps 30 | ) => ObservableProps; 31 | 32 | export type ActionCreatorsMap = Record< 33 | keyof TActionProps, 34 | (...args: any[]) => TAllActions 35 | >; 36 | 37 | export class Connector { 38 | private _state$: Observable; 39 | private _dispatch: Dispatch; 40 | 41 | constructor(state$: Observable, dispatch: Dispatch) { 42 | this._state$ = state$; 43 | this._dispatch = dispatch; 44 | } 45 | 46 | connect< 47 | TObservableProps extends object, 48 | TActionProps extends ActionCreatorsMap = null, 49 | TOwnProps = null 50 | >( 51 | observablePropsFactory: 52 | | ObservableProps 53 | | ObservablePropsFactory, 54 | actionCreatorProps: TActionProps 55 | ) { 56 | const { _state$, _dispatch } = this; 57 | 58 | return ( 59 | component: React.ComponentType< 60 | SanitizeNull & 61 | SanitizeNull & 62 | SanitizeNull 63 | > 64 | ): React.ComponentType> => { 65 | type ComponentState = TObservableProps & TActionProps; 66 | 67 | return class ConnectedComponent extends React.Component< 68 | SanitizeNull, 69 | ComponentState 70 | > { 71 | // dispatchProps and observablePropValues are passed to render the wrapped component 72 | // private dispatchProps: TActionProps; 73 | // private observablePropValues: TObservableProps; 74 | // state variables to track subscriptions & rendering 75 | private observablePropSubscription: Subscription; 76 | 77 | // flag to determine if state should be set through assignment or setState 78 | private _isConstructor = true; 79 | 80 | constructor(props) { 81 | super(props); 82 | 83 | this.state = {} as ComponentState; 84 | 85 | // Run ownprops through observable props factory 86 | const observableProps = this.createObservableProps(props); 87 | // Issue subscription to observable props 88 | this.subscribeObservableProps(observableProps); 89 | // Run ownprops through action creator factory 90 | const actionCreators = 91 | actionCreatorProps || ({} as ActionCreatorsMap); 92 | // Bind action creators to dispatch 93 | this.bindDispatch(actionCreators); 94 | 95 | this._isConstructor = false; 96 | } 97 | 98 | shouldComponentUpdate(nextProps) { 99 | // If own props are equal, this is a change coming from state - we should re-render 100 | if (comparators.shallowEqual(this.props, nextProps)) { 101 | return true; 102 | } 103 | // TODO: refactor to not suck 104 | const obsIsFactory = isFunction(observablePropsFactory); 105 | if (obsIsFactory) { 106 | this.subscribeObservableProps(this.createObservableProps(nextProps)); 107 | return false; 108 | } 109 | 110 | return true; 111 | } 112 | 113 | componentWillUnmount() { 114 | this.clearSubscription(); 115 | } 116 | 117 | private clearSubscription() { 118 | if (this.observablePropSubscription != null) { 119 | this.observablePropSubscription.unsubscribe(); 120 | } 121 | } 122 | 123 | private createObservableProps = (ownProps: TOwnProps) => { 124 | if (observablePropsFactory == null) { 125 | return {} as ObservableProps; 126 | } 127 | if (isFunction(observablePropsFactory)) { 128 | return observablePropsFactory(ownProps); 129 | } 130 | return observablePropsFactory; 131 | }; 132 | 133 | private subscribeObservableProps = ( 134 | observableProps: ObservableProps 135 | ) => { 136 | this.clearSubscription(); 137 | 138 | // TODO: the keys thing is weird, I can get rid of it 139 | const keys = Object.keys(observableProps) as Array; 140 | this.observablePropSubscription = combineLatest( 141 | ...keys.map(key => { 142 | const obs$ = observableProps[key]; 143 | return obs$.pipe( 144 | catchError(err => { 145 | console.error( 146 | `Error evaluating observable property '${key}' of component '${ 147 | component.name 148 | }':\n\n`, 149 | err 150 | ); 151 | return of(undefined); 152 | }), 153 | map(value => ({ [key]: value })) 154 | ); 155 | }) 156 | ) 157 | .pipe( 158 | sample(_state$), 159 | map(changes => Object.assign({}, ...changes)) 160 | ) 161 | .subscribe(nextState => { 162 | if (this._isConstructor) { 163 | Object.assign(this.state, nextState); 164 | } else { 165 | this.setState(nextState); 166 | } 167 | }); 168 | }; 169 | 170 | private bindDispatch = (actionCreators: TActionProps) => { 171 | const boundCreators = mapValues< 172 | (...args: any[]) => TActions, 173 | (...args) => void 174 | >(actionCreators, actionCreator => { 175 | return (...args) => _dispatch(actionCreator(...args)); 176 | }) as TActionProps; 177 | 178 | if (this._isConstructor) { 179 | Object.assign(this.state, boundCreators); 180 | } else { 181 | this.setState(boundCreators); 182 | } 183 | }; 184 | 185 | render() { 186 | const childElementProps = Object.assign({}, this.props, this.state); 187 | // @ts-ignore TODO: can I make the typings happy? 188 | return React.createElement(component, childElementProps); 189 | } 190 | }; 191 | }; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/createApp.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { each, map, mapValues } from 'lodash'; 18 | import { applyMiddleware, createStore, Store } from 'redux'; 19 | import { composeWithDevTools } from 'redux-devtools-extension'; 20 | import { 21 | ActionsObservable, 22 | combineEpics, 23 | createEpicMiddleware, 24 | Epic as ReduxEpic, 25 | } from 'redux-observable'; 26 | import { BehaviorSubject, Observable } from 'rxjs'; 27 | import { pluck } from 'rxjs/operators'; 28 | import { Connector } from './connector'; 29 | import { ActionCreator, ActionImplementation } from './implementAction'; 30 | import { Action, Dispatch, Epic, IReducer, MiddlewareEpic, SanitizeNull } from './types'; 31 | import { get } from './utils/get'; 32 | import { set } from './utils/set'; 33 | 34 | // TODO: check for collisions between state tree and features map? 35 | export type FeaturesMap = { 36 | [K in keyof TFeaturesMap]: App 37 | }; 38 | 39 | // first we need to gather all feature properties from the map (FeaturesMap[keyof FeaturesMap]) 40 | // then, given a union type of features, we need to extract the state generic type from it 41 | export type FeatureState = T extends App ? R : never; 42 | export type FeatureActions = T extends App 43 | ? R 44 | : never; 45 | export type FeatureEpicDependencies = T extends App 46 | ? R 47 | : never; 48 | 49 | export type FeaturesMapState> = { 50 | [K in keyof T]: FeatureState 51 | }; 52 | export type FeaturesMapActions> = T extends null 53 | ? never 54 | : { [K in keyof T]: FeatureActions }[keyof T]; 55 | export type FeaturesMapEpicDependencies> = { 56 | [K in keyof T]: FeatureEpicDependencies 57 | }; 58 | 59 | export type CombinedState< 60 | TState extends object, 61 | TFeaturesMap extends FeaturesMap 62 | > = TState & SanitizeNull>; 63 | 64 | export type CombinedActions< 65 | TActions extends Action, 66 | TFeaturesMap extends FeaturesMap 67 | > = TActions | FeaturesMapActions; 68 | 69 | export type CombinedEpicDependencies< 70 | TEpicDependencies extends object, 71 | TFeaturesMap extends FeaturesMap 72 | > = TEpicDependencies & SanitizeNull>; 73 | 74 | export type ReducerMap = { 75 | [K in keyof Partial]: IReducer< 76 | TAllState, 77 | Extract 78 | > 79 | }; 80 | 81 | // An exhaustive map of implementations for all defined action types 82 | export type ActionImplementationMap< 83 | TState extends object, 84 | TOwnActions extends TAllActions, 85 | TAllActions extends Action, 86 | EpicDependencies extends object 87 | > = { 88 | [K in TOwnActions['type']]: ActionImplementation< 89 | Extract, 90 | TState, 91 | TAllActions, 92 | EpicDependencies 93 | > 94 | }; 95 | 96 | export interface AppCreator< 97 | TOwnState extends object, 98 | TAllState extends object, 99 | TOwnActions extends TAllActions, 100 | TAllActions extends Action, 101 | TFeaturesMap extends FeaturesMap = null, 102 | TAllEpicDeps extends object = null 103 | > { 104 | createApp: ( 105 | params: CreateAppParams 106 | ) => App; 107 | } 108 | 109 | export interface CreateAppParams< 110 | TOwnState extends object, 111 | TAllState extends object, 112 | TOwnActions extends TAllActions, 113 | TAllActions extends Action, 114 | TAllEpicDeps extends object 115 | > { 116 | initialState: TOwnState; 117 | actions: ActionImplementationMap; 118 | extraEpics?: Array>; 119 | middleware?: Array>; 120 | } 121 | 122 | export class App< 123 | TOwnState extends object, 124 | TAllState extends object, 125 | TOwnActions extends TAllActions, 126 | TAllActions extends Action, 127 | TFeatures extends FeaturesMap, 128 | TAllEpicDeps extends object 129 | > { 130 | private _features: TFeatures; 131 | private _initialState: TOwnState; 132 | private _implementation: ActionImplementationMap< 133 | TAllState, 134 | TOwnActions, 135 | TAllActions, 136 | TAllEpicDeps 137 | >; 138 | private _extraEpics: Array>; 139 | 140 | private _state$: BehaviorSubject; 141 | 142 | private _dispatch: Dispatch; 143 | private _store: Store; 144 | 145 | // TODO: type connector, initialize in constructor 146 | private _connector: Connector; 147 | 148 | constructor( 149 | state$: BehaviorSubject, 150 | features: TFeatures, 151 | createAppParams: CreateAppParams< 152 | TOwnState, 153 | TAllState, 154 | TOwnActions, 155 | TAllActions, 156 | TAllEpicDeps 157 | > 158 | ) { 159 | this._state$ = state$; 160 | this._features = features; 161 | this._initialState = createAppParams.initialState; 162 | this._implementation = createAppParams.actions; 163 | this._extraEpics = [ 164 | ...(createAppParams.extraEpics || []), 165 | ...(createAppParams.middleware || []), 166 | ]; 167 | 168 | this._connector = new Connector(this._state$, this.dispatch); 169 | } 170 | 171 | private getReducers = (): ReducerMap => { 172 | let reducers = {} as ReducerMap; 173 | each(this._implementation, (actionImpl, type) => { 174 | if (actionImpl.reducer == null) { 175 | return; 176 | } 177 | return (reducers[type] = actionImpl.reducer); 178 | }); 179 | 180 | each(this._features, (feature, subTreeKey) => { 181 | const featureReducers = mapValues(feature.getReducers(), reducer => { 182 | return (state, action) => { 183 | const currentState = get([subTreeKey])(state); 184 | const nextState = reducer(currentState, action); 185 | return nextState === currentState ? state : set([subTreeKey])(nextState)(state); 186 | }; 187 | }); 188 | reducers = Object.assign({}, reducers, featureReducers); 189 | }); 190 | 191 | return reducers; 192 | }; 193 | 194 | private getEpics = (): ReduxEpic< 195 | TAllActions, 196 | TAllActions, 197 | TAllState, 198 | TAllEpicDeps 199 | >[] => { 200 | let epics: ReduxEpic< 201 | TAllActions, 202 | TAllActions, 203 | TAllState, 204 | TAllEpicDeps 205 | >[] = this._extraEpics.map(epic => (allActions$, state$, deps) => 206 | epic(allActions$, deps) 207 | ); 208 | 209 | each(this._implementation, (actionImpl, type) => { 210 | if (actionImpl.epic == null) { 211 | return; 212 | } 213 | type actionType = typeof actionImpl.constant; 214 | epics = epics.concat((allActions$, state$, deps) => { 215 | return actionImpl.epic( 216 | allActions$.ofType(actionImpl.constant) as ActionsObservable, 217 | deps, 218 | allActions$ 219 | ); 220 | }); 221 | }); 222 | 223 | each(this._features, (feature, subtreeKey) => { 224 | const featureEpics = map(feature.getEpics(), epic => { 225 | return (action$, state$: Observable, epicDependencies) => { 226 | const stateSubTree$ = state$.pipe(pluck(subtreeKey)); 227 | return epic(action$, stateSubTree$ as any, epicDependencies[subtreeKey]); 228 | }; 229 | }); 230 | 231 | epics = [...epics, ...featureEpics]; 232 | }); 233 | 234 | return epics; 235 | }; 236 | 237 | private getInitialState = (): TAllState => { 238 | const featureInitialState = mapValues(this._features, feature => { 239 | return feature.getInitialState(); 240 | }); 241 | 242 | const allState = Object.assign({}, this._initialState, featureInitialState) as object; 243 | return allState as TAllState; 244 | }; 245 | 246 | public wireUpState = (state$: Observable) => { 247 | each(this._features, (app, subtreeKey) => { 248 | app.wireUpState(state$.pipe(pluck(subtreeKey))); 249 | }); 250 | state$.subscribe(this._state$); 251 | }; 252 | 253 | private wireUpDispatch = (dispatch: Dispatch) => { 254 | each(this._features, (app, subtreeKey) => { 255 | app.wireUpDispatch(dispatch); 256 | }); 257 | 258 | this._dispatch = dispatch; 259 | }; 260 | 261 | public actionCreator = ( 262 | type: K 263 | ): ActionCreator> => payload => 264 | ({ type, payload } as Extract); 265 | 266 | // this function makes it "real" 267 | public createStore = (params: { 268 | epicDependencies: TAllEpicDeps; 269 | dev: boolean; 270 | }): void => { 271 | const reducerMap = this.getReducers(); 272 | const rootReducer: IReducer = ( 273 | state = this.getInitialState(), 274 | action: Action 275 | ) => { 276 | return reducerMap[action.type] ? reducerMap[action.type](state, action) : state; 277 | }; 278 | // Create singular root epic that encompases Epics & Middleware (epics that never emit) 279 | const epics = this.getEpics(); 280 | const rootEpic = combineEpics(...epics); 281 | const epicMiddleware = createEpicMiddleware< 282 | TAllActions, 283 | TAllActions, 284 | TAllState, 285 | TAllEpicDeps 286 | >({ 287 | dependencies: params.epicDependencies 288 | ? params.epicDependencies 289 | : ({} as TAllEpicDeps), 290 | }); 291 | 292 | const reduxMiddleware = params.dev 293 | ? composeWithDevTools(applyMiddleware(epicMiddleware)) 294 | : applyMiddleware(epicMiddleware); 295 | 296 | const store = createStore( 297 | rootReducer, 298 | reduxMiddleware 299 | ); 300 | 301 | epicMiddleware.run(rootEpic); 302 | let currentState = store.getState(); 303 | this._state$.next(currentState); 304 | store.subscribe(() => { 305 | const nextState = store.getState(); 306 | if (currentState === nextState) { 307 | return; 308 | } 309 | this._state$.next(nextState); 310 | currentState = nextState; 311 | }); 312 | 313 | this.wireUpDispatch(store.dispatch); 314 | }; 315 | 316 | public connect: Connector['connect'] = (...args: any[]) => { 317 | return this._connector.connect.apply(this._connector, args); 318 | }; 319 | 320 | public dispatch: Dispatch = action => { 321 | if (this._dispatch == null) { 322 | throw new Error( 323 | 'Dispatch is undefined. Initiallize the app instance by calling `app.createStore()`' 324 | ); 325 | } 326 | return this._dispatch(action); 327 | }; 328 | } 329 | -------------------------------------------------------------------------------- /src/implementAction.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { Action, IReducer, SingleActionEpic } from './types'; 18 | 19 | export type ActionCreator = ( 20 | payload: TAction['payload'] 21 | ) => TAction; 22 | 23 | export interface ActionImplementation< 24 | TAction extends TAllActions, 25 | TState extends object, 26 | TAllActions extends Action, 27 | TEpicDependencies extends object 28 | > { 29 | constant: TAction['type']; 30 | creator: ActionCreator; 31 | reducer?: IReducer; 32 | epic?: SingleActionEpic; 33 | } 34 | 35 | export interface ActionImplementer< 36 | TState extends object, 37 | TOwnActions extends TAllActions, 38 | TAllActions extends Action, 39 | TEpicDependencies extends object = {} 40 | > { 41 | action( 42 | constant: K, 43 | implementation?: { 44 | reducer?: IReducer>; 45 | epic?: SingleActionEpic< 46 | TAllActions, 47 | Extract, 48 | TEpicDependencies 49 | >; 50 | } 51 | ): ActionImplementation< 52 | Extract, 53 | TState, 54 | TAllActions, 55 | TEpicDependencies 56 | >; 57 | } 58 | 59 | export default function createActionImplementer< 60 | TState extends object, 61 | TOwnActions extends TAllActions, 62 | TAllActions extends Action, 63 | TEpicDependencies extends object = {} 64 | >(): ActionImplementer { 65 | return { 66 | action: (constant, implementation = {}) => { 67 | return { 68 | constant, 69 | creator: payload => 70 | ({ type: constant, payload } as Extract< 71 | TOwnActions, 72 | { type: typeof constant } 73 | >), 74 | reducer: implementation.reducer, 75 | epic: implementation.epic, 76 | }; 77 | }, 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import createTypesafeRedux from './typesafeRedux'; 18 | import ofType from './utils/ofType'; 19 | import { flow } from './utils/flow'; 20 | import { comparators } from './utils/comparators'; 21 | 22 | export * from './types'; 23 | export * from './typesafeRedux'; 24 | export * from './selectors'; 25 | export * from './paths'; 26 | export * from './implementAction'; 27 | export * from './createApp'; 28 | export * from './connector'; 29 | 30 | export { createTypesafeRedux, ofType, flow, comparators }; 31 | -------------------------------------------------------------------------------- /src/paths.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { BehaviorSubject, Observable } from 'rxjs'; 18 | import { take, tap, toArray } from 'rxjs/operators'; 19 | import createPathFactory from './paths'; 20 | import { Selector } from './selectors'; 21 | 22 | interface State { 23 | counter: number; 24 | a: { 25 | b: { 26 | c: { 27 | number: number; 28 | string: string; 29 | }; 30 | }; 31 | }; 32 | collection: number[]; 33 | } 34 | 35 | function setup() { 36 | const state = { 37 | counter: 0, 38 | a: { b: { c: { number: 1, string: 'a' } } }, 39 | collection: [], 40 | }; 41 | const evalSpy = jest.fn(); 42 | 43 | const state$ = new BehaviorSubject(state); 44 | const { path } = createPathFactory(state$.pipe(tap(evalSpy))); 45 | const COUNTER = path(['counter']); 46 | const NESTED = path(['a', 'b', 'c', 'string']); 47 | const COLLECTION_BY_INDEX = (i: number) => path(['collection', i]); 48 | 49 | return { state, evalSpy, state$, path, COUNTER, NESTED }; 50 | } 51 | 52 | describe('paths', () => { 53 | let { state, evalSpy, state$, path, COUNTER, NESTED } = setup(); 54 | 55 | beforeEach(() => { 56 | const s = setup(); 57 | state = s.state; 58 | evalSpy = s.evalSpy; 59 | state$ = s.state$; 60 | path = s.path; 61 | COUNTER = s.COUNTER; 62 | NESTED = s.NESTED; 63 | }); 64 | 65 | describe('#get', () => { 66 | it('should return the value at the path for shallow props', () => { 67 | expect(COUNTER.get(state)).toEqual(0); 68 | }); 69 | 70 | it('should return the value at the path for deep props', () => { 71 | expect(NESTED.get(state)).toEqual('a'); 72 | }); 73 | 74 | it('should return a default value if the value at the path is null or undefined', () => { 75 | COUNTER = path(['counter'], 7); 76 | state.counter = null; 77 | expect(COUNTER.get(state)).toEqual(7); 78 | state.counter = undefined; 79 | expect(COUNTER.get(state)).toEqual(7); 80 | }); 81 | 82 | it('should not return a default value if the value at the path is falsy', () => { 83 | COUNTER = path(['counter'], 7); 84 | state.counter = 0; 85 | expect(COUNTER.get(state)).toEqual(0); 86 | }); 87 | 88 | it('should return a null default value', () => { 89 | COUNTER = path(['counter'], null); 90 | state.counter = undefined; 91 | expect(COUNTER.get(state)).toEqual(null); 92 | }); 93 | }); 94 | 95 | describe('#set', () => { 96 | it('should set the targetted value', () => { 97 | const nextState = COUNTER.set(2)(state); 98 | expect(nextState.counter).toEqual(2); 99 | }); 100 | 101 | it('should not mutate the original state', () => { 102 | const nextState = COUNTER.set(2)(state); 103 | expect(state.counter).not.toEqual(2); 104 | }); 105 | 106 | it('should return a new object reference', () => { 107 | const nextState = COUNTER.set(2)(state); 108 | expect(nextState).not.toBe(state); 109 | }); 110 | 111 | it('should maintain referential equality for parts of the subtree not affected', () => { 112 | const nextState = COUNTER.set(2)(state); 113 | expect(nextState.a).toBe(state.a); 114 | }); 115 | 116 | it('should cascade reference changes', () => { 117 | const nextState = NESTED.set('hi')(state); 118 | expect(nextState.a).not.toBe(state.a); 119 | expect(nextState.a.b).not.toBe(state.a.b); 120 | expect(nextState.a.b.c).not.toBe(state.a.b.c); 121 | expect(nextState.a.b.c.string).not.toBe(state.a.b.c.string); 122 | expect(nextState.a.b.c.string).toBe('hi'); 123 | expect(nextState.a.b.c.number).toBe(state.a.b.c.number); 124 | }); 125 | }); 126 | 127 | describe('#unset', () => { 128 | it('should remove the targetted value', () => { 129 | const nextState = COUNTER.unset(state); 130 | expect(nextState.counter).toBeUndefined(); 131 | }); 132 | 133 | it('should not mutate the original state', () => { 134 | const nextState = COUNTER.unset(state); 135 | expect(state.counter).toEqual(0); 136 | }); 137 | 138 | it('should return a new object reference', () => { 139 | const nextState = COUNTER.unset(state); 140 | expect(nextState).not.toBe(state); 141 | }); 142 | 143 | it('should maintain referential equality for parts of the subtree not affected', () => { 144 | const nextState = COUNTER.unset(state); 145 | expect(nextState.a).toBe(state.a); 146 | }); 147 | 148 | it('should cascade reference changes', () => { 149 | const nextState = NESTED.unset(state); 150 | 151 | expect(nextState.a).not.toBe(state.a); 152 | expect(nextState.a.b).not.toBe(state.a.b); 153 | expect(nextState.a.b.c).not.toBe(state.a.b.c); 154 | expect(nextState.a.b.c.string).not.toBe(state.a.b.c.string); 155 | expect(nextState.a.b.c.string).toBeUndefined(); 156 | expect(nextState.a.b.c.number).toBe(state.a.b.c.number); 157 | }); 158 | 159 | describe('observable', () => { 160 | it('should be an observable', () => { 161 | expect(COUNTER).toBeInstanceOf(Observable); 162 | }); 163 | 164 | describe('changes', () => { 165 | it('should emit the initial value of the path', () => { 166 | return expect(COUNTER.pipe(take(1)).toPromise()).resolves.toEqual(0); 167 | }); 168 | 169 | it('should emit the most recently assigned value of the path on subscription', () => { 170 | let nextState = state; 171 | nextState = COUNTER.set(1)(nextState); 172 | state$.next(nextState); 173 | nextState = COUNTER.set(2)(nextState); 174 | state$.next(nextState); 175 | return expect(COUNTER.pipe(take(1)).toPromise()).resolves.toEqual(2); 176 | }); 177 | 178 | it('should emit the value of the path each time it changes', () => { 179 | const emittedValues = COUNTER.pipe( 180 | take(3), 181 | toArray() 182 | ).toPromise(); 183 | let nextState = state; 184 | nextState = COUNTER.set(1)(nextState); 185 | state$.next(nextState); 186 | nextState = COUNTER.set(2)(nextState); 187 | state$.next(nextState); 188 | return expect(emittedValues).resolves.toEqual([0, 1, 2]); 189 | }); 190 | 191 | it('should not emit if another part of the state tree changes', () => { 192 | const emittedValues = COUNTER.pipe( 193 | take(3), 194 | toArray() 195 | ).toPromise(); 196 | let nextState = state; 197 | nextState = COUNTER.set(1)(nextState); 198 | state$.next(nextState); 199 | nextState = NESTED.set('heyo')(nextState); 200 | state$.next(nextState); 201 | nextState = COUNTER.set(2)(nextState); 202 | state$.next(nextState); 203 | return expect(emittedValues).resolves.toEqual([0, 1, 2]); 204 | }); 205 | }); 206 | 207 | describe('default values', () => { 208 | beforeEach(() => { 209 | COUNTER = path(['counter'], 10); 210 | }); 211 | 212 | it('should emit the default value if the pat is null', () => { 213 | let nextState = state; 214 | nextState = COUNTER.set(null)(nextState); 215 | state$.next(nextState); 216 | 217 | return expect(COUNTER.pipe(take(1)).toPromise()).resolves.toEqual(10); 218 | }); 219 | 220 | it('should emit the default value if the pat is undefined', () => { 221 | let nextState = state; 222 | nextState = COUNTER.set(undefined)(nextState); 223 | state$.next(nextState); 224 | 225 | return expect(COUNTER.pipe(take(1)).toPromise()).resolves.toEqual(10); 226 | }); 227 | 228 | it('should not emit if the assigned value changes but the result value is the same', () => { 229 | const emittedValues = COUNTER.pipe( 230 | take(3), 231 | toArray() 232 | ).toPromise(); 233 | let nextState = state; 234 | // Each of the next 3 sets should result in a return value of 10, so just 1 emit: 235 | nextState = COUNTER.set(null)(nextState); 236 | state$.next(nextState); 237 | nextState = NESTED.set(undefined)(nextState); 238 | state$.next(nextState); 239 | nextState = COUNTER.set(10)(nextState); 240 | state$.next(nextState); 241 | 242 | nextState = COUNTER.set(2)(nextState); 243 | state$.next(nextState); 244 | return expect(emittedValues).resolves.toEqual([0, 10, 2]); 245 | }); 246 | }); 247 | 248 | describe('replay and ref counting', () => { 249 | let source$: Observable; 250 | let COUNTER: Selector; 251 | 252 | beforeEach(() => { 253 | // TODO: I removed withSurce 254 | COUNTER = path(['counter']); 255 | }); 256 | 257 | it('should execute the source on initial subscription', () => { 258 | COUNTER.subscribe(); 259 | expect(evalSpy).toHaveBeenCalledTimes(1); 260 | }); 261 | 262 | it('should not execute the source on subsequent subscription', () => { 263 | COUNTER.subscribe(); 264 | COUNTER.subscribe(); 265 | COUNTER.subscribe(); 266 | expect(evalSpy).toHaveBeenCalledTimes(1); 267 | }); 268 | 269 | it('should unsubscribe when subscribers go to zero', () => { 270 | let sub = COUNTER.subscribe(); 271 | sub.unsubscribe(); 272 | sub = COUNTER.subscribe(); 273 | sub.unsubscribe(); 274 | sub = COUNTER.subscribe(); 275 | sub.unsubscribe(); 276 | expect(evalSpy).toHaveBeenCalledTimes(3); 277 | }); 278 | }); 279 | }); 280 | }); 281 | }); 282 | -------------------------------------------------------------------------------- /src/paths.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { Observable } from 'rxjs'; 18 | import { distinctUntilChanged, map, publishReplay, refCount } from 'rxjs/operators'; 19 | import { Selector } from './selectors'; 20 | import { StateTransform } from './types'; 21 | import { get } from './utils/get'; 22 | import { set } from './utils/set'; 23 | import { unset } from './utils/unset'; 24 | 25 | // A path is a selector that additionally has utility functions to to immutably get & reassign values 26 | // on the state tree 27 | export interface PathAPI { 28 | get: (state: TState) => TVal; 29 | set: (nextVal: TVal) => StateTransform; 30 | unset: StateTransform; 31 | } 32 | export type Path = Selector & PathAPI; 33 | 34 | export interface PathCreator { 35 | path(ks: [K], defaultVal?: TState[K]): Path; 36 | path( 37 | ks: [K, K1], 38 | defaultVal?: TState[K][K1] 39 | ): Path; 40 | path< 41 | K extends keyof TState, 42 | K1 extends keyof TState[K], 43 | K2 extends keyof TState[K][K1] 44 | >( 45 | ks: [K, K1, K2], 46 | defaultVal?: TState[K][K1][K2] 47 | ): Path; 48 | path< 49 | K extends keyof TState, 50 | K1 extends keyof TState[K], 51 | K2 extends keyof TState[K][K1], 52 | K3 extends keyof TState[K][K1][K2] 53 | >( 54 | ks: [K, K1, K2, K3], 55 | defaultVal?: TState[K][K1][K2][K3] 56 | ): Path; 57 | path< 58 | K extends keyof TState, 59 | K1 extends keyof TState[K], 60 | K2 extends keyof TState[K][K1], 61 | K3 extends keyof TState[K][K1][K2], 62 | K4 extends keyof TState[K][K1][K2][K3] 63 | >( 64 | ks: [K, K1, K2, K3, K4], 65 | defaultVal?: TState[K][K1][K2][K3][K4] 66 | ): Path; 67 | path< 68 | K extends keyof TState, 69 | K1 extends keyof TState[K], 70 | K2 extends keyof TState[K][K1], 71 | K3 extends keyof TState[K][K1][K2], 72 | K4 extends keyof TState[K][K1][K2][K3], 73 | K5 extends keyof TState[K][K1][K2][K3][K4] 74 | >( 75 | ks: [K, K1, K2, K3, K4, K5], 76 | defaultVal?: TState[K][K1][K2][K3][K4][K5] 77 | ): Path; 78 | path< 79 | K extends keyof TState, 80 | K1 extends keyof TState[K], 81 | K2 extends keyof TState[K][K1], 82 | K3 extends keyof TState[K][K1][K2], 83 | K4 extends keyof TState[K][K1][K2][K3], 84 | K5 extends keyof TState[K][K1][K2][K3][K4], 85 | K6 extends keyof TState[K][K1][K2][K3][K4][K5] 86 | >( 87 | ks: [K, K1, K2, K3, K4, K5, K6], 88 | defaultVal?: TState[K][K1][K2][K3][K4][K5][K6] 89 | ): Path; 90 | path< 91 | K extends keyof TState, 92 | K1 extends keyof TState[K], 93 | K2 extends keyof TState[K][K1], 94 | K3 extends keyof TState[K][K1][K2], 95 | K4 extends keyof TState[K][K1][K2][K3], 96 | K5 extends keyof TState[K][K1][K2][K3][K4], 97 | K6 extends keyof TState[K][K1][K2][K3][K4][K5], 98 | K7 extends keyof TState[K][K1][K2][K3][K4][K5][K6] 99 | >( 100 | ks: [K, K1, K2, K3, K4, K5, K6, K7], 101 | defaultVal?: TState[K][K1][K2][K3][K4][K5][K6][K7] 102 | ): Path; 103 | path< 104 | K extends keyof TState, 105 | K1 extends keyof TState[K], 106 | K2 extends keyof TState[K][K1], 107 | K3 extends keyof TState[K][K1][K2], 108 | K4 extends keyof TState[K][K1][K2][K3], 109 | K5 extends keyof TState[K][K1][K2][K3][K4], 110 | K6 extends keyof TState[K][K1][K2][K3][K4][K5], 111 | K7 extends keyof TState[K][K1][K2][K3][K4][K5][K6], 112 | K8 extends keyof TState[K][K1][K2][K3][K4][K5][K6][K7] 113 | >( 114 | ks: [K, K1, K2, K3, K4, K5, K6, K7, K8], 115 | defaultVal?: TState[K][K1][K2][K3][K4][K5][K6][K7][K8] 116 | ): Path; 117 | path< 118 | K extends keyof TState, 119 | K1 extends keyof TState[K], 120 | K2 extends keyof TState[K][K1], 121 | K3 extends keyof TState[K][K1][K2], 122 | K4 extends keyof TState[K][K1][K2][K3], 123 | K5 extends keyof TState[K][K1][K2][K3][K4], 124 | K6 extends keyof TState[K][K1][K2][K3][K4][K5], 125 | K7 extends keyof TState[K][K1][K2][K3][K4][K5][K6], 126 | K8 extends keyof TState[K][K1][K2][K3][K4][K5][K6][K7], 127 | K9 extends keyof TState[K][K1][K2][K3][K4][K5][K6][K7][K8] 128 | >( 129 | ks: [K, K1, K2, K3, K4, K5, K6, K7, K8, K9], 130 | defaultVal?: TState[K][K1][K2][K3][K4][K5][K6][K7][K8][K9] 131 | ): Path; 132 | path< 133 | K extends keyof TState, 134 | K1 extends keyof TState[K], 135 | K2 extends keyof TState[K][K1], 136 | K3 extends keyof TState[K][K1][K2], 137 | K4 extends keyof TState[K][K1][K2][K3], 138 | K5 extends keyof TState[K][K1][K2][K3][K4], 139 | K6 extends keyof TState[K][K1][K2][K3][K4][K5], 140 | K7 extends keyof TState[K][K1][K2][K3][K4][K5][K6], 141 | K8 extends keyof TState[K][K1][K2][K3][K4][K5][K6][K7], 142 | K9 extends keyof TState[K][K1][K2][K3][K4][K5][K6][K7][K8], 143 | K10 extends keyof TState[K][K1][K2][K3][K4][K5][K6][K7][K8][K9] 144 | >( 145 | ks: [K, K1, K2, K3, K4, K5, K6, K7, K8, K9, K10], 146 | defaultVal?: TState[K][K1][K2][K3][K4][K5][K6][K7][K8][K9][K10] 147 | ): Path; 148 | } 149 | 150 | export default function createPathFactory( 151 | state$: Observable 152 | ): PathCreator { 153 | const path = (keys: any, defaultVal: any) => { 154 | const _get = get(keys, defaultVal); 155 | const _set = set(keys); 156 | const _unset = unset(keys); 157 | const obs$ = state$.pipe( 158 | map(_get), 159 | distinctUntilChanged(), 160 | publishReplay(1), 161 | refCount() 162 | ); 163 | 164 | return Object.assign(obs$, { 165 | get: _get, 166 | set: _set, 167 | unset: _unset, 168 | }); 169 | }; 170 | 171 | return { path } as PathCreator; 172 | } 173 | -------------------------------------------------------------------------------- /src/selectors.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { map } from 'lodash'; 18 | import { BehaviorSubject, Observable } from 'rxjs'; 19 | import { take, toArray } from 'rxjs/operators'; 20 | import createPathFactory from './paths'; 21 | import createSelectorFactory from './selectors'; 22 | import { flow } from './utils/flow'; 23 | 24 | interface State { 25 | counter: number; 26 | greeting: string; 27 | users: { 28 | [id: string]: { 29 | name: string; 30 | }; 31 | }; 32 | } 33 | 34 | function setup() { 35 | const state: State = { 36 | counter: 0, 37 | greeting: 'hello', 38 | users: { 39 | 1: { 40 | name: 'Harry', 41 | }, 42 | 2: { 43 | name: 'Ron', 44 | }, 45 | 3: { 46 | name: 'Hermione', 47 | }, 48 | }, 49 | }; 50 | const state$ = new BehaviorSubject(state); 51 | 52 | const { path } = createPathFactory(state$); 53 | const { selector } = createSelectorFactory(state$); 54 | 55 | const COUNTER = path(['counter']); 56 | const GREETING = path(['greeting']); 57 | const USERS = path(['users']); 58 | 59 | return { state, state$, selector, COUNTER, GREETING, USERS }; 60 | } 61 | 62 | describe('selectors', () => { 63 | let { state, state$, selector, COUNTER, GREETING, USERS } = setup(); 64 | let projectFn: jest.Mock; 65 | 66 | beforeEach(() => { 67 | const s = setup(); 68 | state = s.state; 69 | state$ = s.state$; 70 | selector = s.selector; 71 | COUNTER = s.COUNTER; 72 | GREETING = s.GREETING; 73 | USERS = s.USERS; 74 | 75 | projectFn = jest.fn(() => 1); 76 | }); 77 | 78 | describe('observable', () => { 79 | it('should be an observable', () => { 80 | const doubledCounter = selector(COUNTER, counter => counter * 2); 81 | expect(doubledCounter).toBeInstanceOf(Observable); 82 | }); 83 | 84 | describe('replay & ref count', () => { 85 | it('should not evaluate the project function until there is a subscriber', () => { 86 | const s = selector(COUNTER, GREETING, USERS, projectFn); 87 | expect(projectFn).not.toHaveBeenCalled(); 88 | }); 89 | 90 | it('should evaluate the project function with the input selectors once there is a subscriber', () => { 91 | const s = selector(COUNTER, GREETING, USERS, projectFn); 92 | s.subscribe(); 93 | expect(projectFn).toHaveBeenCalledTimes(1); 94 | const [counter, greeting, users] = projectFn.mock.calls[0]; 95 | expect(counter).toBe(state.counter); 96 | expect(greeting).toBe(state.greeting); 97 | expect(users).toBe(state.users); 98 | }); 99 | 100 | it('should not re-evaluate the projector on subsequent subscriptions', () => { 101 | const s = selector(COUNTER, GREETING, USERS, projectFn); 102 | s.subscribe(); 103 | expect(projectFn).toHaveBeenCalledTimes(1); 104 | s.subscribe(); 105 | s.subscribe(); 106 | expect(projectFn).toHaveBeenCalledTimes(1); 107 | }); 108 | 109 | it('should re-evaluate the projector each time subscriptions go to zero', () => { 110 | const s = selector(COUNTER, GREETING, USERS, projectFn); 111 | let sub = s.subscribe(); 112 | expect(projectFn).toHaveBeenCalledTimes(1); 113 | sub.unsubscribe(); 114 | sub = s.subscribe(); 115 | expect(projectFn).toHaveBeenCalledTimes(2); 116 | sub.unsubscribe(); 117 | sub = s.subscribe(); 118 | expect(projectFn).toHaveBeenCalledTimes(3); 119 | }); 120 | }); 121 | 122 | describe('sample', () => { 123 | it('should not evaluate the projector if the input observables do not emit', () => { 124 | const s = selector(COUNTER, projectFn); 125 | s.subscribe(); 126 | expect(projectFn).toHaveBeenCalledTimes(1); 127 | let nextState = state; 128 | // state emits a change to a different part of the state tree 129 | nextState = GREETING.set('heyoooo')(nextState); 130 | state$.next(nextState); 131 | // state emits a non-change to the watched part of the state tree 132 | nextState = COUNTER.set(state.counter)(nextState); 133 | state$.next(nextState); 134 | expect(projectFn).toHaveBeenCalledTimes(1); 135 | }); 136 | 137 | it('should evaluate the projector at most once each time the source emits, with all the latest input values', () => { 138 | const s = selector(COUNTER, GREETING, USERS, projectFn); 139 | s.subscribe(); 140 | expect(projectFn).toHaveBeenCalledTimes(1); 141 | let nextState = state; 142 | // state emits a change to a different part of the state tree 143 | nextState = flow( 144 | COUNTER.set(1), 145 | GREETING.set('heyo') 146 | )(nextState); 147 | state$.next(nextState); 148 | expect(projectFn).toHaveBeenCalledTimes(2); 149 | const [counter, greeting, users] = projectFn.mock.calls[1]; 150 | expect(counter).toBe(1); 151 | expect(greeting).toBe('heyo'); 152 | expect(users).toBe(state.users); 153 | }); 154 | 155 | it('should accept any arbitrary observable as an input', () => { 156 | const ticker$ = new BehaviorSubject(100); 157 | const s = selector(COUNTER, ticker$, projectFn); 158 | s.subscribe(); 159 | expect(projectFn).toHaveBeenCalledTimes(1); 160 | const [counter, ticker] = projectFn.mock.calls[0]; 161 | expect(counter).toBe(0); 162 | expect(ticker).toBe(100); 163 | }); 164 | 165 | it('should evaluate the projector only when the source emits', () => { 166 | const ticker$ = new BehaviorSubject(100); 167 | const s = selector(COUNTER, ticker$, projectFn); 168 | s.subscribe(); 169 | expect(projectFn).toHaveBeenCalledTimes(1); 170 | const [counter, ticker] = projectFn.mock.calls[0]; 171 | expect(counter).toBe(0); 172 | expect(ticker).toBe(100); 173 | 174 | ticker$.next(200); 175 | ticker$.next(300); 176 | ticker$.next(400); 177 | expect(projectFn).toHaveBeenCalledTimes(1); 178 | state$.next(state); 179 | expect(projectFn).toHaveBeenCalledTimes(2); 180 | }); 181 | }); 182 | 183 | describe('distinct until changed', () => { 184 | it('should only emit a new value each time the result of the projection function changes', () => { 185 | const s = selector( 186 | COUNTER, 187 | GREETING, 188 | (counter, greeting) => counter + greeting.length 189 | ); 190 | const emittedValues = s 191 | .pipe( 192 | take(3), 193 | toArray() 194 | ) 195 | .toPromise(); 196 | 197 | let nextState = state; 198 | nextState = COUNTER.set(1)(nextState); 199 | state$.next(nextState); 200 | nextState = COUNTER.set(1)(nextState); 201 | state$.next(nextState); 202 | nextState = GREETING.set('hella')(nextState); 203 | state$.next(nextState); 204 | nextState = GREETING.set('hellos')(nextState); 205 | state$.next(nextState); 206 | 207 | return expect(emittedValues).resolves.toEqual([5, 6, 7]); 208 | }); 209 | 210 | it('should use referential equality by default', () => { 211 | const tableTest = [ 212 | { 213 | input: selector(GREETING, greeting => greeting.length), 214 | output: [5, 2, 5], 215 | }, 216 | { 217 | input: selector(GREETING, greeting => greeting[0]), 218 | output: ['h', 'o', 'a'], 219 | }, 220 | { 221 | input: selector(GREETING, greeting => greeting.length > 4), 222 | output: [true, false, true], 223 | }, 224 | { 225 | input: selector(GREETING, () => { 226 | return [1, 2]; 227 | }), 228 | output: [[1, 2], [1, 2], [1, 2], [1, 2]], 229 | }, 230 | ]; 231 | 232 | const expectations = map(tableTest, ({ input, output }) => { 233 | return expect( 234 | (input as Observable) 235 | .pipe( 236 | take(output.length), 237 | toArray() 238 | ) 239 | .toPromise() 240 | ).resolves.toEqual(output); 241 | }); 242 | 243 | let nextState = state; 244 | nextState = GREETING.set('hi')(nextState); 245 | state$.next(nextState); 246 | 247 | nextState = GREETING.set('oh')(nextState); 248 | state$.next(nextState); 249 | 250 | nextState = GREETING.set('arrgh')(nextState); 251 | state$.next(nextState); 252 | 253 | return Promise.all(expectations); 254 | }); 255 | 256 | it('should accept an optional comparator to determine if next values are distinct', () => { 257 | const s = selector( 258 | GREETING, 259 | USERS, 260 | (greeting, users) => map(users, user => `${greeting}, ${user.name}`), 261 | { compare: (a, b) => a.length === b.length } 262 | ); 263 | const output = s 264 | .pipe( 265 | take(2), 266 | toArray() 267 | ) 268 | .toPromise(); 269 | let nextState = state; 270 | // This will not change the length, so will not emit 271 | nextState = GREETING.set('hi')(nextState); 272 | state$.next(nextState); 273 | // this will 274 | nextState = USERS.set( 275 | Object.assign({}, nextState.users, { 4: { name: 'Draco' } }) 276 | )(nextState); 277 | state$.next(nextState); 278 | 279 | return expect(output).resolves.toEqual([ 280 | ['hello, Harry', 'hello, Ron', 'hello, Hermione'], 281 | ['hi, Harry', 'hi, Ron', 'hi, Hermione', 'hi, Draco'], 282 | ]); 283 | }); 284 | }); 285 | }); 286 | }); 287 | -------------------------------------------------------------------------------- /src/selectors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { initial, isFunction, last } from 'lodash'; 18 | import { Observable, combineLatest } from 'rxjs'; 19 | import { 20 | distinctUntilChanged, 21 | map, 22 | publishReplay, 23 | refCount, 24 | sample, 25 | } from 'rxjs/operators'; 26 | 27 | function strictEquality(a, b) { 28 | return a === b; 29 | } 30 | 31 | export interface SelectorOptions { 32 | compare: (a: T, b: T) => boolean; 33 | } 34 | // A selector is an observable over the state stream, which can have its source overridden 35 | export type Selector = Observable; 36 | 37 | export interface SelectorCreator { 38 | selector( 39 | selector1: Observable, 40 | projectFn: (arg1: A) => Result, 41 | opts?: SelectorOptions 42 | ): Selector; 43 | selector( 44 | selector1: Observable, 45 | selector2: Observable, 46 | projectFn: (arg1: A, arg2: B) => Result, 47 | opts?: SelectorOptions 48 | ): Selector; 49 | selector( 50 | selector1: Observable, 51 | selector2: Observable, 52 | selector3: Observable, 53 | projectFn: (arg1: A, arg2: B, arg3: C) => Result, 54 | opts?: SelectorOptions 55 | ): Selector; 56 | selector( 57 | selector1: Observable, 58 | selector2: Observable, 59 | selector3: Observable, 60 | selector4: Observable, 61 | projectFn: (arg1: A, arg2: B, arg3: C, arg4: D) => Result, 62 | opts?: SelectorOptions 63 | ): Selector; 64 | selector( 65 | selector1: Observable, 66 | selector2: Observable, 67 | selector3: Observable, 68 | selector4: Observable, 69 | selector5: Observable, 70 | projectFn: (arg1: A, arg2: B, arg3: C, arg4: D, arg5: E) => Result, 71 | opts?: SelectorOptions 72 | ): Selector; 73 | selector( 74 | selector1: Observable, 75 | selector2: Observable, 76 | selector3: Observable, 77 | selector4: Observable, 78 | selector5: Observable, 79 | selector6: Observable, 80 | projectFn: (arg1: A, arg2: B, arg3: C, arg4: D, arg5: E, arg6: F) => Result, 81 | opts?: SelectorOptions 82 | ): Selector; 83 | selector( 84 | selector1: Observable, 85 | selector2: Observable, 86 | selector3: Observable, 87 | selector4: Observable, 88 | selector5: Observable, 89 | selector6: Observable, 90 | selector7: Observable, 91 | projectFn: (arg1: A, arg2: B, arg3: C, arg4: D, arg5: E, arg6: F, arg7: G) => Result, 92 | opts?: SelectorOptions 93 | ): Selector; 94 | selector( 95 | selector1: Observable, 96 | selector2: Observable, 97 | selector3: Observable, 98 | selector4: Observable, 99 | selector5: Observable, 100 | selector6: Observable, 101 | selector7: Observable, 102 | selector8: Observable, 103 | projectFn: ( 104 | arg1: A, 105 | arg2: B, 106 | arg3: C, 107 | arg4: D, 108 | arg5: E, 109 | arg6: F, 110 | arg7: G, 111 | arg8: H 112 | ) => Result, 113 | opts?: SelectorOptions 114 | ): Selector; 115 | selector( 116 | selector1: Observable, 117 | selector2: Observable, 118 | selector3: Observable, 119 | selector4: Observable, 120 | selector5: Observable, 121 | selector6: Observable, 122 | selector7: Observable, 123 | selector8: Observable, 124 | selector9: Observable, 125 | projectFn: ( 126 | arg1: A, 127 | arg2: B, 128 | arg3: C, 129 | arg4: D, 130 | arg5: E, 131 | arg6: F, 132 | arg7: G, 133 | arg8: H, 134 | arg9: I 135 | ) => Result, 136 | opts?: SelectorOptions 137 | ): Selector; 138 | } 139 | 140 | export default function createSelectorFactory( 141 | state$: Observable 142 | ): SelectorCreator { 143 | function selector(...args: any[]) { 144 | const lastArg = last(args); 145 | let inputs, projectFn, opts; 146 | // if the last arg is the project function, then no options were provided 147 | if (isFunction(lastArg)) { 148 | inputs = initial(args); 149 | projectFn = lastArg; 150 | opts = { 151 | compare: strictEquality, 152 | }; 153 | } else { 154 | opts = lastArg; 155 | projectFn = args[args.length - 2]; 156 | inputs = args.slice(0, args.length - 2); 157 | } 158 | 159 | const selector$ = combineLatest(...inputs).pipe( 160 | sample(state$), 161 | map(args => projectFn(...args)), 162 | distinctUntilChanged(opts.compare), 163 | publishReplay(1), 164 | refCount() 165 | ); 166 | 167 | return selector$; 168 | } 169 | 170 | return { selector }; 171 | } 172 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { Observable } from 'rxjs'; 18 | 19 | // function that takes a state instance and returns a new, transformed state instance 20 | export type StateTransform = (state: T) => T; 21 | 22 | // we differ from redux action defintion in that we require a payload 23 | // having a uniform structure gives us nice inference capabilities 24 | export interface Action { 25 | type: string; 26 | payload: object; 27 | } 28 | 29 | export type Dispatch = (a: TActions) => void; 30 | 31 | export type IReducer = ( 32 | state: TState, 33 | action: TAction 34 | ) => TState; 35 | 36 | export type Epic< 37 | TWatchedAction extends Action, 38 | TReturnedAction extends Action, 39 | TEpicDependencies extends object 40 | > = ( 41 | action$: Observable, 42 | deps: TEpicDependencies 43 | ) => Observable; 44 | 45 | export type MiddlewareEpic< 46 | TAllActions extends Action, 47 | TEpicDependencies extends object 48 | > = Epic; 49 | 50 | export type AllActionsEpic< 51 | TAllActions extends Action, 52 | TEpicDependencies extends object 53 | > = Epic; 54 | 55 | export type SingleActionEpic< 56 | TAllActions extends Action, 57 | TAction extends TAllActions, 58 | TEpicDependencies extends object 59 | > = ( 60 | action$: Observable, 61 | deps: TEpicDependencies, 62 | allActions$: Observable 63 | ) => Observable; 64 | 65 | // Utility - infers the type of the first argument of a function 66 | export type Arg0 = T extends (args0: infer R, ...any: any[]) => any ? R : never; 67 | 68 | // TODO: this does not feel like good practice. We need to re-evaluate our use of 69 | // generic default parameters and decide how to leverage the inference engine correctly 70 | export type SanitizeNull = T extends null ? {} : T; 71 | -------------------------------------------------------------------------------- /src/typesafeRedux.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { each } from 'lodash'; 18 | import { BehaviorSubject } from 'rxjs'; 19 | import { 20 | App, 21 | AppCreator, 22 | CombinedActions, 23 | CombinedEpicDependencies, 24 | CombinedState, 25 | FeaturesMap, 26 | CreateAppParams, 27 | } from './createApp'; 28 | import createActionImplementer, { ActionImplementer } from './implementAction'; 29 | import createPathFactory, { PathCreator } from './paths'; 30 | import createSelectorFactory, { SelectorCreator } from './selectors'; 31 | import { Action } from './types'; 32 | 33 | const emptyObj = {}; 34 | 35 | export type TypesafeRedux< 36 | TOwnState extends object, 37 | TAllState extends object, 38 | TOwnActions extends TAllActions, 39 | TAllActions extends Action, 40 | TAllEpicDeps extends object = null, 41 | TFeaturesMap extends FeaturesMap = null 42 | > = PathCreator & 43 | SelectorCreator & 44 | ActionImplementer & 45 | AppCreator< 46 | TOwnState, 47 | TAllState, 48 | TOwnActions, 49 | TAllActions, 50 | TFeaturesMap, 51 | TAllEpicDeps 52 | > & { 53 | state$: BehaviorSubject; 54 | }; 55 | 56 | export default function createTypesafeRedux< 57 | TOwnState extends object, 58 | TOwnActions extends Action, 59 | TOwnEpicDeps extends object = {}, 60 | TFeaturesMap extends FeaturesMap = null 61 | >( 62 | externalFeatures: TFeaturesMap = null 63 | ): TypesafeRedux< 64 | TOwnState, 65 | CombinedState, 66 | TOwnActions, 67 | CombinedActions, 68 | CombinedEpicDependencies, 69 | TFeaturesMap 70 | > { 71 | type TAllState = CombinedState; 72 | type TAllActions = CombinedActions; 73 | type TAllEpicDeps = CombinedEpicDependencies; 74 | 75 | const state$: BehaviorSubject = new BehaviorSubject( 76 | emptyObj as TAllState 77 | ); 78 | 79 | const { path } = createPathFactory(state$); 80 | const { selector } = createSelectorFactory(state$); 81 | const { action } = createActionImplementer< 82 | TAllState, 83 | TOwnActions, 84 | TAllActions, 85 | TAllEpicDeps 86 | >(); 87 | const createApp = ( 88 | params: CreateAppParams 89 | ) => { 90 | return new App< 91 | TOwnState, 92 | TAllState, 93 | TOwnActions, 94 | TAllActions, 95 | TFeaturesMap, 96 | TAllEpicDeps 97 | >(state$, externalFeatures, params); 98 | }; 99 | 100 | each(externalFeatures, (feature, subtreeKey) => { 101 | // @ts-ignore ts server is really choking on the typings here 102 | feature.wireUpState(path([subtreeKey], emptyObj)); 103 | }); 104 | 105 | return { 106 | state$, 107 | path, 108 | selector, 109 | action, 110 | createApp, 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /src/utils/comparators.test.ts: -------------------------------------------------------------------------------- 1 | // Copied from react-redux 2 | 3 | import { comparators } from './comparators'; 4 | 5 | describe('utils', () => { 6 | describe('shallowEqual', () => { 7 | it('should return true if arguments fields are equal', () => { 8 | expect( 9 | comparators.shallowEqual( 10 | { a: 1, b: 2, c: undefined }, 11 | { a: 1, b: 2, c: undefined } 12 | ) 13 | ).toBe(true); 14 | 15 | expect(comparators.shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3 })).toBe( 16 | true 17 | ); 18 | 19 | const o = {}; 20 | expect(comparators.shallowEqual({ a: 1, b: 2, c: o }, { a: 1, b: 2, c: o })).toBe( 21 | true 22 | ); 23 | 24 | const d = function() { 25 | return 1; 26 | }; 27 | expect( 28 | comparators.shallowEqual({ a: 1, b: 2, c: o, d }, { a: 1, b: 2, c: o, d }) 29 | ).toBe(true); 30 | }); 31 | 32 | it('should return false if arguments fields are different function identities', () => { 33 | expect( 34 | comparators.shallowEqual( 35 | { 36 | a: 1, 37 | b: 2, 38 | d: function() { 39 | return 1; 40 | }, 41 | }, 42 | { 43 | a: 1, 44 | b: 2, 45 | d: function() { 46 | return 1; 47 | }, 48 | } 49 | ) 50 | ).toBe(false); 51 | }); 52 | 53 | it('should return false if first argument has too many keys', () => { 54 | expect(comparators.shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })).toBe(false); 55 | }); 56 | 57 | it('should return false if second argument has too many keys', () => { 58 | expect(comparators.shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2, c: 3 })).toBe(false); 59 | }); 60 | 61 | it('should return false if arguments have different keys', () => { 62 | expect( 63 | comparators.shallowEqual( 64 | { a: 1, b: 2, c: undefined }, 65 | { a: 1, bb: 2, c: undefined } 66 | ) 67 | ).toBe(false); 68 | }); 69 | 70 | it('should compare two NaN values', () => { 71 | expect(comparators.shallowEqual(NaN, NaN)).toBe(true); 72 | }); 73 | 74 | it('should compare empty objects with false', () => { 75 | expect(comparators.shallowEqual({}, false)).toBe(false); 76 | expect(comparators.shallowEqual(false, {})).toBe(false); 77 | expect(comparators.shallowEqual([], false)).toBe(false); 78 | expect(comparators.shallowEqual(false, [])).toBe(false); 79 | }); 80 | 81 | it('should compare two zero values', () => { 82 | expect(comparators.shallowEqual(0, 0)).toBe(true); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/utils/comparators.ts: -------------------------------------------------------------------------------- 1 | // Copied from react-redux 2 | import { isEqual } from 'lodash'; 3 | 4 | const hasOwn = Object.prototype.hasOwnProperty; 5 | 6 | function is(x, y) { 7 | if (x === y) { 8 | return x !== 0 || y !== 0 || 1 / x === 1 / y; 9 | } else { 10 | return x !== x && y !== y; 11 | } 12 | } 13 | 14 | function shallowEqual(objA, objB) { 15 | if (is(objA, objB)) return true; 16 | 17 | if ( 18 | typeof objA !== 'object' || 19 | objA === null || 20 | typeof objB !== 'object' || 21 | objB === null 22 | ) { 23 | return false; 24 | } 25 | 26 | const keysA = Object.keys(objA); 27 | const keysB = Object.keys(objB); 28 | 29 | if (keysA.length !== keysB.length) return false; 30 | 31 | for (let i = 0; i < keysA.length; i++) { 32 | if (!hasOwn.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { 33 | return false; 34 | } 35 | } 36 | 37 | return true; 38 | } 39 | 40 | function strictEqual(a, b) { 41 | return a === b; 42 | } 43 | 44 | function deepEqual(a, b) { 45 | return isEqual(a, b); 46 | } 47 | 48 | export const comparators = { 49 | shallowEqual, 50 | strictEqual, 51 | deepEqual, 52 | }; 53 | -------------------------------------------------------------------------------- /src/utils/flow.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { flow as fpFlow } from 'lodash/fp'; 18 | import { StateTransform } from '../types'; 19 | 20 | export function flow( 21 | ...transforms: StateTransform[] 22 | ): (state: T) => T; 23 | 24 | export function flow(...args: any[]): (state: T) => T { 25 | return fpFlow(...args); 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/get.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { get } from './get'; 18 | 19 | interface State { 20 | a: { 21 | b: { 22 | c: { 23 | string: string; 24 | number: number; 25 | }; 26 | }; 27 | }; 28 | counter: number; 29 | bool: boolean; 30 | } 31 | 32 | describe('get', () => { 33 | let state: State; 34 | 35 | beforeEach(() => { 36 | state = { 37 | a: { 38 | b: { 39 | c: { 40 | string: '1', 41 | number: 2, 42 | }, 43 | }, 44 | }, 45 | counter: 7, 46 | bool: true, 47 | }; 48 | }); 49 | 50 | it('should work on shallow props', () => { 51 | expect(get(['counter'])(state)).toEqual(7); 52 | }); 53 | 54 | it('should work on deep props', () => { 55 | expect(get(['a', 'b', 'c', 'string'])(state)).toEqual('1'); 56 | }); 57 | 58 | it('should return default val if prop is null or undefined', () => { 59 | state.counter = null; 60 | expect(get(['counter'], 10)(state)).toEqual(10); 61 | state.counter = undefined; 62 | expect(get(['counter'], 10)(state)).toEqual(10); 63 | }); 64 | 65 | it('should not return default val if prop is falsy', () => { 66 | state.counter = 0; 67 | expect(get(['counter'], 10)(state)).toEqual(0); 68 | state.bool = false; 69 | expect(get(['bool'], true)(state)).toEqual(false); 70 | }); 71 | 72 | it('should return default val if prop is null or undefined', () => { 73 | state.counter = null; 74 | expect(get(['counter'], 10)(state)).toEqual(10); 75 | state.counter = undefined; 76 | expect(get(['counter'], 10)(state)).toEqual(10); 77 | }); 78 | 79 | it('should return default vals of null', () => { 80 | state.counter = undefined; 81 | expect(get(['counter'], null)(state)).toEqual(null); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/utils/get.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { get as fpGet } from 'lodash/fp'; 18 | 19 | export function get(path: string[], defaultVal?: any) { 20 | return function(state: T) { 21 | const val = fpGet(path.join('.'), state); 22 | if (defaultVal !== undefined && val == null) { 23 | return defaultVal; 24 | } 25 | return val; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/ofType.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { ofType as rxOfType } from 'redux-observable'; 18 | import { Action } from '../types'; 19 | import { Observable } from 'rxjs'; 20 | 21 | export default function ofType(...key: K[]) { 22 | return (source: Observable): Observable> => { 23 | return source.pipe(rxOfType(...key)); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/set.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { set } from './set'; 18 | 19 | interface State { 20 | a: { 21 | b: { 22 | c: { 23 | string: string; 24 | number: number; 25 | }; 26 | }; 27 | }; 28 | counter: number; 29 | } 30 | 31 | const state: State = { 32 | a: { 33 | b: { 34 | c: { 35 | string: '1', 36 | number: 2, 37 | }, 38 | }, 39 | }, 40 | counter: 7, 41 | }; 42 | 43 | describe('set', () => { 44 | it('should set the targetted value', () => { 45 | const nextState = set(['counter'])(2)(state); 46 | expect(nextState.counter).toEqual(2); 47 | }); 48 | 49 | it('should not mutate the original state', () => { 50 | const nextState = set(['counter'])(2)(state); 51 | expect(state.counter).not.toEqual(2); 52 | }); 53 | 54 | it('should return a new object reference', () => { 55 | const nextState = set(['counter'])(2)(state); 56 | expect(nextState).not.toBe(state); 57 | }); 58 | 59 | it('should maintain referential equality for parts of the subtree not affected', () => { 60 | const nextState = set(['counter'])(2)(state); 61 | expect(nextState.a).toBe(state.a); 62 | }); 63 | 64 | it('should cascade reference changes', () => { 65 | const nextState = set(['a', 'b', 'c', 'string'])('hi')(state); 66 | expect(nextState.a).not.toBe(state.a); 67 | expect(nextState.a.b).not.toBe(state.a.b); 68 | expect(nextState.a.b.c).not.toBe(state.a.b.c); 69 | expect(nextState.a.b.c.string).not.toBe(state.a.b.c.string); 70 | expect(nextState.a.b.c.string).toBe('hi'); 71 | expect(nextState.a.b.c.number).toBe(state.a.b.c.number); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/utils/set.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { set as fpSet } from 'lodash/fp'; 18 | import { StateTransform } from '../types'; 19 | 20 | export function set(path: string[]) { 21 | return function(value: any): StateTransform { 22 | return fpSet(path.join('.'), value) as StateTransform; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/unset.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { unset } from './unset'; 18 | 19 | interface State { 20 | a: { 21 | b: { 22 | c: { 23 | string: string; 24 | number: number; 25 | }; 26 | }; 27 | }; 28 | counter: number; 29 | } 30 | 31 | const state: State = { 32 | a: { 33 | b: { 34 | c: { 35 | string: '1', 36 | number: 2, 37 | }, 38 | }, 39 | }, 40 | counter: 7, 41 | }; 42 | 43 | describe('unset', () => { 44 | it('should remove the targetted value', () => { 45 | const nextState = unset(['counter'])(state); 46 | expect(nextState.counter).toBeUndefined(); 47 | }); 48 | 49 | it('should not mutate the original state', () => { 50 | const nextState = unset(['counter'])(state); 51 | expect(state.counter).toEqual(7); 52 | }); 53 | 54 | it('should return a new object reference', () => { 55 | const nextState = unset(['counter'])(state); 56 | expect(nextState).not.toBe(state); 57 | }); 58 | 59 | it('should maintain referential equality for parts of the subtree not affected', () => { 60 | const nextState = unset(['counter'])(state); 61 | expect(nextState.a).toBe(state.a); 62 | }); 63 | 64 | it('should cascade reference changes', () => { 65 | const nextState = unset(['a', 'b', 'c', 'string'])(state); 66 | 67 | expect(nextState.a).not.toBe(state.a); 68 | expect(nextState.a.b).not.toBe(state.a.b); 69 | expect(nextState.a.b.c).not.toBe(state.a.b.c); 70 | expect(nextState.a.b.c.string).not.toBe(state.a.b.c.string); 71 | expect(nextState.a.b.c.string).toBeUndefined(); 72 | expect(nextState.a.b.c.number).toBe(state.a.b.c.number); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/utils/unset.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { unset as fpUnset } from 'lodash/fp'; 18 | import { StateTransform } from '../types'; 19 | 20 | export function unset(path: string[]) { 21 | return fpUnset(path.join('.')) as StateTransform; 22 | } 23 | -------------------------------------------------------------------------------- /test/app.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { ofType } from '../src'; 18 | import { createTypesafeRedux } from '../src'; 19 | 20 | import { CounterActionTypes, makeLib } from './lib'; 21 | import { 22 | map, 23 | mapTo, 24 | flatMap, 25 | tap, 26 | takeUntil, 27 | toArray, 28 | sample, 29 | buffer, 30 | } from 'rxjs/operators'; 31 | import { never } from 'rxjs'; 32 | 33 | export interface AppState { 34 | name: string; 35 | numbers: number[]; 36 | } 37 | 38 | export enum ActionTypes { 39 | SET_NAME = 'SET_NAME', 40 | ADD_NUMBER = 'ADD_NUMBER', 41 | REMOVE_NUMBER = 'REMOVE_NUMBER', 42 | } 43 | 44 | export interface Actions { 45 | [ActionTypes.SET_NAME]: { 46 | type: ActionTypes.SET_NAME; 47 | payload: { 48 | name: string; 49 | }; 50 | }; 51 | [ActionTypes.ADD_NUMBER]: { 52 | type: ActionTypes.ADD_NUMBER; 53 | payload: { 54 | number: number; 55 | }; 56 | }; 57 | [ActionTypes.REMOVE_NUMBER]: { 58 | type: ActionTypes.REMOVE_NUMBER; 59 | payload: {}; 60 | }; 61 | } 62 | 63 | export function makeApp(middlewareSpy: () => void) { 64 | const lib = makeLib(); 65 | const { counterLib, COUNTER } = lib; 66 | 67 | interface Features { 68 | lib: typeof counterLib; 69 | } 70 | 71 | const redux = createTypesafeRedux({ 72 | lib: counterLib, 73 | }); 74 | 75 | const NAME = redux.path(['name']); 76 | const NUMBERS = redux.path(['numbers']); 77 | 78 | const SUM = redux.selector(NUMBERS, COUNTER, (numbers, counter) => { 79 | return numbers.reduce((sum, n) => sum + n, 0) + counter; 80 | }); 81 | 82 | const setName = redux.action(ActionTypes.SET_NAME, { 83 | reducer: (state, action) => { 84 | return NAME.set(action.payload.name)(state); 85 | }, 86 | }); 87 | const addNumber = redux.action(ActionTypes.ADD_NUMBER, { 88 | reducer: (state, action) => { 89 | const numbers = NUMBERS.get(state); 90 | return NUMBERS.set(numbers.concat(action.payload.number))(state); 91 | }, 92 | epic: (action$, deps) => { 93 | return action$.pipe( 94 | mapTo(counterLib.actionCreator(CounterActionTypes.increment)({ amount: 1 })) 95 | ); 96 | }, 97 | }); 98 | const removeNumber = redux.action(ActionTypes.REMOVE_NUMBER, { 99 | reducer: (state, action) => { 100 | const numbers = NUMBERS.get(state); 101 | return NUMBERS.set(numbers.slice(0, -1))(state); 102 | }, 103 | epic: action$ => { 104 | return action$.pipe( 105 | mapTo(counterLib.actionCreator(CounterActionTypes.decrement)({ amount: 1 })) 106 | ); 107 | }, 108 | }); 109 | 110 | const app = redux.createApp({ 111 | initialState: { 112 | name: 'Steve', 113 | numbers: [], 114 | }, 115 | actions: { 116 | [ActionTypes.SET_NAME]: setName, 117 | [ActionTypes.ADD_NUMBER]: addNumber, 118 | [ActionTypes.REMOVE_NUMBER]: removeNumber, 119 | }, 120 | middleware: [ 121 | actions$ => { 122 | return actions$.pipe(ofType(ActionTypes.SET_NAME)).pipe( 123 | tap(middlewareSpy), 124 | flatMap(() => never()) 125 | ); 126 | }, 127 | ], 128 | extraEpics: [ 129 | (actions$, deps) => { 130 | return actions$.pipe( 131 | ofType(CounterActionTypes.increment), 132 | buffer(actions$.pipe(ofType(CounterActionTypes.decrement))), 133 | map(amounts => { 134 | return setName.creator({ 135 | name: amounts.map(a => a.payload.amount).join(''), 136 | }); 137 | }) 138 | ); 139 | }, 140 | ], 141 | }); 142 | 143 | return { 144 | state$: redux.state$, 145 | app, 146 | NAME, 147 | NUMBERS, 148 | SUM, 149 | setName, 150 | addNumber, 151 | removeNumber, 152 | ...lib, 153 | }; 154 | } 155 | -------------------------------------------------------------------------------- /test/composability.test.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import Enzyme from 'enzyme'; 18 | import Adapter from 'enzyme-adapter-react-16'; 19 | import * as React from 'react'; 20 | import { of } from 'rxjs'; 21 | import { ActionTypes, makeApp, Actions } from './app'; 22 | import { CounterActionTypes } from './lib'; 23 | 24 | Enzyme.configure({ adapter: new Adapter() }); 25 | const { mount } = Enzyme; 26 | 27 | /** 28 | * Testing plan: 29 | * - [X] epic dependency injection 30 | * - [X] Component connected to the library 31 | * - [X] Paths and selectors from libs 32 | * - [X] Paths and selectors from app 33 | * - Epics using with source 34 | * - [X] Middleware 35 | * - [X] Extra epics 36 | */ 37 | 38 | // TODO: epic dependencies in create store? 39 | // Update type of epic dependencies to allow null if it is null or {} 40 | // epics api: 41 | // [X] typesafe redux returns ref to state$ stream 42 | // [X] typesafe epic api takes $action, deps, $actions 43 | // [X] "extra epics" for binding to libs functionality 44 | // Think about the name of create store 45 | // Collisions in epic deps 46 | // Collisions in features vs state tree 47 | // remove withsource 48 | 49 | interface ObservableProps { 50 | counter: number; 51 | counterString: string; 52 | sum: number; 53 | name: string; 54 | } 55 | 56 | interface DispatchProps { 57 | remove: () => Actions[ActionTypes.REMOVE_NUMBER]; 58 | add: (addBy: number) => Actions[ActionTypes.ADD_NUMBER]; 59 | } 60 | 61 | const renderSpy = jest.fn(); 62 | class TestComponent extends React.Component { 63 | render() { 64 | renderSpy(this.props); 65 | return ( 66 |
67 |
{this.props.counter}
68 |
{this.props.sum}
69 |
{this.props.counterString}
70 |
71 | ); 72 | } 73 | } 74 | 75 | describe('composability', () => { 76 | let ConnectedComponent: React.ComponentType; 77 | let middlewareSpy = jest.fn(); 78 | let { app, counterLib, COUNTER, COUNTER_AS_STRING, state$ } = makeApp(middlewareSpy); 79 | 80 | beforeEach(() => { 81 | renderSpy.mockReset(); 82 | middlewareSpy = jest.fn(); 83 | const a = makeApp(middlewareSpy); 84 | const { SUM, NUMBERS, NAME } = a; 85 | app = a.app; 86 | state$ = a.state$; 87 | counterLib = a.counterLib; 88 | COUNTER = a.COUNTER; 89 | COUNTER_AS_STRING = a.COUNTER_AS_STRING; 90 | 91 | app.createStore({ 92 | epicDependencies: { 93 | lib: { 94 | getValue: () => 7, 95 | }, 96 | }, 97 | dev: false, 98 | }); 99 | 100 | ConnectedComponent = app.connect( 101 | { 102 | counter: COUNTER, 103 | counterString: COUNTER_AS_STRING, 104 | sum: SUM, 105 | name: NAME, 106 | }, 107 | { 108 | remove: () => app.actionCreator(ActionTypes.REMOVE_NUMBER)({}), 109 | add: (addBy: number) => 110 | app.actionCreator(ActionTypes.ADD_NUMBER)({ number: addBy }), 111 | } 112 | )(TestComponent); 113 | }); 114 | 115 | it('should execute selectors from the lib', () => { 116 | mount(); 117 | expect(renderSpy).toHaveBeenCalledTimes(1); 118 | const props = renderSpy.mock.calls[0][0]; 119 | expect(props.counter).toEqual(0); 120 | expect(props.sum).toEqual(0); 121 | }); 122 | 123 | it('should allow dispatcing of app actions', () => { 124 | mount(); 125 | 126 | app.dispatch(app.actionCreator(ActionTypes.SET_NAME)({ name: 'ted' })); 127 | 128 | expect(renderSpy).toHaveBeenCalledTimes(2); 129 | const props = renderSpy.mock.calls[1][0]; 130 | expect(props.name).toEqual('ted'); 131 | }); 132 | 133 | it('should allow dispatcing of lib actions from app', () => { 134 | mount(); 135 | 136 | app.dispatch(app.actionCreator(CounterActionTypes.increment)({ amount: 1 })); 137 | 138 | expect(renderSpy).toHaveBeenCalledTimes(2); 139 | const props = renderSpy.mock.calls[1][0]; 140 | expect(props.counter).toEqual(1); 141 | }); 142 | 143 | it('should allow dispatcing of lib actions from lib', () => { 144 | mount(); 145 | 146 | app.dispatch(counterLib.actionCreator(CounterActionTypes.increment)({ amount: 1 })); 147 | 148 | expect(renderSpy).toHaveBeenCalledTimes(2); 149 | const props = renderSpy.mock.calls[1][0]; 150 | expect(props.counter).toEqual(1); 151 | }); 152 | 153 | it('should allow components to be connected through the lib', () => { 154 | ConnectedComponent = counterLib.connect( 155 | { 156 | counter: COUNTER, 157 | counterString: COUNTER_AS_STRING, 158 | sum: of(0), 159 | name: of('asdf'), 160 | }, 161 | null 162 | )(TestComponent); 163 | mount(); 164 | 165 | app.dispatch(app.actionCreator(CounterActionTypes.increment)({ amount: 10 })); 166 | 167 | expect(renderSpy).toHaveBeenCalledTimes(2); 168 | const props = renderSpy.mock.calls[1][0]; 169 | expect(props.counter).toEqual(10); 170 | }); 171 | 172 | it('should allow selectors combined from the app and the lib', () => { 173 | mount(); 174 | 175 | app.dispatch(app.actionCreator(CounterActionTypes.increment)({ amount: 10 })); 176 | 177 | expect(renderSpy).toHaveBeenCalledTimes(2); 178 | const props = renderSpy.mock.calls[1][0]; 179 | expect(props.sum).toEqual(10); 180 | }); 181 | 182 | it('should respect epic dependencies injected into the parent app', () => { 183 | mount(); 184 | app.dispatch(app.actionCreator(CounterActionTypes.incrementEpic)({})); 185 | expect(renderSpy).toHaveBeenCalledTimes(2); 186 | const props = renderSpy.mock.calls[1][0]; 187 | }); 188 | 189 | it('should invoke middleware epics provided to the app', () => { 190 | mount(); 191 | expect(middlewareSpy).toHaveBeenCalledTimes(0); 192 | app.dispatch(app.actionCreator(ActionTypes.SET_NAME)({ name: 'a' })); 193 | expect(middlewareSpy).toHaveBeenCalledTimes(1); 194 | app.dispatch(app.actionCreator(ActionTypes.SET_NAME)({ name: 'b' })); 195 | expect(middlewareSpy).toHaveBeenCalledTimes(2); 196 | }); 197 | 198 | it('should invoke extra epics provided to the app', () => { 199 | mount(); 200 | app.dispatch(app.actionCreator(CounterActionTypes.increment)({ amount: 7 })); 201 | app.dispatch(app.actionCreator(CounterActionTypes.increment)({ amount: 7 })); 202 | app.dispatch(app.actionCreator(CounterActionTypes.increment)({ amount: 34 })); 203 | app.dispatch(app.actionCreator(CounterActionTypes.decrement)({ amount: 34 })); 204 | 205 | expect(renderSpy).toHaveBeenCalledTimes(6); 206 | const props = renderSpy.mock.calls[5][0]; 207 | expect(props.name).toEqual('7734'); 208 | }); 209 | 210 | it('should handle case when a reducer returns state referentially equivalent to the currentState', () => { 211 | app.dispatch(app.actionCreator(ActionTypes.SET_NAME)({ name: 'ted' })); 212 | const beforeState = state$.getValue(); 213 | app.dispatch(app.actionCreator(CounterActionTypes.identity)({})); 214 | const nextState = state$.getValue(); 215 | expect(beforeState).toBe(nextState); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /test/lib.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { createTypesafeRedux } from '../src'; 18 | import { map } from 'rxjs/operators'; 19 | 20 | export interface CounterState { 21 | counter: number; 22 | } 23 | 24 | export enum CounterActionTypes { 25 | increment = 'COUNTER::INCREMENT', 26 | decrement = 'COUNTER::DECREMENT', 27 | incrementEpic = 'COUNTER::INCREMENT_EPIC', 28 | identity = 'COUNTER::IDENTITY', 29 | } 30 | 31 | interface CounterActions { 32 | [CounterActionTypes.increment]: { 33 | type: CounterActionTypes.increment; 34 | payload: { amount: number }; 35 | }; 36 | [CounterActionTypes.decrement]: { 37 | type: CounterActionTypes.decrement; 38 | payload: { amount: number }; 39 | }; 40 | [CounterActionTypes.incrementEpic]: { 41 | type: CounterActionTypes.incrementEpic; 42 | payload: {}; 43 | }; 44 | [CounterActionTypes.identity]: { 45 | type: CounterActionTypes.identity, 46 | payload: {} 47 | } 48 | } 49 | 50 | export interface CounterEpicDeps { 51 | getValue: () => number; 52 | } 53 | 54 | export function makeLib() { 55 | const redux = createTypesafeRedux< 56 | CounterState, 57 | CounterActions[keyof CounterActions], 58 | CounterEpicDeps 59 | >(); 60 | 61 | const COUNTER = redux.path(['counter']); 62 | const COUNTER_AS_STRING = redux.selector(COUNTER, counter => { 63 | return `Counter Value is ${counter}`; 64 | }); 65 | 66 | const increment = redux.action(CounterActionTypes.increment, { 67 | reducer: (state, action) => { 68 | const currentValue = COUNTER.get(state); 69 | return COUNTER.set(currentValue + action.payload.amount)(state); 70 | }, 71 | }); 72 | const decrement = redux.action(CounterActionTypes.decrement, { 73 | reducer: (state, action) => { 74 | const currentValue = COUNTER.get(state); 75 | return COUNTER.set(currentValue - action.payload.amount)(state); 76 | }, 77 | }); 78 | const incrementEpic = redux.action(CounterActionTypes.incrementEpic, { 79 | epic: (action$, deps) => { 80 | return action$.pipe( 81 | map(v => { 82 | return increment.creator({ amount: deps.getValue() }); 83 | }) 84 | ); 85 | }, 86 | }); 87 | 88 | const identity = redux.action(CounterActionTypes.identity, { 89 | reducer: state => state 90 | }) 91 | 92 | const counterLib = redux.createApp({ 93 | initialState: { counter: 0 }, 94 | actions: { 95 | [CounterActionTypes.increment]: increment, 96 | [CounterActionTypes.decrement]: decrement, 97 | [CounterActionTypes.incrementEpic]: incrementEpic, 98 | [CounterActionTypes.identity]: identity, 99 | }, 100 | }); 101 | 102 | return { 103 | COUNTER, 104 | COUNTER_AS_STRING, 105 | counterLib, 106 | }; 107 | } 108 | -------------------------------------------------------------------------------- /test/testability.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright Avero, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { makeApp, AppState } from './app'; 18 | import { noop } from 'lodash'; 19 | import { take, toArray } from 'rxjs/operators'; 20 | import { CounterActionTypes } from './lib'; 21 | import { of } from 'rxjs'; 22 | 23 | const initialState = { 24 | name: '', 25 | numbers: [], 26 | lib: { 27 | counter: 0, 28 | }, 29 | }; 30 | 31 | describe('app testability', () => { 32 | let { state$, NUMBERS, NAME, SUM, setName, addNumber, removeNumber } = makeApp(noop); 33 | 34 | beforeEach(() => { 35 | const a = makeApp(noop); 36 | 37 | state$ = a.state$; 38 | NUMBERS = a.NUMBERS; 39 | NAME = a.NAME; 40 | SUM = a.SUM; 41 | setName = a.setName; 42 | addNumber = a.addNumber; 43 | removeNumber = a.removeNumber; 44 | 45 | state$.next(initialState); 46 | }); 47 | 48 | describe('paths', () => { 49 | it('should be testable by interacting with the state$ stream', () => { 50 | state$.next({ 51 | ...initialState, 52 | name: 'Elliot', 53 | }); 54 | 55 | return expect(NAME.pipe(take(1)).toPromise()).resolves.toEqual('Elliot'); 56 | }); 57 | }); 58 | 59 | describe('selectors', () => { 60 | it('should be testable by interacting with the state$ stream', () => { 61 | state$.next({ 62 | ...initialState, 63 | numbers: [1, 2, 3], 64 | lib: { 65 | counter: 10, 66 | }, 67 | }); 68 | 69 | const expected = SUM.pipe( 70 | take(3), 71 | toArray() 72 | ).toPromise(); 73 | 74 | state$.next({ 75 | ...initialState, 76 | numbers: [1, 2, 3], 77 | lib: { 78 | counter: 20, 79 | }, 80 | }); 81 | state$.next({ 82 | ...initialState, 83 | numbers: [5], 84 | lib: { 85 | counter: 20, 86 | }, 87 | }); 88 | 89 | return expect(expected).resolves.toEqual([16, 26, 25]); 90 | }); 91 | }); 92 | 93 | describe('reducers', () => { 94 | it('should allow testing of reducers as pure functions', () => { 95 | const nextState = setName.reducer(initialState, setName.creator({ name: 'bob' })); 96 | 97 | expect(nextState).not.toBe(initialState); 98 | expect(nextState.name).toEqual('bob'); 99 | }); 100 | }); 101 | 102 | describe('epics', () => { 103 | it('should allow testing of epics', () => { 104 | const { epic, creator } = addNumber; 105 | 106 | const emittedAction = epic( 107 | of(creator({ number: 10 })), 108 | { 109 | lib: { 110 | getValue: () => 7, 111 | }, 112 | }, 113 | null 114 | ); 115 | 116 | return expect(emittedAction.toPromise()).resolves.toEqual({ 117 | type: CounterActionTypes.increment, 118 | payload: { amount: 1 }, 119 | }); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules", "**/*.test.ts", "test/**/*", "example/**/*"], 3 | "compilerOptions": { 4 | /* Basic Options */ 5 | "target": 6 | "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "lib": [ 10 | "es2017", 11 | "dom" 12 | ] /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 13 | // "lib": [], /* Specify library files to be included in the compilation. */ 14 | // "allowJs": true, /* Allow javascript files to be compiled. */ 15 | // "checkJs": true, /* Report errors in .js files. */ 16 | "jsx": 17 | "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 18 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 19 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 20 | // "outFile": "./", /* Concatenate and emit output to single file. */ 21 | // "outDir": "./", /* Redirect output structure to the directory. */ 22 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 23 | // "removeComments": true, /* Do not emit comments to output. */ 24 | // "noEmit": true, /* Do not emit outputs. */ 25 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 27 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 28 | 29 | /* Strict Type-Checking Options */ 30 | "strict": false, 31 | "skipLibCheck": true /* Enable all strict type-checking options. */, 32 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | // "strictNullChecks": true, /* Enable strict null checks. */ 34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | 39 | /* Additional Checks */ 40 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 41 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 42 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 43 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /typings/lodash-fp.d.ts: -------------------------------------------------------------------------------- 1 | // https://raw.githubusercontent.com/donnut/typescript-ramda/master/ramda.d.ts 2 | // https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/master/lodash/lodash.d.ts 3 | 4 | declare namespace fp { 5 | interface Dictionary { 6 | [index: string]: T; 7 | } 8 | 9 | interface CurriedFunction1 { 10 | (): CurriedFunction1; 11 | (t1: T1): R; 12 | } 13 | 14 | interface CurriedFunction2 { 15 | (): CurriedFunction2; 16 | (t1: T1): CurriedFunction1; 17 | (t1: T1, t2: T2): R; 18 | } 19 | 20 | interface CurriedFunction3 { 21 | (): CurriedFunction3; 22 | (t1: T1): CurriedFunction2; 23 | (t1: T1, t2: T2): CurriedFunction1; 24 | (t1: T1, t2: T2, t3: T3): R; 25 | } 26 | 27 | interface CurriedFunction4 { 28 | (): CurriedFunction4; 29 | (t1: T1): CurriedFunction3; 30 | (t1: T1, t2: T2): CurriedFunction2; 31 | (t1: T1, t2: T2, t3: T3): CurriedFunction1; 32 | (t1: T1, t2: T2, t3: T3, t4: T4): R; 33 | } 34 | 35 | interface CurriedFunction5 { 36 | (): CurriedFunction5; 37 | (t1: T1): CurriedFunction4; 38 | (t1: T1, t2: T2): CurriedFunction3; 39 | (t1: T1, t2: T2, t3: T3): CurriedFunction2; 40 | (t1: T1, t2: T2, t3: T3, t4: T4): CurriedFunction1; 41 | (t1: T1, t2: T2, t3: T3, t4: T4, t5: T5): R; 42 | } 43 | 44 | interface ListIterator { 45 | (value: T, index: number, collection: List): TResult; 46 | } 47 | 48 | // Common interface between Arrays and jQuery objects 49 | interface List { 50 | [index: number]: T; 51 | length: number; 52 | } 53 | 54 | interface Static { 55 | /** 56 | * Creates an array of elements corresponding to the given keys, or indexes, of collection. Keys may be 57 | * specified as individual arguments or as arrays of keys. 58 | * 59 | * @param props The property names or indexes of elements to pick, specified individually or in arrays. 60 | * @param collection The collection to iterate over. 61 | * @return Returns the new array of picked elements. 62 | */ 63 | at(props: (number | string | (number | string)[])[]): (collection: List) => T[]; 64 | 65 | /** 66 | * Returns a curried equivalent of the provided function, with the specified arity. The curried function has 67 | * two unusual capabilities. First, its arguments needn't be provided one at a time. 68 | */ 69 | curryN(length: number, fn: (...args: any[]) => any): Function; 70 | 71 | /** 72 | * Returns a curried equivalent of the provided function. The curried function has two unusual capabilities. 73 | * First, its arguments needn't be provided one at a time. 74 | */ 75 | curry( 76 | fn: (a: T1, b: T2) => TResult 77 | ): CurriedFunction2; 78 | curry( 79 | fn: (a: T1, b: T2, c: T3) => TResult 80 | ): CurriedFunction3; 81 | curry( 82 | fn: (a: T1, b: T2, c: T3, d: T4) => TResult 83 | ): CurriedFunction4; 84 | curry( 85 | fn: (a: T1, b: T2, c: T3, d: T4, e: T5) => TResult 86 | ): CurriedFunction5; 87 | curry(fn: Function): Function; 88 | 89 | /** 90 | * Creates a function that returns value. 91 | * 92 | * @param value The value to return from the new function. 93 | * @return Returns the new function. 94 | */ 95 | constant(value: T): () => T; 96 | 97 | /** 98 | * A no-operation function that returns undefined regardless of the arguments it receives. 99 | * 100 | * @return undefined 101 | */ 102 | noop(...args: any[]): void; 103 | 104 | /** 105 | * Creates a function that invokes the method at path on a given object. Any additional arguments are provided 106 | * to the invoked method. 107 | * 108 | * @param path The path of the method to invoke. 109 | * @param args The arguments to invoke the method with. 110 | * @return Returns the new function. 111 | */ 112 | method(path: string, ...args: any[]): (object: TObject) => TResult; 113 | 114 | /** 115 | * Creates an object composed of the picked `object` properties. 116 | * 117 | * @static 118 | * @memberOf _ 119 | * @category Object 120 | * @param {...(string|string[])} [props] The property names to pick, specified 121 | * individually or in arrays. 122 | * @param {Object} object The source object. 123 | * @returns {Object} Returns the new object. 124 | */ 125 | pick(predicate: string[], object: T): TResult; 126 | pick(predicate: string[]): (object: T) => TResult; 127 | 128 | /** 129 | * Checks if `value` is `null` or `undefined`. 130 | * 131 | * @static 132 | * @memberOf _ 133 | * @category Lang 134 | * @param {*} value The value to check. 135 | * @returns {boolean} Returns `true` if `value` is nullish, else `false`. 136 | */ 137 | isNil(value?: any): boolean; 138 | 139 | pluck(val: string): Function; 140 | 141 | /** 142 | * Creates a function that memoizes the result of func. If resolver is provided it determines the cache key for 143 | * storing the result based on the arguments provided to the memoized function. By default, the first argument 144 | * provided to the memoized function is coerced to a string and used as the cache key. The func is invoked with 145 | * the this binding of the memoized function. 146 | * 147 | * @param func The function to have its output memoized. 148 | * @return Returns the new memoizing function. 149 | */ 150 | memoize(func: Function): Function; 151 | 152 | /** 153 | * Creates an object composed of keys generated from the results of running each element of collection through 154 | * iteratee. The corresponding value of each key is the last element responsible for generating the key. The 155 | * iteratee function is bound to thisArg and invoked with three arguments: 156 | * (value, index|key, collection). 157 | * 158 | * If a property name is provided for iteratee the created _.property style callback returns the property 159 | * value of the given element. 160 | * 161 | * If a value is also provided for thisArg the created _.matchesProperty style callback returns true for 162 | * elements that have a matching property value, else false. 163 | * 164 | * If an object is provided for iteratee the created _.matches style callback returns true for elements that 165 | * have the properties of the given object, else false. 166 | * 167 | * @param term 168 | * @param collection The collection to iterate over. 169 | * @return Returns the composed aggregate object. 170 | */ 171 | keyBy(termn: string): (collection: List) => T; 172 | keyBy(termn: string, collection: List): T; 173 | 174 | /** 175 | * Gets the first element of array. 176 | * 177 | * @alias _.first 178 | * 179 | * @param array The array to query. 180 | * @return Returns the first element of array. 181 | */ 182 | head(array: List): T; 183 | 184 | flatten(): any; 185 | 186 | /** 187 | * Iterates over elements of collection, returning the first element predicate returns truthy for. 188 | * The predicate is bound to thisArg and invoked with three arguments: (value, index|key, collection). 189 | * 190 | * If a property name is provided for predicate the created _.property style callback returns the property 191 | * value of the given element. 192 | * 193 | * If a value is also provided for thisArg the created _.matchesProperty style callback returns true for 194 | * elements that have a matching property value, else false. 195 | * 196 | * If an object is provided for predicate the created _.matches style callback returns true for elements that 197 | * have the properties of the given object, else false. 198 | * 199 | * @param predicate The function invoked per iteration. 200 | * @param collection The collection to search. 201 | * @return Returns the matched element, else undefined. 202 | */ 203 | find(predicate: ListIterator, collection: List): T; 204 | find(predicate: ListIterator): (collection: List) => T; 205 | 206 | /** 207 | * @see fp.find 208 | */ 209 | find(predicate: TObject, collection: List): T; 210 | find(predicate: TObject): (collection: List) => T; 211 | 212 | /** 213 | * Creates a function that returns the property value at path on a given object. 214 | * 215 | * @param path The path of the property to get. 216 | * @return Returns the new function. 217 | */ 218 | property(path: string): (obj: TObj) => TResult; 219 | 220 | /** 221 | * Creates an array of the own enumerable property values of object. 222 | * 223 | * @param object The object to query. 224 | * @return Returns an array of property values. 225 | */ 226 | values(object?: any): T[]; 227 | 228 | /** 229 | * @see _.values 230 | */ 231 | 232 | /** 233 | * Creates a debounced function that delays invoking func until after wait milliseconds have elapsed since 234 | * the last time the debounced function was invoked. The debounced function comes with a cancel method to 235 | * cancel delayed invocations and a flush method to immediately invoke them. Provide an options object to 236 | * indicate that func should be invoked on the leading and/or trailing edge of the wait timeout. Subsequent 237 | * calls to the debounced function return the result of the last func invocation. 238 | * 239 | * Note: If leading and trailing options are true, func is invoked on the trailing edge of the timeout only 240 | * if the the debounced function is invoked more than once during the wait timeout. 241 | * 242 | * See David Corbacho’s article for details over the differences between _.debounce and _.throttle. 243 | * 244 | * @param func The function to debounce. 245 | * @param wait The number of milliseconds to delay. 246 | * @return Returns the new debounced function. 247 | */ 248 | debounce(wait: number, func: T): T; 249 | 250 | /** 251 | * Creates an object composed of keys generated from the results of running each element of collection through 252 | * iteratee. The corresponding value of each key is an array of the elements responsible for generating the 253 | * key. The iteratee is bound to thisArg and invoked with three arguments: 254 | * (value, index|key, collection). 255 | * 256 | * If a property name is provided for iteratee the created _.property style callback returns the property 257 | * value of the given element. 258 | * 259 | * If a value is also provided for thisArg the created _.matchesProperty style callback returns true for 260 | * elements that have a matching property value, else false. 261 | * 262 | * If an object is provided for iteratee the created _.matches style callback returns true for elements that 263 | * have the properties of the given object, else false. 264 | * 265 | * @param collection The collection to iterate over. 266 | * @param iteratee The function invoked per iteration. 267 | * @param thisArg The this binding of iteratee. 268 | * @return Returns the composed aggregate object. 269 | */ 270 | groupBy(term: string): (collection: List) => T[]; 271 | 272 | /** 273 | * Checks if `path` is a direct property of `object`. 274 | * 275 | * @static 276 | * @memberOf _ 277 | * @category Object 278 | * @param {Array|string} path The path to check. 279 | * @param {Object} object The object to query. 280 | * @returns {boolean} Returns `true` if `path` exists, else `false`. 281 | */ 282 | has(path: string, object: T): boolean; 283 | has(path: string): (object: T) => boolean; 284 | 285 | /** 286 | * Creates an array of the own enumerable property values of object. 287 | * 288 | * @param object The object to query. 289 | * @return Returns an array of property values. 290 | */ 291 | values(object?: Object): T[]; 292 | 293 | /** 294 | * Gets the property value at path of object. If the resolved value is undefined the defaultValue is used 295 | * in its place. 296 | * 297 | * @param path The path of the property to get. 298 | * @param object The object to query. 299 | * @return Returns the resolved value. 300 | */ 301 | get(path: string, object: TObject): TResult; 302 | 303 | /** 304 | * @see fp.get 305 | */ 306 | get(path: string, object: any): TResult; 307 | 308 | /** 309 | * @see fp.get 310 | */ 311 | get(path: string): (object: any) => TResult; 312 | 313 | get(path: string[]): (object: any) => TResult; 314 | 315 | getOr(fallback: TResult, path: string): (object: any) => TResult; 316 | getOr( 317 | fallback: TResult, 318 | path: string 319 | ): (object: TObject) => TResult; 320 | getOr(fallback: TResult, path: string, object: any): TResult; 321 | getOr(fallback: TResult, path: string, object: TObject): TResult; 322 | 323 | /** 324 | * @see fp.method 325 | */ 326 | method(path: string, ...args: any[]): (object: any) => TResult; 327 | 328 | /** 329 | * Returns true if the first parameter is less than the second. 330 | */ 331 | lt(a: number, b: number): boolean; 332 | lt(a: number): (b: number) => boolean; 333 | 334 | /** 335 | * Returns a function, fn, which encapsulates if/else-if/else logic. R.cond takes a list of [predicate, transform] pairs. 336 | * All of the arguments to fn are applied to each of the predicates in turn until one returns a "truthy" value, at which 337 | * point fn returns the result of applying its arguments to the corresponding transformer. If none of the predicates 338 | * matches, fn returns undefined. 339 | */ 340 | cond(fns: [any, any][]): Function; 341 | 342 | /** 343 | * Returns true if its arguments are equivalent, false otherwise. Dispatches to an equals method if present. 344 | * Handles cyclical data structures. 345 | */ 346 | eq(a: T, b: T): boolean; 347 | eq(a: T): (b: T) => boolean; 348 | 349 | /** 350 | * Returns true if the first parameter is greater than the second. 351 | */ 352 | gt(a: number, b: number): boolean; 353 | gt(a: number): (b: number) => boolean; 354 | 355 | /** 356 | * Accepts as its arguments a function and any number of values and returns a function that, 357 | * when invoked, calls the original function with all of the values prepended to the 358 | * original function's arguments list. In some libraries this function is named `applyLeft`. 359 | */ 360 | partial(fn: Function, ...args: any[]): () => {}; 361 | partial(fn: Function): (...args: any[]) => () => {}; 362 | 363 | /** 364 | * Checks if predicate returns truthy for any element of collection. Iteration is stopped once predicate 365 | * returns truthy. The predicate is invoked with three arguments: (value, index|key, collection). 366 | * 367 | * @param predicate The function invoked per iteration. 368 | * @param collection The collection to iterate over. 369 | * @return Returns true if any element passes the predicate check, else false. 370 | */ 371 | some(predicate: ListIterator, collection: List): boolean; 372 | some(predicate: ListIterator): (collection: List) => boolean; 373 | 374 | /** 375 | * Creates a function that returns the result of invoking the provided functions with the this binding of the 376 | * created function, where each successive invocation is supplied the return value of the previous. 377 | * 378 | * @param funcs Functions to invoke. 379 | * @return Returns the new function. 380 | */ 381 | flow(...funcs: Function[]): TResult; 382 | 383 | /** 384 | * Sets the value at path of object. If a portion of path doesn’t exist it’s created. Arrays are created for 385 | * missing index properties while objects are created for all other missing properties. Use _.setWith to 386 | * customize path creation. 387 | * 388 | * @param path The path of the property to set. 389 | * @param value The value to set. 390 | * @param object The object to modify. 391 | * @return Returns object. 392 | */ 393 | set(path: string, value: U, object: T): T; 394 | set(path: string, value: U): (object: T) => T; 395 | set(path: string): (value: U, object: T) => T; 396 | set(path: string): (value: U) => (object: T) => T; 397 | 398 | unset(path: string): (object: any) => TResult; 399 | 400 | /** 401 | * This method invokes interceptor and returns value. The interceptor is bound to thisArg and invoked with one 402 | * argument; (value). The purpose of this method is to "tap into" a method chain in order to perform operations 403 | * on intermediate results within the chain. 404 | * 405 | * @param interceptor The function to invoke. 406 | * @param value The value to provide to interceptor. 407 | * @return Returns value. 408 | **/ 409 | tap(interceptor: (value: T) => void, value: T): T; 410 | tap(interceptor: (value: T) => void): (value: T) => T; 411 | 412 | /** 413 | * Returns a new list, constructed by applying the supplied function to every element of the supplied list. 414 | */ 415 | map(fn: Function, list: T[]): U[]; 416 | map(fn: Function, obj: U): U; 417 | map(fn: Function): (list: T[]) => U[]; 418 | 419 | /** 420 | * Returns a new list containing only one copy of each element in the original list, based upon 421 | * the value returned by applying the supplied function to each list element. Prefers the first 422 | * item if the supplied function produces the same value on two items. R.equals is used for comparison. 423 | */ 424 | uniqBy(fn: (a: T) => U, list: T[]): T[]; 425 | uniqBy(fn: (a: T) => U): (list: T[]) => T[]; 426 | uniqBy(identity: string, list: T[]): T[]; 427 | uniqBy(identity: string): (list: T[]) => T[]; 428 | 429 | /** 430 | * Returns `true` if the specified item is somewhere in the list, `false` otherwise. 431 | * Equivalent to `indexOf(a)(list) > -1`. Uses strict (`===`) equality checking. 432 | */ 433 | includes(value: T, collection: T | T[]): boolean; 434 | includes(value: T): (collection: T | T[]) => boolean; 435 | 436 | /** 437 | * Returns a new list containing only those items that match a given predicate function. The 438 | * predicate function is passed one argument: (value). 439 | */ 440 | filter(fn: (value: T) => boolean): (list: T[]) => T[]; 441 | filter(fn: (value: T) => boolean, list: T[]): T[]; 442 | filter(predicate: string): (collection: string) => string[]; 443 | filter(predicate: W): (collection: List) => T[]; 444 | 445 | /** 446 | * A special placeholder value used to specify "gaps" within curried functions, allowing partial 447 | * application of any combination of arguments, regardless of their positions. 448 | */ 449 | placeholder: any; 450 | 451 | /** 452 | * Turns a named method of an object (or object prototype) into a function that can be 453 | * called directly. Passing the optional `len` parameter restricts the returned function to 454 | * the initial `len` parameters of the method. 455 | * 456 | * The returned function is curried and accepts `len + 1` parameters (or `method.length + 1` 457 | * when `len` is not specified), and the final parameter is the target object. 458 | */ 459 | invokeArgs(name: string, obj: any, len?: number): () => {}; 460 | invokeArgs(name: string): (obj: any, len?: number) => () => {}; 461 | 462 | /** 463 | * Invokes the method at path of object. 464 | * @param object The object to query. 465 | * @param path The path of the method to invoke. 466 | * @param args The arguments to invoke the method with. 467 | **/ 468 | invoke(path: string): (object: TObject) => TResult; 469 | 470 | /** 471 | * Returns true if its arguments are equivalent, false otherwise. Dispatches to an equals method if present. 472 | * Handles cyclical data structures. 473 | */ 474 | isEqual(a: T, b: T): boolean; 475 | isEqual(a: T): (b: T) => boolean; 476 | 477 | /* 478 | * A function that always returns true. Any passed in parameters are ignored. 479 | */ 480 | stubTrue(): boolean; 481 | 482 | /** 483 | * Creates an array of elements, sorted in ascending order by the results of 484 | * running each element in a collection through each iteratee. This method 485 | * performs a stable sort, that is, it preserves the original sort order of 486 | * equal elements. The iteratees are invoked with one argument: (value). 487 | * 488 | * @static 489 | * @memberOf _ 490 | * @category Collection 491 | * @param {Array|Object} collection The collection to iterate over. 492 | * @param {...(Function|Function[]|Object|Object[]|string|string[])} [iteratees=[_.identity]] 493 | * The iteratees to sort by, specified individually or in arrays. 494 | * @returns {Array} Returns the new sorted array. 495 | * @example 496 | * 497 | * var users = [ 498 | * { 'user': 'fred', 'age': 48 }, 499 | * { 'user': 'barney', 'age': 36 }, 500 | * { 'user': 'fred', 'age': 42 }, 501 | * { 'user': 'barney', 'age': 34 } 502 | * ]; 503 | * 504 | * _.sortBy(users, function(o) { return o.user; }); 505 | * // => objects for [['barney', 36], ['barney', 34], ['fred', 48], ['fred', 42]] 506 | * 507 | * _.sortBy(users, ['user', 'age']); 508 | * // => objects for [['barney', 34], ['barney', 36], ['fred', 42], ['fred', 48]] 509 | * 510 | * _.sortBy(users, 'user', function(o) { 511 | * return Math.floor(o.age / 10); 512 | * }); 513 | * // => objects for [['barney', 36], ['barney', 34], ['fred', 48], ['fred', 42]] 514 | */ 515 | sortBy(collection: List, iteratee?: ListIterator): T[]; 516 | 517 | /** 518 | * An alternative to _.reduce; this method transforms object to a new accumulator object which is the result of 519 | * running each of its own enumerable properties through iteratee, with each invocation potentially mutating 520 | * the accumulator object. The iteratee is bound to thisArg and invoked with four arguments: (accumulator, 521 | * value, key, object). Iteratee functions may exit iteration early by explicitly returning false. 522 | * 523 | * @param iteratee The function invoked per iteration. 524 | * @param object The object to iterate over. 525 | * @param accumulator The custom accumulator value. 526 | * @param thisArg The this binding of iteratee. 527 | * @return Returns the accumulated value. 528 | */ 529 | transform(iteratee: T, accumulator: any): (object: T[]) => TResult[]; 530 | 531 | /** 532 | * Checks if value is classified as an Array object. 533 | * @param value The value to check. 534 | * 535 | * @return Returns true if value is correctly classified, else false. 536 | */ 537 | isArray(value?: any): value is T[]; 538 | 539 | /** 540 | * Removes all elements from array that predicate returns truthy for and returns an array of the removed 541 | * elements. The predicate is bound to thisArg and invoked with three arguments: (value, index, array). 542 | * 543 | * If a property name is provided for predicate the created _.property style callback returns the property 544 | * value of the given element. 545 | * 546 | * If a value is also provided for thisArg the created _.matchesProperty style callback returns true for 547 | * elements that have a matching property value, else false. 548 | * 549 | * If an object is provided for predicate the created _.matches style callback returns true for elements that 550 | * have the properties of the given object, else false. 551 | * 552 | * Note: Unlike _.filter, this method mutates array. 553 | * 554 | * @param predicate The function invoked per iteration. 555 | * @return Returns the new array of removed elements. 556 | */ 557 | remove(predicate: Function | Object): (array: List) => T[]; 558 | 559 | /** 560 | * Removes all provided values from array using SameValueZero for equality comparisons. 561 | * 562 | * Note: Unlike _.without, this method mutates array. 563 | * 564 | * @param array The array to modify. 565 | * @param values The values to remove. 566 | * @return Returns array. 567 | */ 568 | pull(array: T[], ...values: T[]): T[]; 569 | 570 | /** 571 | * @see _.pull 572 | */ 573 | pull(array: List, ...values: T[]): List; 574 | 575 | keys(arg: T): (keyof T)[]; 576 | 577 | range(min: number, max: number): number; 578 | 579 | /** 580 | * Recursively flattens a nested array. 581 | * 582 | * @param array The array to recursively flatten. 583 | * @return Returns the new flattened array. 584 | */ 585 | flattenDeep(array: T): T[]; 586 | 587 | /** 588 | * Creates an array of elements split into groups the length of size. If collection can’t be split evenly, the 589 | * final chunk will be the remaining elements. 590 | * 591 | * @param size The length of each chunk. 592 | * @param array The array to process. 593 | * @return Returns the new array containing chunks. 594 | */ 595 | chunk(size: number, array: List): T[][]; 596 | chunk(size: number): (array: List) => T[][]; 597 | 598 | /** 599 | * The opposite of _.mapValues; this method creates an object with the same values as object and keys generated 600 | * by running each own enumerable property of object through iteratee. 601 | * 602 | * @param iteratee The function invoked per iteration. 603 | * @param object The object to iterate over. 604 | * @return Returns the new mapped object. 605 | */ 606 | mapKeys>( 607 | iteratee: (k: keyof T1) => keyof T2, 608 | object: T1 609 | ): T2; 610 | mapKeys>( 611 | iteratee: (k: keyof T1) => keyof T2 612 | ): (object: T1) => T2; 613 | 614 | /** 615 | * Checks if value is empty. A value is considered empty unless it’s an arguments object, array, string, or 616 | * jQuery-like collection with a length greater than 0 or an object with own enumerable properties. 617 | * 618 | * @param value The value to inspect. 619 | * @return Returns true if value is empty, else false. 620 | */ 621 | isEmpty(value?: any): boolean; 622 | 623 | size(value?: any): number; 624 | } 625 | } 626 | 627 | declare let fp: fp.Static; 628 | declare module 'lodash/fp' { 629 | export = fp; 630 | } 631 | -------------------------------------------------------------------------------- /usage-diagram-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AveroLLC/types-first-ui/8de60c54db8a74188ed80dc9bed3e90a1ab977c2/usage-diagram-lg.png -------------------------------------------------------------------------------- /usage-diagram-sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AveroLLC/types-first-ui/8de60c54db8a74188ed80dc9bed3e90a1ab977c2/usage-diagram-sm.png --------------------------------------------------------------------------------