├── .editorconfig ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── TODO.md ├── docs ├── actions.md ├── reducers.md └── redux.md ├── examples └── async-redux │ ├── actions │ ├── PostsActions.js │ ├── SelectionActions.js │ └── index.js │ ├── components │ ├── Picker.js │ └── Posts.js │ ├── containers │ ├── AsyncApp.js │ └── Root.js │ ├── index.html │ ├── index.js │ ├── package.json │ ├── reducers │ ├── index.js │ ├── posts.js │ ├── postsByReddit.js │ └── selectedReddit.js │ ├── server.js │ ├── store │ ├── configureStore.js │ └── index.js │ └── webpack.config.js ├── index.js ├── package.json ├── redux.js └── src ├── callAction.js ├── combineReducers.js ├── connectActionSets.js ├── getConsensus.js ├── index.js ├── isFunction.js ├── notFoundValue.js ├── redux.js ├── sequence.js └── types.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | tab_width = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | lib 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flambeau 2 | Lightweight Redux enhancements with opinions: 3 | 4 | ### Declarative action creators 5 | - **No UPPERCASE_CONSTANTS**. Just use an exported function to name the action, and a destructured object to document the payload. 6 | ```javascript 7 | export function addTodo({ text }) {} // No constants 8 | 9 | export function editTodo({ index, text }) {} // Payload is self-documenting 10 | 11 | export function completeTodo({ index }) { // Payload can be altered, otherwise defaults to the input. 12 | return { index, dateCompleted: new Date() }; 13 | } 14 | ``` 15 | - Namespacing of actions into sets for better organization. 16 | - **Async action support built-in**, with convenient dispatching of other actions. 17 | 18 | ### Reusable reducers 19 | - **No switch statements** to handle actions, just declare functions matching those exported by the action set. 20 | ```javascript 21 | export const TodoListActions = { 22 | addTodo(state, { text }) { 23 | return state.concat({ text }); 24 | }, 25 | 26 | editTodo(state, { index, text }) { 27 | let newState = state.slice(); 28 | newState[index] = { ...newState[index], text }; 29 | return newState; 30 | }, 31 | 32 | completeTodo(state, { index, dateCompleted }) { 33 | let newState = state.slice(); 34 | newState[index] = { ...newState[index], dateCompleted }; 35 | return newState; 36 | } 37 | } 38 | ``` 39 | - **Reusable reducers, using props to customize** the response to actions or initial state. 40 | ```javascript 41 | // Props are passed in as first argument: 42 | export function getInitialState({ initialItems = [] }) { 43 | return { 44 | items: initialItems 45 | }; 46 | } 47 | ``` 48 | - **Forward action sets in bulk** within reducers to allow easy composition of reducers, such as in collections or other hierarchies. 49 | 50 | ### Reducer state encapsulation 51 | - **Introspection methods encapsulate** reducers’ internal state. This removes action creators’ knowledge of the store’s structure, allowing greater code reuse. 52 | - Get a **consensus for async actions**, such as whether something needs loading, by polling reducers using their introspection methods. 53 | 54 | ## Documentation 55 | 56 | - [Actions](docs/actions.md) 57 | - [Reducers](docs/reducers.md) 58 | - [Using with Redux](docs/redux.md) 59 | 60 | ## Installation 61 | 62 | `npm install flambeau --save` 63 | 64 | ## Examples 65 | 66 | - See the [async redux demo example](examples/async-redux) for a full example of introspection and the features of Flambeau. 67 | - The great [flux-comparison](https://github.com/voronianski/flux-comparison) project has a [Redux + Flambeau example](https://github.com/voronianski/flux-comparison/tree/master/redux-flambeau). 68 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## Allow reducers to emit custom events 2 | 3 | Events are global, are not stored anywhere only transmitted, and listened to by 4 | those who wish to subscribe. 5 | 6 | ```javascript 7 | export const events = { 8 | blockCannotJoinWithPrevious({ index }) 9 | }; 10 | 11 | 12 | export function joinBlockWithPrevious(state, { index }, { emit }) { 13 | if (index === 0) { 14 | emit.blockCannotJoinWithPrevious({ index }); 15 | } 16 | 17 | // ... 18 | } 19 | ``` 20 | 21 | ## Declarative way to combine reducers? 22 | 23 | ```javascript 24 | export default { 25 | combine: { 26 | byId, 27 | visibleIds 28 | }, 29 | alsoAdd: (combined) => ({ 30 | 31 | }) 32 | }; 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/actions.md: -------------------------------------------------------------------------------- 1 | # Actions 2 | 3 | ## Action Sets 4 | 5 | Action sets group multiple action creators into one namespace. 6 | 7 | The recommended way to declare an action set is to create a file with the name 8 | of action set, e.g. *TodoListActions.js*. Then declare action creators and 9 | introspection methods, which are detailed below. 10 | 11 | ## Action Creators 12 | 13 | Flambeau’s action creators are declarative. There is no need for 14 | UPPERCASE_CONSTANTS. Simply export a function, with its name identifying the 15 | action creator, and a destructured object (`{ anything, you, like }`) for the 16 | first argument as the payload. 17 | 18 | If the payload is to be used as-is, then the body of the function can be left 19 | empty. 20 | If you would like to add other properties or transform the payload in some 21 | way, then return the customized payload you want. 22 | 23 | e.g. 24 | ```javascript 25 | export function addTodo({ text }) {} // Payload as-is 26 | 27 | export function addTodoWithCurrentDate({ text }) { // Payload transformed 28 | return { 29 | dateCreated: new Date(), 30 | text 31 | }; 32 | } 33 | ``` 34 | 35 | ## Async Action Creators 36 | 37 | Asynchronous action creators allow operations such as loading or saving to be 38 | encapsulated. 39 | These action creators forward to other actions that reducers can listen to. 40 | 41 | To make your action creator asynchronous, simple add a second argument to your action creator with a destructured object 42 | of the following optional properties: 43 | - `currentActionSet`: The action set you are currently declaring within, 44 | allowing you to dispatch sibling actions. Maps action identifiers to each action’s 45 | dispatcher function. e.g. `currentActionSet.otherAction(payload)` 46 | - `allActionSets`: All the action sets, mapping action set identifiers to their dispatcher functions. e.g. `allActionSets.OtherActionSet.someOtherAction(payload)` 47 | 48 | ```javascript 49 | import fetch from 'isomorphic-fetch'; 50 | 51 | export function addTodo({ text }) {} 52 | export function addTodosFromURL({ items, URL }) {} 53 | 54 | export function importTodosFromURL({ URL }, { currentActionSet, allActionSets }) { 55 | fetch(URL) 56 | .then(response => response.json()) 57 | .then(items => currentActionSet.addTodosFromURL({ items, URL })); 58 | } 59 | ``` 60 | 61 | ## Introspection 62 | 63 | Flambeau allows action creators to query reducers with the concept of 64 | 'introspection', which is similar to interfaces or protocols in other 65 | programming languages. 66 | 67 | Reducers implement introspection methods, returning results from within its 68 | state. This completely encapsulates the specifics of the state’s structure. 69 | Unlike the use of Redux’s `getState()`, action creators have no knowledge 70 | of the state tree. 71 | 72 | Declare introspection methods underneath your exported actions: 73 | ```javascript 74 | export const introspection = { 75 | hasImportedFromURL({ URL }) {}, 76 | hasTodoContainingText({ text }) {} 77 | }; 78 | ``` 79 | 80 | Introspection is explained in more detail and with examples in the 81 | [reducers](reducers.md#introspection) 82 | section. 83 | 84 | --- 85 | 86 | **[Actions](actions.md)** 87 | · 88 | [Reducers](reducers.md) 89 | · 90 | [Using with Redux](redux.md) 91 | -------------------------------------------------------------------------------- /docs/reducers.md: -------------------------------------------------------------------------------- 1 | # Reducers 2 | 3 | ## Props 4 | 5 | Reducers in Flambeau are differentiated from the standard Redux reducer by a 6 | number of modest features. One of these is the addition of props for reducers. 7 | 8 | These props allow both the initial state and the state transformation during an 9 | action to be customized. Like React’s props, their use is encouraged to enable 10 | reducers to be less hard-coded and more reusable. 11 | 12 | ## Initial State 13 | 14 | The initial state in Flambeau is determined by a declaration of the exported 15 | function `getInitialState`. The props are passed as the first argument. 16 | 17 | ```javascript 18 | export function getInitialState({ initialItems = [] }) { 19 | return { 20 | items: initialItems 21 | }; 22 | } 23 | ``` 24 | 25 | ## Implementing Action Responders 26 | 27 | Responding to actions is done in a similar manner to action creators: by 28 | declaring functions. The name of the function mirrors that of the action 29 | creator. The functions are declared in a vanilla JavaScript object, named the 30 | same as the action set. 31 | 32 | The only difference from action creators is the current state is passed as the 33 | first argument. 34 | 35 | ```javascript 36 | export const TodoListActions = { 37 | addTodo(state, { text }) { 38 | return state.concat({ text }); 39 | } 40 | } 41 | ``` 42 | 43 | ## Composing 44 | 45 | Reducers can be easily composed within each other. This allows you to break your 46 | reducers into multiple pieces. 47 | 48 | Forwarding actions is possible, especially easily done in bulk per action set. 49 | To forward an action, declare an action set as a function instead of an object. 50 | When an action from this set is dispatched, this function will be called with 51 | the following parameters. 52 | - `isAction`: when the payload being dispatched is a standard action. 53 | - `isIntrospection`: when introspection into reducers is being requested. 54 | - `actionID`: the identifier of the action or introspection method. 55 | - `payload`: the payload being dispatched. 56 | - `props`: the props of this particular reducer. 57 | - `forwardTo()`: Call this to use another reducer on a subset of your state. 58 | 59 | ```javascript 60 | // TodoItemActions.js 61 | 62 | export function changeText({ text, index }) {} 63 | export function changeCompleted({ isCompleted, index }) {} 64 | ``` 65 | 66 | ```javascript 67 | // TodoListReducer.js 68 | import TodoItemReducer from './TodoItemReducer'; 69 | 70 | export function getInitialState() { 71 | return { 72 | items: [] 73 | }; 74 | } 75 | 76 | export function TodoItemActions(state, { isAction, isIntrospection, payload, props, forwardTo }) { 77 | if (isAction) { 78 | const { index } = payload; 79 | state = Object.assign({}, state, { 80 | items: state.items.slice() // Make a copy of the entire array 81 | }); 82 | state.items[index] = forwardTo({ responder: TodoItemReducer, initialState: state.items[index] }); 83 | 84 | return state; 85 | } 86 | } 87 | ``` 88 | 89 | ```javascript 90 | // TodoItemReducer.js 91 | 92 | export const TodoItemActions = { 93 | changeText(item, { text, index }) { 94 | return Object.assign({}, item, { text }); 95 | }, 96 | 97 | changeCompleted(item, { isCompleted, index }) { 98 | return Object.assign({}, item, { isCompleted }); 99 | } 100 | } 101 | ``` 102 | 103 | ## Introspection 104 | 105 | An application will often need different actions to be dispatched depending on the 106 | store’s state. Differing from the normal method of directly checking the store 107 | (`getState()` in Redux), Flambeau introduces *introspection* methods, which 108 | allow reducers to completely encapsulate its state from the outside world. 109 | 110 | Say a todo list allows importing items from a URL online. The import action 111 | creator may want to only load data if it hasn’t been done already. Because 112 | action creators are stateless, this bit of information will be stored by a 113 | reducer somewhere. 114 | 115 | Introspection methods allow a reducer to declare its preference, say whether to 116 | load a URL or not, whilst leaving the implementation details of the store’s 117 | state hidden from the action creator. 118 | 119 | ```javascript 120 | // TodoListActions.js 121 | import fetch from 'isomorphic-fetch'; 122 | 123 | export function addTodosFromURL({ items, URL }) {} 124 | 125 | function importTodosFromURL({ URL }, { currentActionSet }) { 126 | fetch(URL) 127 | .then(response => response.json()) 128 | .then(items => currentActionSet.addTodosFromURL({ items, URL })); 129 | } 130 | 131 | export function importTodosFromURLIfNeeded({ URL }, { currentActionSet }) { 132 | if (!currentActionSet.consensus.hasImportedFromURL({ URL }).every()) { 133 | // This function is not exported as a public action, instead used directly. 134 | importTodosFromURL({ URL }, { currentActionSet }); 135 | } 136 | } 137 | 138 | export const introspection = { 139 | hasImportedFromURL({ URL }) {} 140 | }; 141 | ``` 142 | 143 | ```javascript 144 | // TodoListReducer.js 145 | export function getInitialState({ initialItems = [] }) { 146 | return { 147 | items: initialItems, 148 | importedURLs: {} 149 | }; 150 | } 151 | 152 | export const TodoListActions = { 153 | addTodosFromURL(state, { items, URL }) { 154 | // Other reducers might have returned false from hasImportedFromURL() 155 | if (state.importedURLs[URL]) { 156 | return; 157 | } 158 | 159 | return Object.assign({}, state, { 160 | importedURLs: Object.assign({}, state.importedURLs, { [URL]: true }), 161 | items: state.items.concat(items) 162 | }); 163 | }, 164 | 165 | introspection: { 166 | hasImportedFromURL(state, { URL }) { 167 | return Boolean( 168 | state.importedURLs[URL] 169 | ); 170 | } 171 | } 172 | }; 173 | ``` 174 | 175 | ## Introspection Consensus 176 | 177 | The `consensus` property is part of every connected action set, including 178 | those passed to action creators (`currentActionSet` and `allActionSets`), that 179 | have introspection methods. 180 | 181 | To use it, append your introspection identifier, and call it with the payload to 182 | pass to each reducer’s introspection method. Then call one of the following 183 | methods: 184 | 185 | - `some([callback])`: like `Array.some`, returns true if callback returns true for any reducer’s 186 | result. 187 | If a callback is not passed, then the result is treated as a boolean. 188 | - `every([callback])`: like `Array.every`, returns true if callback returns true for every reducer’s 189 | result. 190 | If a callback is not passed, then the result is treated as a boolean. 191 | - `singleton()`: expects there to be only one reducer, returning its result. 192 | Throws an exception if zero or more than one reducer responded. 193 | - `reduce(callback[, initialValue])`: like `Array.reduce`, combines every 194 | reducer’s result using a callback passed the combined result so far, and the 195 | currently iterated reducer’s result for the introspection method. 196 | - `toArray()`: returns an array of results of every reducer for this 197 | introspection method. 198 | 199 | ```javascript 200 | if (currentActionSet.consensus.yourIntrospectionID({ 201 | yourPayloadProperties: true 202 | }).some()) { 203 | // If any reducer returned true. 204 | } 205 | ``` 206 | 207 | ```javascript 208 | if (currentActionSet.consensus.yourIntrospectionID({ 209 | yourPayloadProperties: true 210 | }).every()) { 211 | // If all reducers returned true. 212 | } 213 | ``` 214 | 215 | ```javascript 216 | const currentActionSet.consensus.yourIntrospectionID({ 217 | yourPayloadProperties: true 218 | }).singleton(); 219 | // A single chosen reducer returned a value. 220 | // Throws if no or multiple reducers returned a result. 221 | // Great for configuration variables. 222 | ``` 223 | 224 | ```javascript 225 | const combinedResult = currentActionSet.consensus.yourIntrospectionID({ 226 | yourPayloadProperties: true 227 | }).reduce((combined, current) => { 228 | // Reduce `combined` and `current` 229 | return combined + current; 230 | }, /* optional initialValue */ 0); 231 | ``` 232 | 233 | --- 234 | 235 | [Actions](actions.md) 236 | · 237 | **[Reducers](reducers.md)** 238 | · 239 | [Using with Redux](redux.md) 240 | -------------------------------------------------------------------------------- /docs/redux.md: -------------------------------------------------------------------------------- 1 | # Redux Support 2 | 3 | Flambeau is built to be composable, in a style inspired from Redux. 4 | 5 | Because Flambeau’s action creators and reducers are declarative, you don’t need 6 | to import Flambeau into their files. The only thing you need to import is 7 | 'flambeau/redux' into your Redux store. 8 | 9 | Two functions are provided: 10 | - `createRootReducer({ reducers, idToProps })`: Creates a Redux compatible 11 | reducer. Pass your props, mapping the reducer ID to specific props. 12 | - `connectActionSetsToStore()`: Connects your action sets to the Redux store. It 13 | is recommended that you export this object, to be used within your controller 14 | components (in React). 15 | 16 | Here is an example of integrating Redux with Flambeau: 17 | 18 | ```javascript 19 | import { createStore, applyMiddleware } from 'redux'; 20 | import { createRootReducer, connectActionSetsToStore } from 'flambeau/redux'; 21 | import actionSets from '../actions'; 22 | import reducers, { idToProps } from '../reducers'; 23 | 24 | const createStoreWithMiddleware = applyMiddleware( 25 | // All your favorite Redux middleware 26 | )(createStore); 27 | 28 | const rootReducer = createRootReducer({ reducers, idToProps }); 29 | export const store = createStoreWithMiddleware(rootReducer, initialState); 30 | export const connectedActionSets = connectActionSetsToStore({ actionSets, store }); 31 | ``` 32 | 33 | It is expected that `actions` and `reducers` would be directories exporting all 34 | action sets and reducers, like the following: 35 | 36 | ```javascript 37 | // actions/index.js 38 | 39 | import * as TodoItemActions from './TodoItemActions'; 40 | import * as TodoListActions from './TodoListActions'; 41 | 42 | export default { 43 | TodoItemActions, 44 | TodoListActions 45 | }; 46 | ``` 47 | 48 | ```javascript 49 | // reducers/index.js 50 | 51 | import * as TodoListReducer from './TodoListReducer'; 52 | 53 | export default { 54 | list: TodoListReducer 55 | }; 56 | 57 | export const idToProps = { 58 | list: { 59 | initialItems: [ 60 | { 61 | text: 'Read Flambeau documentation', 62 | isCompleted: true 63 | } 64 | ] 65 | } 66 | } 67 | ``` 68 | 69 | --- 70 | 71 | [Actions](actions.md) 72 | · 73 | [Reducers](reducers.md) 74 | · 75 | **[Using with Redux](redux.md)** 76 | -------------------------------------------------------------------------------- /examples/async-redux/actions/PostsActions.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch'; 2 | 3 | 4 | export function invalidateReddit({ reddit }) {} 5 | 6 | export function requestPosts({ reddit }) {} 7 | 8 | export function receivePosts({ reddit, json }) { 9 | return { 10 | reddit, 11 | posts: json.data.children.map(child => child.data), 12 | receivedAt: Date.now() 13 | }; 14 | } 15 | 16 | function fetchPosts({ reddit }, { currentActionSet }) { 17 | currentActionSet.requestPosts({ reddit }); 18 | 19 | fetch(`http://www.reddit.com/r/${reddit}.json`) 20 | .then(response => response.json()) 21 | .then(json => currentActionSet.receivePosts({ reddit, json })) 22 | ; 23 | } 24 | 25 | export const introspection = { 26 | shouldFetchPosts({ reddit }) {} 27 | } 28 | 29 | export function fetchPostsIfNeeded({ reddit }, { currentActionSet }) { 30 | if (currentActionSet.consensus.shouldFetchPosts({ reddit }).some()) { 31 | fetchPosts({ reddit }, { currentActionSet }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/async-redux/actions/SelectionActions.js: -------------------------------------------------------------------------------- 1 | export function selectReddit({ reddit }) { 2 | return arguments[0]; 3 | } 4 | -------------------------------------------------------------------------------- /examples/async-redux/actions/index.js: -------------------------------------------------------------------------------- 1 | import * as PostsActions from './PostsActions'; 2 | import * as SelectionActions from './SelectionActions'; 3 | 4 | export default { 5 | PostsActions, 6 | SelectionActions 7 | }; 8 | -------------------------------------------------------------------------------- /examples/async-redux/components/Picker.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | export default class Picker extends Component { 4 | render () { 5 | const { value, onChange, options } = this.props; 6 | 7 | return ( 8 | 9 |

{value}

10 | 18 |
19 | ); 20 | } 21 | } 22 | 23 | Picker.propTypes = { 24 | options: PropTypes.arrayOf( 25 | PropTypes.string.isRequired 26 | ).isRequired, 27 | value: PropTypes.string.isRequired, 28 | onChange: PropTypes.func.isRequired 29 | }; -------------------------------------------------------------------------------- /examples/async-redux/components/Posts.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes, Component } from 'react'; 2 | 3 | export default class Posts extends Component { 4 | render () { 5 | return ( 6 | 11 | ); 12 | } 13 | } 14 | 15 | Posts.propTypes = { 16 | posts: PropTypes.array.isRequired 17 | }; 18 | -------------------------------------------------------------------------------- /examples/async-redux/containers/AsyncApp.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connectedActionSets } from '../store'; 3 | import Picker from '../components/Picker'; 4 | import Posts from '../components/Posts'; 5 | 6 | const { selectReddit } = connectedActionSets.SelectionActions; 7 | const { fetchPostsIfNeeded, invalidateReddit } = connectedActionSets.PostsActions; 8 | 9 | class AsyncApp extends Component { 10 | constructor(props) { 11 | super(props); 12 | this.handleChange = this.handleChange.bind(this); 13 | this.handleRefreshClick = this.handleRefreshClick.bind(this); 14 | } 15 | 16 | componentDidMount() { 17 | const { selectedReddit } = this.props; 18 | fetchPostsIfNeeded({ reddit: selectedReddit }); 19 | } 20 | 21 | componentWillReceiveProps(nextProps) { 22 | if (nextProps.selectedReddit !== this.props.selectedReddit) { 23 | const { selectedReddit } = nextProps; 24 | fetchPostsIfNeeded({ reddit: selectedReddit }); 25 | } 26 | } 27 | 28 | handleChange(nextReddit) { 29 | selectReddit({ reddit: nextReddit }); 30 | } 31 | 32 | handleRefreshClick(e) { 33 | e.preventDefault(); 34 | 35 | const { selectedReddit } = this.props; 36 | invalidateReddit({ reddit: selectedReddit }); 37 | fetchPostsIfNeeded({ reddit: selectedReddit }); 38 | } 39 | 40 | render () { 41 | const { selectedReddit, posts, isFetching, lastUpdated } = this.props; 42 | return ( 43 |
44 | 47 |

48 | {lastUpdated && 49 | 50 | Last updated at {new Date(lastUpdated).toLocaleTimeString()}. 51 | {' '} 52 | 53 | } 54 | {!isFetching && 55 | 57 | Refresh 58 | 59 | } 60 |

61 | {isFetching && posts.length === 0 && 62 |

Loading...

63 | } 64 | {!isFetching && posts.length === 0 && 65 |

Empty.

66 | } 67 | {posts.length > 0 && 68 |
69 | 70 |
71 | } 72 |
73 | ); 74 | } 75 | } 76 | 77 | AsyncApp.propTypes = { 78 | selectedReddit: PropTypes.string.isRequired, 79 | posts: PropTypes.array.isRequired, 80 | isFetching: PropTypes.bool.isRequired, 81 | lastUpdated: PropTypes.number 82 | }; 83 | 84 | export default AsyncApp; 85 | //export default connect(mapStateToProps)(AsyncApp); 86 | -------------------------------------------------------------------------------- /examples/async-redux/containers/Root.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import App from './AsyncApp'; 4 | 5 | 6 | export function mapStateToProps({ selectedReddit, postsByReddit }) { 7 | const { 8 | isFetching, 9 | lastUpdated, 10 | items: posts 11 | } = postsByReddit[selectedReddit] || { 12 | isFetching: true, 13 | items: [] 14 | }; 15 | 16 | return { 17 | selectedReddit, 18 | posts, 19 | isFetching, 20 | lastUpdated 21 | }; 22 | } 23 | 24 | export default connect(mapStateToProps)(App); 25 | -------------------------------------------------------------------------------- /examples/async-redux/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redux async example 5 | 6 | 7 |
8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/async-redux/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-core/polyfill'; 2 | 3 | import React from 'react'; 4 | import { Provider } from 'react-redux'; 5 | import Root from './containers/Root'; 6 | import { store } from './store'; 7 | 8 | React.render( 9 | 10 | {() => } 11 | , 12 | document.getElementById('root') 13 | ); 14 | -------------------------------------------------------------------------------- /examples/async-redux/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-async-example", 3 | "version": "0.0.0", 4 | "description": "Redux async example", 5 | "scripts": { 6 | "start": "node server.js" 7 | }, 8 | "license": "MIT", 9 | "dependencies": { 10 | "isomorphic-fetch": "^2.1.1", 11 | "react": "^0.13.3", 12 | "react-redux": "^2.1.2", 13 | "redux": "^3.0.0", 14 | "redux-logger": "0.0.3" 15 | }, 16 | "devDependencies": { 17 | "babel-core": "^5.6.18", 18 | "babel-loader": "^5.1.4", 19 | "expect": "^1.6.0", 20 | "jsdom": "^5.6.1", 21 | "mocha": "^2.2.5", 22 | "mocha-jsdom": "^1.0.0", 23 | "node-libs-browser": "^0.5.2", 24 | "react-hot-loader": "^1.3.0", 25 | "webpack": "^1.9.11", 26 | "webpack-dev-server": "^1.9.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/async-redux/reducers/index.js: -------------------------------------------------------------------------------- 1 | import * as postsByReddit from './postsByReddit'; 2 | import * as selectedReddit from './selectedReddit'; 3 | 4 | export default { 5 | postsByReddit, 6 | selectedReddit 7 | }; 8 | -------------------------------------------------------------------------------- /examples/async-redux/reducers/posts.js: -------------------------------------------------------------------------------- 1 | export function getInitialState() { 2 | return { 3 | isFetching: false, 4 | didInvalidate: true, 5 | items: [] 6 | }; 7 | } 8 | 9 | export const PostsActions = { 10 | invalidateReddit(state, { reddit }) { 11 | return Object.assign({}, state, { 12 | didInvalidate: true 13 | }); 14 | }, 15 | 16 | requestPosts(state, { reddit }) { 17 | return Object.assign({}, state, { 18 | isFetching: true, 19 | didInvalidate: false 20 | }); 21 | }, 22 | 23 | receivePosts(state, { reddit, posts, receivedAt }) { 24 | return Object.assign({}, state, { 25 | isFetching: false, 26 | didInvalidate: false, 27 | items: posts, 28 | lastUpdated: receivedAt 29 | }); 30 | }, 31 | 32 | introspection: { 33 | shouldFetchPosts(state, { reddit }) { 34 | if (!state) { 35 | return true; 36 | } else if (state.isFetching) { 37 | return false; 38 | } else { 39 | return state.didInvalidate; 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/async-redux/reducers/postsByReddit.js: -------------------------------------------------------------------------------- 1 | import * as posts from './posts'; 2 | 3 | 4 | export function getInitialState() { 5 | return {}; 6 | } 7 | 8 | export function PostsActions(state, { isAction, isIntrospection, actionID, payload, forwardTo }) { 9 | if (isAction) { 10 | return Object.assign({}, state, { 11 | [payload.reddit]: forwardTo({ responder: posts, initialState: state[payload.reddit] || posts.getInitialState() }) 12 | }); 13 | } 14 | else if (isIntrospection) { 15 | return forwardTo({ responder: posts, initialState: state[payload.reddit] || posts.getInitialState() }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/async-redux/reducers/selectedReddit.js: -------------------------------------------------------------------------------- 1 | export function getInitialState({ defaultReddit }) { 2 | return defaultReddit; 3 | } 4 | 5 | export const SelectionActions = { 6 | selectReddit(state, { reddit }) { 7 | return reddit; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/async-redux/server.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var WebpackDevServer = require('webpack-dev-server'); 3 | var config = require('./webpack.config'); 4 | 5 | new WebpackDevServer(webpack(config), { 6 | publicPath: config.output.publicPath, 7 | hot: true, 8 | historyApiFallback: true, 9 | stats: { 10 | colors: true 11 | } 12 | }).listen(3000, 'localhost', function (err) { 13 | if (err) { 14 | console.log(err); 15 | } 16 | 17 | console.log('Listening at localhost:3000'); 18 | }); 19 | -------------------------------------------------------------------------------- /examples/async-redux/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import loggerMiddleware from 'redux-logger'; 3 | import { createRootReducer, connectActionSetsToStore } from 'flambeau/redux'; 4 | import actionSets from '../actions'; 5 | import reducers from '../reducers'; 6 | 7 | 8 | const createStoreWithMiddleware = applyMiddleware( 9 | loggerMiddleware 10 | )(createStore); 11 | 12 | export default function configureStore(initialState) { 13 | const idToProps = { 14 | selectedReddit: { 15 | defaultReddit: 'reactjs' 16 | } 17 | }; 18 | 19 | const rootReducer = createRootReducer({ reducers, idToProps }); 20 | const store = createStoreWithMiddleware(rootReducer, initialState); 21 | 22 | if (module.hot) { 23 | // Enable Webpack hot module replacement for reducers 24 | module.hot.accept('../reducers', () => { 25 | const reducers = require('../reducers'); 26 | store.replaceReducer(createRootReducer({ reducers, idToProps })); 27 | }); 28 | } 29 | 30 | const connectedActionSets = connectActionSetsToStore({ actionSets, store }); 31 | 32 | return { 33 | store, 34 | connectedActionSets 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /examples/async-redux/store/index.js: -------------------------------------------------------------------------------- 1 | import configureStore from './configureStore'; 2 | 3 | const { store, connectedActionSets } = configureStore(); 4 | 5 | export { store as store } 6 | export { connectedActionSets as connectedActionSets } 7 | -------------------------------------------------------------------------------- /examples/async-redux/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'cheap-module-eval-source-map', 6 | entry: [ 7 | 'webpack-dev-server/client?http://localhost:3000', 8 | 'webpack/hot/only-dev-server', 9 | './index' 10 | ], 11 | output: { 12 | path: path.join(__dirname, 'dist'), 13 | filename: 'bundle.js', 14 | publicPath: '/static/' 15 | }, 16 | plugins: [ 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin() 19 | ], 20 | resolve: { 21 | alias: { 22 | 'flambeau': path.join(__dirname, '..', '..') 23 | }, 24 | extensions: ['', '.js'] 25 | }, 26 | module: { 27 | loaders: [{ 28 | test: /\.js$/, 29 | loaders: ['react-hot', 'babel'], 30 | exclude: /node_modules/, 31 | include: __dirname 32 | }, /*{ 33 | test: /\.js$/, 34 | loaders: ['babel'], 35 | include: path.join(__dirname, '..', '..', 'lib') 36 | }*/] 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flambeau", 3 | "version": "0.7.0", 4 | "description": "Lightweight flux library", 5 | "scripts": { 6 | "build": "babel src --out-dir lib", 7 | "clean": "rimraf lib", 8 | "prepublish": "npm run clean && npm run build" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/BurntCaramel/flambeau.git" 13 | }, 14 | "keywords": [ 15 | "flux", 16 | "react", 17 | "immutable", 18 | "reducer", 19 | "redux" 20 | ], 21 | "author": "Patrick Smith", 22 | "license": "Apache-2.0", 23 | "bugs": { 24 | "url": "https://github.com/BurntCaramel/flambeau/issues" 25 | }, 26 | "homepage": "https://github.com/BurntCaramel/flambeau#readme", 27 | "devDependencies": { 28 | "babel": "^5.8.23", 29 | "rimraf": "^2.4.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /redux.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/redux'); 2 | -------------------------------------------------------------------------------- /src/callAction.js: -------------------------------------------------------------------------------- 1 | import isFunction from './isFunction'; 2 | import { ACTION_TYPE, INTROSPECTION_TYPE, INTROSPECTION_PROPERTY } from './types'; 3 | 4 | 5 | export default function callAction({ responder, type, initialState, actionSetID, actionID, payload, notFoundValue, props, sourceResponder }) { 6 | const responderFunction = findActionResponder({ responder, type, actionSetID, actionID, notFoundValue }); 7 | if (responderFunction !== notFoundValue) { 8 | return responderFunction(initialState, payload, { props, sourceResponder }); 9 | } 10 | else { 11 | return notFoundValue; 12 | } 13 | } 14 | 15 | 16 | function findActionResponder({ responder, type, actionSetID, actionID, notFoundValue }) { 17 | if (responder[actionSetID]) { 18 | // Has forwarding function for entire type 19 | if (isFunction(responder[actionSetID])) { 20 | return (initialState, payload, { props }) => { 21 | function forwardTo({ responder, initialState, props = {}, sourceResponder }) { 22 | let responseNotFoundValue; 23 | if (type === ACTION_TYPE) { 24 | responseNotFoundValue = initialState; 25 | } 26 | 27 | return callAction({ 28 | notFoundValue: responseNotFoundValue, 29 | responder, type, initialState, actionSetID, actionID, payload, 30 | props, sourceResponder 31 | }); 32 | } 33 | 34 | const result = responder[actionSetID](initialState, { 35 | isAction: (type === ACTION_TYPE), 36 | isIntrospection: (type === INTROSPECTION_TYPE), 37 | type, actionID, payload, props, forwardTo 38 | }); 39 | if (typeof result === 'undefined') { 40 | return notFoundValue; 41 | } 42 | else { 43 | return result; 44 | } 45 | }; 46 | } 47 | else { 48 | let typeResponder; 49 | // Actions are direct children 50 | if (type === ACTION_TYPE) { 51 | typeResponder = responder[actionSetID]; 52 | } 53 | // Others are grouped, such as introspection 54 | else if (type === INTROSPECTION_TYPE) { 55 | typeResponder = responder[actionSetID][INTROSPECTION_PROPERTY]; 56 | } 57 | else { 58 | return notFoundValue; 59 | } 60 | 61 | if (typeResponder[actionID]) { 62 | return typeResponder[actionID]; 63 | } 64 | } 65 | } 66 | 67 | return notFoundValue; 68 | } 69 | -------------------------------------------------------------------------------- /src/combineReducers.js: -------------------------------------------------------------------------------- 1 | import { GET_INITIAL_STATE } from './types'; 2 | 3 | 4 | /** 5 | * Combines several Flambeau reducers into one, taking care of introspection 6 | * 7 | * @param {Object} reducers The reducers to be combined together, using the keys to inform the state’s structure 8 | * @param {-> Object} getIDToProps(props) A function returning the props for each individual reducer. It is passed the combined reducer’s props. 9 | * @return {Flambeau Reducer} A new reducer combining those that were passed 10 | */ 11 | export default function combineReducers(reducers, { getIDToProps = () => ({}), alsoAdd } = {}) { 12 | let alsoResponder; 13 | const reducerIDs = Object.keys(reducers); 14 | 15 | let combinedReducer = { 16 | [GET_INITIAL_STATE]: (masterProps) => { 17 | const propsByID = getIDToProps(masterProps); 18 | return reducerIDs.reduce((state, reducerID) => { 19 | const reducer = reducers[reducerID]; 20 | if (reducer[GET_INITIAL_STATE]) { 21 | state[reducerID] = reducer[GET_INITIAL_STATE](propsByID[reducerID]); 22 | } 23 | return state; 24 | }, {}); 25 | } 26 | }; 27 | 28 | const actionSetHandlers = reducerIDs.reduce((actionSetHandlers, reducerID) => { 29 | const reducer = reducers[reducerID]; 30 | Object.keys(reducer).reduce((actionSetHandlers, property) => { 31 | if (property === GET_INITIAL_STATE || !!actionSetHandlers[property]) { 32 | return actionSetHandlers; 33 | } 34 | 35 | actionSetHandlers[property] = (initialState, { isAction, isIntrospection, props, forwardTo }) => { 36 | const propsByID = getIDToProps(props); 37 | 38 | function forwardToReducerWithID(reducerID) { 39 | return forwardTo({ 40 | responder: reducers[reducerID], 41 | initialState: initialState[reducerID], 42 | props: propsByID[reducerID] 43 | }); 44 | } 45 | 46 | function forwardToAlsoResponder(initialState) { 47 | return forwardTo({ 48 | responder: alsoResponder, 49 | initialState, 50 | props, 51 | sourceResponder: combinedReducer 52 | }); 53 | } 54 | 55 | if (isAction) { 56 | let newState = reducerIDs.reduce((state, reducerID) => { 57 | state[reducerID] = forwardToReducerWithID(reducerID); 58 | return state; 59 | }, {}); 60 | 61 | if (alsoResponder) { 62 | newState = forwardToAlsoResponder(newState); 63 | } 64 | 65 | return newState; 66 | } 67 | else if (isIntrospection) { 68 | if (alsoResponder) { 69 | // Use customized response, if one was given. 70 | const response = forwardToAlsoResponder(initialState); 71 | if (response) { 72 | // Ensure is array. 73 | return [].concat(response); 74 | } 75 | } 76 | 77 | // Use first reducer that responds. 78 | return reducerIDs.reduce((responses, reducerID) => { 79 | const response = forwardToReducerWithID(reducerID); 80 | if (typeof response === 'undefined') { 81 | return responses; 82 | } 83 | else { 84 | // Appends the response. 85 | // Response types of array are shallow flat mapped. 86 | return responses.concat(response); 87 | } 88 | }, []); 89 | } 90 | } 91 | 92 | return actionSetHandlers; 93 | }, actionSetHandlers); 94 | 95 | return actionSetHandlers; 96 | }, {}); 97 | 98 | Object.assign(combinedReducer, actionSetHandlers); 99 | 100 | if (alsoAdd) { 101 | alsoResponder = alsoAdd(combinedReducer); 102 | } 103 | 104 | return combinedReducer; 105 | } 106 | -------------------------------------------------------------------------------- /src/connectActionSets.js: -------------------------------------------------------------------------------- 1 | import { ACTION_TYPE, INTROSPECTION_PROPERTY } from './types'; 2 | 3 | /** 4 | * Get a set of actions, connected to this store ready to dispatch to the reducers. 5 | * 6 | * @param {String} actionSetID The unique identifier for the action set 7 | * @return {Object} The connected action functions 8 | */ 9 | function getConnectedActionSet({ actionSet, actionSetID, getAllConnectedActionSets, dispatch, getConsensusForIntrospection }) { 10 | let connectedActionSet = {}; 11 | 12 | Object.keys(actionSet).forEach(actionID => { 13 | if (actionID === INTROSPECTION_PROPERTY) { 14 | const introspectionIDs = Object.keys(actionSet[INTROSPECTION_PROPERTY]); 15 | connectedActionSet.getConsensus = connectedActionSet.consensus = introspectionIDs.reduce((consensusFunctions, introspectionID) => { 16 | consensusFunctions[introspectionID] = getConsensusForIntrospection(introspectionID); 17 | return consensusFunctions; 18 | }, {}); 19 | return; 20 | } 21 | 22 | const sourceActionFunction = actionSet[actionID]; 23 | 24 | connectedActionSet[actionID] = (payload = {}) => { 25 | // Synchronous, immediately dispatched 26 | if (sourceActionFunction.length <= 1) { 27 | const result = sourceActionFunction(payload); 28 | // Allow empty action declarations 29 | if (typeof result !== 'undefined') { 30 | payload = result; 31 | } 32 | return dispatch({ type: ACTION_TYPE, actionSetID, actionID, payload }); 33 | } 34 | // Asychronous, delegates the dispatching 35 | else { 36 | return sourceActionFunction(payload, { 37 | currentActionSet: connectedActionSet, 38 | allActionSets: getAllConnectedActionSets() 39 | }); 40 | } 41 | }; 42 | }); 43 | 44 | return connectedActionSet; 45 | } 46 | 47 | /** 48 | * Get multiple actions sets, connected to this store ready to dispatch to the reducers. 49 | * 50 | * @param {Array} actionSetIDs The unique identifiers of the action sets. 51 | * @return {Object} The action set identifiers to connected action function sets. 52 | */ 53 | export default function connectActionSets({ actionSets, dispatch, getConsensusForActionSet }) { 54 | return Object.keys(actionSets).reduce((connectedActionSets, actionSetID) => { 55 | connectedActionSets[actionSetID] = getConnectedActionSet({ 56 | actionSet: actionSets[actionSetID], 57 | actionSetID, 58 | getAllConnectedActionSets() { 59 | return connectedActionSets 60 | }, 61 | dispatch, 62 | getConsensusForIntrospection: getConsensusForActionSet(actionSetID) 63 | }); 64 | 65 | return connectedActionSets; 66 | }, {}); 67 | } 68 | -------------------------------------------------------------------------------- /src/getConsensus.js: -------------------------------------------------------------------------------- 1 | import callAction from './callAction'; 2 | import { INTROSPECTION_TYPE } from './types'; 3 | import notFoundValue from './notFoundValue'; 4 | 5 | export function consensusForResults(allResults) { 6 | return { 7 | some(callback = Boolean) { 8 | return allResults.some(callback); 9 | }, 10 | 11 | every(callback = Boolean) { 12 | return allResults.every(callback); 13 | }, 14 | 15 | reduce(callback, initialValue) { 16 | if (allResults.length === 0 && (typeof initialValue === 'undefined')) { 17 | // Do not allow TypeError to be thrown. 18 | return; 19 | } 20 | 21 | return allResults.reduce(callback, initialValue); 22 | }, 23 | 24 | singleton() { 25 | if (allResults.length === 1) { 26 | return allResults[0]; 27 | } 28 | else { 29 | throw ( 30 | "`.singleton()` requires that only one reducer responds to this introspection method. " 31 | + `${allResults.length} actually responded.` 32 | ); 33 | } 34 | }, 35 | 36 | toArray() { 37 | return allResults 38 | } 39 | }; 40 | } 41 | 42 | export default function getConsensus({ responder, props, state, actionSetID, introspectionID, payload = {} }) { 43 | let allResults = callAction({ 44 | responder, 45 | type: INTROSPECTION_TYPE, 46 | initialState: state, 47 | props, 48 | actionID: introspectionID, 49 | actionSetID, 50 | payload, 51 | notFoundValue: [] 52 | }); 53 | 54 | // Ensure is an array 55 | allResults = [].concat(allResults); 56 | 57 | return consensusForResults(allResults); 58 | } 59 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as combineReducers } from './combineReducers'; 2 | export { default as getConsensus } from './getConsensus'; 3 | export { default as connectActionSets } from './connectActionSets'; 4 | -------------------------------------------------------------------------------- /src/isFunction.js: -------------------------------------------------------------------------------- 1 | export default function isFunction(something) { 2 | return (typeof something === 'function') || (Object.prototype.toString.call(something) === `[object Function]`); 3 | } 4 | -------------------------------------------------------------------------------- /src/notFoundValue.js: -------------------------------------------------------------------------------- 1 | export default {} 2 | -------------------------------------------------------------------------------- /src/redux.js: -------------------------------------------------------------------------------- 1 | import createResourcesFromReducers from './createResourcesFromReducers'; 2 | import combineReducers from './combineReducers'; 3 | import callAction from './callAction'; 4 | import getConsensus from './getConsensus'; 5 | import connectActionSets from './connectActionSets'; 6 | import { ACTION_TYPE, INTROSPECTION_TYPE } from './types'; 7 | 8 | const GET_CONSENSUS_TYPE = 'flambeau-consensus'; 9 | let latestConsensus; 10 | 11 | export function createRootReducer({ reducers, idToProps }) { 12 | const reducer = combineReducers(reducers, { getIDToProps: () => idToProps }); 13 | 14 | return (state = reducer.getInitialState(), action) => { 15 | if (action.type === ACTION_TYPE) { 16 | return callAction({ 17 | responder: reducer, 18 | type: ACTION_TYPE, 19 | initialState: state, 20 | notFoundValue: state, 21 | payload: action.payload, 22 | actionSetID: action.actionSetID, 23 | actionID: action.actionID 24 | }); 25 | } 26 | else if (action.type === GET_CONSENSUS_TYPE) { 27 | latestConsensus = getConsensus({ 28 | responder: reducer, 29 | props: {}, 30 | state: action.state, 31 | actionSetID: action.actionSetID, 32 | introspectionID: action.introspectionID, 33 | payload: action.payload 34 | }); 35 | } 36 | 37 | return state; 38 | }; 39 | } 40 | 41 | export function connectActionSetsToStore({ actionSets, store: { dispatch, getState } }) { 42 | return connectActionSets({ 43 | actionSets, 44 | dispatch, 45 | getConsensusForActionSet: (actionSetID) => (introspectionID) => (payload = {}) => { 46 | dispatch({ type: GET_CONSENSUS_TYPE, actionSetID, introspectionID, payload, state: getState() }); 47 | return latestConsensus; 48 | } 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/sequence.js: -------------------------------------------------------------------------------- 1 | function runSequence({ 2 | stepID, payload, stepCreators, connectedSteps, onFulfilled, onRejected, tap 3 | }) { 4 | try { 5 | const result = stepCreators[stepID](payload, { current: connectedSteps }); 6 | 7 | if (!result) { // Undefined, meaning this step is a completion 8 | onFulfilled(payload); 9 | return; 10 | } 11 | 12 | // Returned a promise 13 | Promise.resolve(result).then((result) => { 14 | if (!!tap) { 15 | tap(result.id, result); 16 | } 17 | onFulfilled(result); 18 | }, onRejected); 19 | } 20 | catch (error) { 21 | onRejected(error); 22 | } 23 | } 24 | 25 | export function createSequence(stepCreators) { 26 | const completionIDs = Object.keys(stepCreators).filter(stepID => 27 | stepCreators[stepID].length === 1 28 | ); 29 | 30 | let connectedSteps = {}; 31 | 32 | Object.keys(stepCreators).reduce((output, stepID) => { 33 | output[stepID] = (payload) => Object.assign( 34 | new Promise((onFulfilled, onRejected) => { 35 | Promise.resolve(payload).then(payload => { 36 | runSequence({ 37 | stepID, 38 | payload, 39 | stepCreators, 40 | connectedSteps, 41 | onFulfilled, 42 | onRejected, 43 | }); 44 | }); 45 | }), 46 | id: stepID, 47 | ); 48 | 49 | return output; 50 | }, connectedSteps); 51 | 52 | return connectedSteps; 53 | } -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | export const GET_INITIAL_STATE = 'getInitialState'; 2 | export const INTROSPECTION_PROPERTY = 'introspection'; 3 | 4 | // For libraries that expect a 'type' property in the payload. 5 | export const ACTION_TYPE = 'flambeau-action'; 6 | export const INTROSPECTION_TYPE = 'flambeau-introspection'; 7 | --------------------------------------------------------------------------------