├── .gitignore ├── .npmignore ├── README.md ├── circle.yml ├── cypress.json ├── cypress ├── fixtures │ ├── 10000people.json │ ├── db.js │ ├── example.json │ └── todos.json ├── index.ejs ├── integration │ ├── _todos_spec.js │ ├── ajax_spec.js │ ├── combineSources_spec.js │ ├── context_spec.js │ ├── counter_spec.js │ ├── generic_spec.js │ ├── nested_spec.js │ ├── pipe_spec.js │ ├── plans_spec.js │ ├── scanPlans_spec.js │ ├── stepper_spec.js │ ├── streamProps_spec.js │ └── stream_spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── docs ├── index.js └── poi.config.js ├── examples ├── .env.development ├── ajax │ └── index.js ├── api │ └── index.js ├── basic │ └── index.js ├── combineSources │ ├── components.js │ └── index.js ├── context │ └── index.js ├── counter │ └── index.js ├── drag │ └── index.js ├── generic │ └── index.js ├── index.css ├── index.js ├── nested │ └── index.js ├── package.json ├── ping │ └── index.js ├── pipe │ └── index.js ├── plans │ └── index.js ├── poi.config.js ├── render │ └── index.js ├── scanPlans │ └── index.js ├── sequence │ └── index.js ├── shopping-cart │ ├── components │ │ ├── Cart.js │ │ ├── CartItem.js │ │ ├── Product.js │ │ ├── ProductItem.js │ │ └── ProductList.js │ ├── index.js │ ├── pipes.js │ ├── plans.js │ └── streams.js ├── stepper │ └── index.js ├── stream │ └── index.js ├── streamProps │ └── index.js └── todos │ └── index.js ├── logo.svg ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── index.ts ├── observable │ └── combineSources.ts ├── operators │ ├── assign.ts │ ├── patchScan.ts │ ├── scanPlans.ts │ ├── scanSequence.ts │ └── scanStreams.ts ├── plan.ts ├── stream.ts ├── streamProps.ts └── utils │ └── curry.ts ├── test └── index.test.ts ├── tsconfig-test.json ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .rpt2_cache/ 64 | .DS_Store 65 | .vscode/ 66 | dist/ 67 | cypress/screenshots 68 | cypress/videos 69 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-streams 2 | 3 |

4 | 5 | react-streams logo 6 |

7 | 8 | ## Installation 9 | 10 | Install both `react-streams` and `rxjs` 11 | 12 | ```bash 13 | npm i react-streams rxjs 14 | ``` 15 | 16 | ## Build Status 17 | 18 | [![CircleCI](https://circleci.com/gh/johnlindquist/react-streams.svg?style=svg)](https://circleci.com/gh/johnlindquist/react-streams) 19 | 20 | [Cypress Dashboard](https://dashboard.cypress.io/#/projects/ar6axg/) 21 | 22 | ## About 23 | 24 | `react-streams` enables you to stream from a source or props. The stream will pass through a `pipe` and new values will often be pushed through by `plans`. 25 | 26 | ### Stream from sources 27 | 28 | **_``_** - A component that subscribes to a `source` and streams values to children. The stream will pass through a `pipe`. 29 | 30 | > ```js 31 | > 32 | > {values =>
{values.message}
} 33 | >
34 | > ``` 35 | 36 | **_`stream(source)`_** - Creates a named component that subscribes to a `source` and streams values to children. The stream will pass through a `pipe`. 37 | 38 | > ```js 39 | > const MyStreamingComponent = stream(source$) 40 | > 41 | > 42 | > {(values)=>
{values.message}
} 43 | >
44 | > ``` 45 | 46 | ### Stream from props 47 | 48 | **_``_** - A component that streams props changes to children. Changes to props will pass through the `pipe` and can be updated by `plans`. 49 | 50 | > ```js 51 | > 52 | > {values =>
{values.message}
} 53 | >
54 | > ``` 55 | 56 | **_`streamProps()`_** - Create a named component that streams props changes to children. Changes to props will pass through the `pipe` and can be updated by `plans`. 57 | 58 | > ```js 59 | > const MyStreamingPropsComponent = streamProps() 60 | > 61 | > 62 | > {(values)=>
{values.message}
} 63 | >
64 | > ``` 65 | 66 | ### Stream through `pipe` 67 | 68 | **_`pipe`_** is any operator (or `piped` combination of operators) that you want to act on your stream. Pipes can be simple mappings or complex ajax requests with timing as long as they return a function that returns an object which matches the `children`'s arguments. 69 | 70 | > ```js 71 | > message={message} 73 | > pipe={map(({ message }) => message + "!")} 74 | > > 75 | > {values =>
{values.message}
} 76 | >
77 | > ``` 78 | 79 | ### Make a `plan` to update 80 | 81 | **_`plan`_** is a function that can be observed. 82 | 83 | > ```js 84 | > const update = plan() 85 | > 86 | > from(update).subscribe(value => console.log(value)) 87 | > 88 | > update("Hello") //logs "Hello" 89 | > update("Friends") //logs "Friends" 90 | > ``` 91 | 92 | ## Examples 93 | 94 | Enough chit-chat, time for examples! 95 | 96 | Play with Examples at [codesandbox.io](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=/stream/index.js) 97 | 98 | ### `` 99 | 100 | [Demo here](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=generic/index.js) 101 | 102 | ```js 103 | import React from "react" 104 | import { Stream } from "react-streams" 105 | import { of, pipe } from "rxjs" 106 | import { delay, startWith } from "rxjs/operators" 107 | 108 | const startWithAndDelay = (message, time) => 109 | pipe( 110 | delay(time), 111 | startWith({ message }) 112 | ) 113 | 114 | const message$ = of({ message: "Hello" }) 115 | 116 | export default () => ( 117 |
118 |

Stream as a Component

119 | 123 | {({ message }) =>
{message}
} 124 |
125 | 129 | {({ message }) =>
{message}
} 130 |
131 |
132 | ) 133 | ``` 134 | 135 | ### `stream` 136 | 137 | [Demo here](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=/stream/index.js) 138 | 139 | ```js 140 | import React from "react" 141 | import { stream } from "react-streams" 142 | import { interval } from "rxjs" 143 | import { map } from "rxjs/operators" 144 | 145 | const count$ = interval(250).pipe( 146 | map(count => ({ count })) 147 | ) 148 | 149 | const Counter = stream(count$) 150 | 151 | export default () => ( 152 |
153 |

Subscribe to a Stream

154 | 155 | {({ count }) =>
{count}
} 156 |
157 |
158 | ) 159 | ``` 160 | 161 | ### `pipe` 162 | 163 | [Demo here](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=/pipe/index.js) 164 | 165 | ```js 166 | import React from "react" 167 | import { stream } from "react-streams" 168 | import { of } from "rxjs" 169 | import { map } from "rxjs/operators" 170 | 171 | const stream$ = of({ greeting: "Hello", name: "world" }) 172 | 173 | const mapToMessage = map(({ greeting, name }) => ({ 174 | message: `${greeting}, ${name}!` 175 | })) 176 | 177 | const Greeting = stream(stream$, mapToMessage) 178 | 179 | export default () => ( 180 |
181 |

Pipe Stream Values

182 | 183 | {({ message }) =>
{message}
} 184 |
185 |
186 | ) 187 | ``` 188 | 189 | ### `streamProps` 190 | 191 | [Demo here](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=/streamProps/index.js) 192 | 193 | ```js 194 | import React from "react" 195 | import { streamProps } from "react-streams" 196 | import { map } from "rxjs/operators" 197 | 198 | const mapGreeting = map(({ greeting, name }) => ({ 199 | message: `${greeting}, ${name}!` 200 | })) 201 | 202 | const HelloWorld = streamProps(mapGreeting) 203 | 204 | export default () => ( 205 |
206 |

Stream Props to Children

207 | 208 | {({ message }) =>
{message}
} 209 |
210 | 211 | {({ message }) =>
{message}
} 212 |
213 |
214 | ) 215 | ``` 216 | 217 | ### Ajax 218 | 219 | [Demo here](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=/ajax/index.js) 220 | 221 | ```js 222 | import React from "react" 223 | import { streamProps } from "react-streams" 224 | import { pipe } from "rxjs" 225 | import { ajax } from "rxjs/ajax" 226 | import { 227 | pluck, 228 | switchMap, 229 | startWith 230 | } from "rxjs/operators" 231 | 232 | const getTodo = pipe( 233 | switchMap(({ url, id }) => ajax(`${url}/${id}`)), 234 | pluck("response") 235 | ) 236 | 237 | const Todo = streamProps(getTodo) 238 | 239 | const url = process.env.DEV 240 | ? "/api/todos" 241 | : "https://dandelion-bonsai.glitch.me/todos" 242 | 243 | export default () => ( 244 |
245 |

Ajax Demo

246 | 247 | {({ text, id }) => ( 248 |
249 | {id}. {text} 250 |
251 | )} 252 |
253 | 254 | {({ text, id }) => ( 255 |
256 | {id}. {text} 257 |
258 | )} 259 |
260 |
261 | ) 262 | ``` 263 | 264 | ### Nested Streams 265 | 266 | [Demo here](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=/nested/index.js) 267 | 268 | ```js 269 | import React from "react" 270 | import { Stream, StreamProps } from "react-streams" 271 | import { map, filter } from "rxjs/operators" 272 | import { interval } from "rxjs" 273 | 274 | const count$ = interval(1000).pipe( 275 | map(count => ({ count })) 276 | ) 277 | 278 | const odds = filter(({ count }) => count % 2) 279 | const evens = filter(({ count }) => !(count % 2)) 280 | 281 | export default () => ( 282 | 283 | {({ count }) => ( 284 |
285 |

286 | Stream with Nested StreamProps Components 287 |

288 | 289 | {({ count }) =>
No filter: {count}
} 290 |
291 | 292 | {({ count }) =>
Odds: {count}
} 293 |
294 | 295 | {({ count }) =>
Evens: {count}
} 296 |
297 |
298 | )} 299 |
300 | ) 301 | ``` 302 | 303 | ### Create a `plan` 304 | 305 | [Demo here](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=/plans/index.js) 306 | 307 | ```js 308 | import React from "react" 309 | import { StreamProps, plan } from "react-streams" 310 | import { map, pluck } from "rxjs/operators" 311 | 312 | const onChange = plan( 313 | pluck("target", "value"), 314 | map(message => ({ message })) 315 | ) 316 | 317 | export default () => ( 318 |
319 |

Update a Stream with Plans

320 | 321 | {({ message, onChange }) => ( 322 |
323 | 328 |
{message}
329 |
330 | )} 331 |
332 |
333 | ) 334 | ``` 335 | 336 | ### `scanPlans` 337 | 338 | [Demo here](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=/plans/index.js) 339 | 340 | ```js 341 | import React from "react" 342 | import { 343 | scanPlans, 344 | plan, 345 | streamProps 346 | } from "react-streams" 347 | import { pipe } from "rxjs" 348 | import { ajax } from "rxjs/ajax" 349 | import { 350 | debounceTime, 351 | distinctUntilChanged, 352 | map, 353 | pluck 354 | } from "rxjs/operators" 355 | 356 | const handleInput = pipe( 357 | pluck("target", "value"), 358 | debounceTime(250), 359 | distinctUntilChanged(), 360 | /** 361 | * map to a fn which returns an object, fn, or Observable (which returns an 362 | * object, fn, or Observable) 363 | */ 364 | map(term => props => { 365 | if (term.length < 2) return { people: [], term: "" } 366 | return ajax( 367 | `${props.url}?username_like=${term}` 368 | ).pipe( 369 | pluck("response"), 370 | map(people => ({ 371 | term, 372 | people: people.slice(0, 10) 373 | })) 374 | ) 375 | }) 376 | ) 377 | 378 | const Typeahead = streamProps( 379 | scanPlans({ onChange: plan(handleInput) }) 380 | ) 381 | 382 | const url = process.env.DEV 383 | ? "/api/people" 384 | : "https://dandelion-bonsai.glitch.me/people" 385 | 386 | export default () => ( 387 | 388 | {({ term, people, onChange }) => ( 389 |
390 |

Search a username: {term}

391 | 397 |
    398 | {people.map(person => ( 399 |
  • 403 | {person.username} 404 | {person.username} 409 |
  • 410 | ))} 411 |
412 |
413 | )} 414 |
415 | ) 416 | ``` 417 | 418 | ### Counter Demo 419 | 420 | [Demo here](https://codesandbox.io/s/github/johnlindquist/react-streams/tree/master/examples?module=/counter/index.js) 421 | 422 | ```js 423 | import React from "react" 424 | import { 425 | scanPlans, 426 | plan, 427 | streamProps 428 | } from "react-streams" 429 | import { map } from "rxjs/operators" 430 | 431 | const onInc = plan( 432 | map(() => state => ({ count: state.count + 2 })) 433 | ) 434 | const onDec = plan( 435 | map(() => state => ({ count: state.count - 2 })) 436 | ) 437 | const onReset = plan(map(() => state => ({ count: 4 }))) 438 | 439 | const Counter = streamProps( 440 | scanPlans({ onInc, onDec, onReset }) 441 | ) 442 | 443 | export default () => ( 444 | 445 | {({ count, onInc, onDec, onReset }) => ( 446 |
447 | 454 | 455 | {count} 456 | 457 | 464 | 467 |
468 | )} 469 |
470 | ) 471 | ``` 472 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | # https://docs.cypress.io/guides/guides/continuous-integration.html#CircleCI 2 | version: 2 3 | jobs: 4 | build: 5 | docker: 6 | - image: cypress/base:10 7 | environment: 8 | ## this enables colors in the output 9 | TERM: xterm 10 | working_directory: ~/app 11 | steps: 12 | - checkout 13 | - restore_cache: 14 | key: v1-app 15 | - run: yarn install 16 | - save_cache: 17 | key: v1-app 18 | paths: 19 | # since we use Yarn it caches NPM modules in ~/.cache 20 | # and Cypress caches its binary there! 21 | # to confirm: 22 | # yarn cache dir 23 | # npx print-cachedir Cypress 24 | - ~/.cache 25 | - run: yarn build 26 | # run e2e tests with video recorded by Cypress 27 | - run: yarn e2e:video 28 | # store videos and screenshots (if any) as CI artifacts 29 | - store_artifacts: 30 | path: cypress/videos 31 | - store_artifacts: 32 | path: cypress/screenshots 33 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "ar6axg", 3 | "video": false 4 | } -------------------------------------------------------------------------------- /cypress/fixtures/db.js: -------------------------------------------------------------------------------- 1 | const todos = require("./todos") 2 | const people = require("./10000people") 3 | module.exports = () => ({ 4 | todos, 5 | people 6 | }) 7 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/fixtures/todos.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "text": "Eat", 3 | "done": false, 4 | "id": 1 5 | }, 6 | { 7 | "text": "Sleep", 8 | "done": false, 9 | "id": 2 10 | }, 11 | { 12 | "text": "Code", 13 | "done": false, 14 | "id": 3 15 | } 16 | ] -------------------------------------------------------------------------------- /cypress/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= htmlWebpackPlugin.options.title %> 10 | 11 | <% if (htmlWebpackPlugin.options.description) { %> 12 | 13 | <% } %> 14 | 15 | 16 | 17 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /cypress/integration/_todos_spec.js: -------------------------------------------------------------------------------- 1 | const text = new Date().toString() 2 | 3 | describe("Todos", () => { 4 | beforeEach(() => { 5 | cy.command("launch", "todos") 6 | }) 7 | 8 | it("should add a todo", () => { 9 | cy.get('input[type="text"]').type(text) 10 | cy.get('input[type="submit"]').click() 11 | cy.wait("@post") 12 | cy.get("li:last-child > span").should("have.text", text) 13 | }) 14 | 15 | it("should delete a todo", () => { 16 | cy.get("li:last-child > button:last-child").click() 17 | cy.wait("@delete") 18 | cy.get("li:last-child > span").should("not.have.text", text) 19 | }) 20 | 21 | it("should toggle a todo", () => { 22 | cy 23 | .get("li:last-child > span") 24 | .should("have.css", "text-decoration-line", "none") 25 | 26 | cy.get("li:last-child > button:first").click() 27 | cy.wait("@patch") 28 | cy 29 | .get("li:last-child > span") 30 | .should("have.css", "text-decoration-line", "line-through") 31 | 32 | cy.get("li:last-child > button:first").click() 33 | cy.wait("@patch") 34 | cy 35 | .get("li:last-child > span") 36 | .should("have.css", "text-decoration-line", "none") 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /cypress/integration/ajax_spec.js: -------------------------------------------------------------------------------- 1 | describe("Ajax", () => { 2 | beforeEach(() => { 3 | cy.command("launch", "ajax") 4 | }) 5 | 6 | it("should load and display the second and third Todo", () => { 7 | cy.contains("2. Sleep") 8 | cy.contains("3. Code") 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /cypress/integration/combineSources_spec.js: -------------------------------------------------------------------------------- 1 | describe("combineSources", () => { 2 | beforeEach(() => { 3 | cy.command("launch", "combineSources") 4 | cy.get(".count > button:first").as("countButton") 5 | cy.get(".count > h3").as("countText") 6 | cy.get(".name > h3").as("nameText") 7 | cy.get(".nameAndCount > h3").as("nameAndCountText") 8 | }) 9 | 10 | it("should start with 5 in both 'count' and 'nameAndCount'", () => { 11 | cy.get("@countText").should("have.text", "5 apples") 12 | cy.get("@nameAndCountText").should("have.text", "John has 5 apples") 13 | }) 14 | 15 | it("should have 'John' in both 'name' and 'nameAndCount'", () => { 16 | cy.get("@nameText").should("have.text", "John") 17 | cy.get("@nameAndCountText").should("have.text", "John has 5 apples") 18 | }) 19 | 20 | it("should change both 'count' and 'nameAndCount' when clicking buttons", () => { 21 | cy.get("@countButton").click() 22 | cy.get("@countText").should("have.text", "6 apples") 23 | cy.get("@nameAndCountText").should("have.text", "John has 6 apples") 24 | 25 | cy.get(".nameAndCount > button:first").click() 26 | cy.get("@countText").should("have.text", "7 apples") 27 | cy.get("@nameAndCountText").should("have.text", "John has 7 apples") 28 | 29 | cy.get(".nameAndCount > button:last").click() 30 | cy.get("@countText").should("have.text", "6 apples") 31 | cy.get("@nameAndCountText").should("have.text", "John has 6 apples") 32 | 33 | cy.get(".count > button:last").click() 34 | cy.get("@countText").should("have.text", "5 apples") 35 | cy.get("@nameAndCountText").should("have.text", "John has 5 apples") 36 | }) 37 | 38 | it("should change both 'name' and 'nameAndCount' when entering text", () => { 39 | cy 40 | .get(".name > input") 41 | .clear() 42 | .type("Mindy") 43 | cy.get("@nameText").should("have.text", "Mindy") 44 | cy.get("@nameAndCountText").should("have.text", "Mindy has 5 apples") 45 | 46 | cy 47 | .get(".nameAndCount > input") 48 | .first() 49 | .clear() 50 | .type("Ben") 51 | cy.get("@nameText").should("have.text", "Ben") 52 | cy.get("@nameAndCountText").should("have.text", "Ben has 5 apples") 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /cypress/integration/context_spec.js: -------------------------------------------------------------------------------- 1 | describe("Context", () => { 2 | beforeEach(() => { 3 | cy.command("launch", "context") 4 | }) 5 | it("should display 'Hello'", () => { 6 | cy.get("#message").should("have.text", "Hello") 7 | }) 8 | 9 | it("should change to 'On' when clicking on", () => { 10 | cy.get('[aria-label="change message to on"]').click() 11 | cy.get("#message").should("have.text", "On") 12 | }) 13 | 14 | it("should change to the 'Off' when clicking off", () => { 15 | cy.get('[aria-label="change message to off"]').click() 16 | cy.get("#message").should("have.text", "Off") 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /cypress/integration/counter_spec.js: -------------------------------------------------------------------------------- 1 | describe("Counter", () => { 2 | beforeEach(() => { 3 | cy.visit("http://localhost:4321/counter") 4 | }) 5 | 6 | it("should start with 4", () => { 7 | cy.get("#count").should("contain", 4) 8 | }) 9 | 10 | it("should change to 6 when inc button clicked", () => { 11 | cy.get("#inc").click() 12 | cy.get("#count").should("contain", 6) 13 | }) 14 | 15 | it("should change to 2 when dec button clicked", () => { 16 | cy.get("#dec").click() 17 | cy.get("#count").should("contain", 2) 18 | }) 19 | 20 | it("should be 4 when inc then dec button clicked", () => { 21 | cy.get("#inc").click() 22 | cy.get("#dec").click() 23 | cy.get("#count").should("contain", 4) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /cypress/integration/generic_spec.js: -------------------------------------------------------------------------------- 1 | describe("generic", () => { 2 | it("should stream in 0, 1, 2", () => { 3 | cy.clock() 4 | cy.visit("http://localhost:4321/generic") 5 | cy.contains("Wait...") 6 | cy.contains("Wait longer...") 7 | cy.tick(500) 8 | cy.contains("Hello") 9 | cy.tick(2500) 10 | cy 11 | .get('[style="padding: 2rem;"] > :nth-child(1) > :nth-child(3)') 12 | .contains("Hello") 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /cypress/integration/nested_spec.js: -------------------------------------------------------------------------------- 1 | describe("Ajax", () => { 2 | beforeEach(() => { 3 | cy.clock() 4 | cy.command("launch", "nested") 5 | }) 6 | 7 | it("should load and display the second and third Todo", () => { 8 | cy.tick(1000) 9 | cy.contains("0") 10 | cy.tick(1000) 11 | cy.contains("1") 12 | cy.contains("0") 13 | cy.tick(1000) 14 | cy.contains("2") 15 | cy.contains("1") 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /cypress/integration/pipe_spec.js: -------------------------------------------------------------------------------- 1 | describe("Basic", () => { 2 | it("should display 'Wait...' then 'Hello'", () => { 3 | cy.visit("http://localhost:4321/pipe") 4 | cy.contains("Hello, world!") 5 | }) 6 | }) 7 | -------------------------------------------------------------------------------- /cypress/integration/plans_spec.js: -------------------------------------------------------------------------------- 1 | describe("Text", () => { 2 | beforeEach(() => { 3 | cy.visit("http://localhost:4321/plans") 4 | }) 5 | 6 | it("should start with 'Hello'", () => { 7 | cy.get("#message").contains(/^Hello$/) 8 | }) 9 | 10 | it("should change to 'Friends'", () => { 11 | cy.get("#input").type("Friends") 12 | cy.get("#message").should("have.text", "Friends") 13 | }) 14 | 15 | it("should delete then type 'Hi'", () => { 16 | cy 17 | .get("#input") 18 | .clear() 19 | .type("Hi") 20 | cy.get("#message").should("have.text", "Hi") 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /cypress/integration/scanPlans_spec.js: -------------------------------------------------------------------------------- 1 | describe("scanPlans", () => { 2 | beforeEach(() => { 3 | cy.command("launch", "scanPlans") 4 | cy.get("input").clear() 5 | }) 6 | it("should have an empty list", () => { 7 | cy.get("ul").should("be.empty") 8 | }) 9 | 10 | it("should still be empty with 1 char", () => { 11 | cy.get("input").type("a") 12 | cy.get("ul").should("be.empty") 13 | }) 14 | 15 | it("should receive results with 2 chars then clear after delete", () => { 16 | cy.get("input").type("ab") 17 | cy.get("ul").should("not.be.empty") 18 | 19 | cy.get("input").type("{backspace}") 20 | cy.get("ul").should("be.empty") 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /cypress/integration/stepper_spec.js: -------------------------------------------------------------------------------- 1 | describe("Stepper", () => { 2 | beforeEach(() => { 3 | cy.visit("http://localhost:4321/stepper") 4 | cy.get(":nth-child(1) > input").as("min") 5 | cy.get(":nth-child(2) > input").as("max") 6 | cy.get(":nth-child(3) > input").as("step") 7 | cy.get('[aria-label="Increment value"]').as("inc") 8 | cy.get('[aria-label="Decrement value"]').as("dec") 9 | cy.get('[type="text"]').as("value") 10 | }) 11 | 12 | it("should increase and decrease by the step amount", () => { 13 | cy.get("@value").should("have.value", "10") 14 | cy.get("@inc").click() 15 | cy.get("@value").should("have.value", "11") 16 | cy.get("@dec").click() 17 | cy.get("@value").should("have.value", "10") 18 | 19 | cy 20 | .get("@step") 21 | .clear() 22 | .type("2") 23 | 24 | cy.get("@value").should("have.value", "10") 25 | cy.get("@inc").click() 26 | cy.get("@value").should("have.value", "12") 27 | cy.get("@dec").click() 28 | cy.get("@value").should("have.value", "10") 29 | }) 30 | 31 | it("should stop at max and min", () => { 32 | cy.get("@inc").click() 33 | cy.get("@inc").click() 34 | cy.get("@inc").click() 35 | cy.get("@inc").click() 36 | cy.get("@inc").click() 37 | cy.get("@inc").click() 38 | cy.get("@inc").click() 39 | cy.get("@inc").click() 40 | cy.get("@inc").click() 41 | 42 | cy.get("@value").should("have.value", "15") 43 | 44 | cy.get("@dec").click() 45 | cy.get("@dec").click() 46 | cy.get("@dec").click() 47 | cy.get("@dec").click() 48 | cy.get("@dec").click() 49 | cy.get("@dec").click() 50 | cy.get("@dec").click() 51 | cy.get("@dec").click() 52 | cy.get("@dec").click() 53 | 54 | cy.get("@value").should("have.value", "10") 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /cypress/integration/streamProps_spec.js: -------------------------------------------------------------------------------- 1 | describe("stream", () => { 2 | it("should stream in 0, 1, 2", () => { 3 | cy.visit("http://localhost:4321/streamProps") 4 | cy.contains("Hello, world!") 5 | cy.contains("Bonjour, John!") 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /cypress/integration/stream_spec.js: -------------------------------------------------------------------------------- 1 | describe("stream", () => { 2 | it("should stream in 0, 1, 2", () => { 3 | cy.clock() 4 | cy.visit("http://localhost:4321/stream") 5 | cy.tick(250) 6 | cy.contains("0") 7 | cy.tick(250) 8 | cy.contains("1") 9 | cy.tick(250) 10 | cy.contains("2") 11 | cy.tick(250) 12 | cy.contains("3") 13 | cy.tick(250) 14 | cy.contains("4") 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | const webpack = require("@cypress/webpack-preprocessor") 15 | 16 | module.exports = on => { 17 | const options = webpack.defaultOptions 18 | 19 | options.webpackOptions.module.rules[0].use[0].options.presets[0] = [ 20 | "babel-preset-env", 21 | { targets: { node: "current" }, exclude: ["transform-regenerator"] } 22 | ] 23 | 24 | on("file:preprocessor", webpack(options)) 25 | } 26 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | Cypress.Commands.add("launch", url => { 28 | cy.server() 29 | cy.route("GET", "/api/todos").as("get") 30 | cy.route("POST", "/api/todos").as("post") 31 | cy.route("PATCH", "/api/todos/*").as("patch") 32 | cy.route("DELETE", "/api/todos/*").as("delete") 33 | 34 | cy.visit(`http://localhost:4321/${url}`) 35 | }) 36 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /docs/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { render } from "react-dom" 3 | import Counter from "../examples/counter" 4 | import CounterDocs from "../examples/counter/README.md" 5 | 6 | const App = () => ( 7 |
8 | 9 | 10 |
11 | ) 12 | 13 | render(, document.querySelector("#app")) 14 | -------------------------------------------------------------------------------- /docs/poi.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | entry: "./docs/index.js", 5 | configureWebpack(config, context) { 6 | config.resolve.alias = { 7 | "react-streams": "../../" 8 | } 9 | config.module.rules.push({ 10 | test: /\.md?$/, 11 | use: [ 12 | { 13 | loader: "babel-loader", 14 | options: { 15 | presets: [ 16 | "env", 17 | ["@babel/preset-stage-0", { decoratorsLegacy: true }], 18 | "@babel/react" 19 | ] 20 | } 21 | }, 22 | "@mdx-js/loader" 23 | ] 24 | }) 25 | 26 | return config 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/.env.development: -------------------------------------------------------------------------------- 1 | DEV=true -------------------------------------------------------------------------------- /examples/ajax/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { streamProps } from "react-streams" 3 | import { pipe } from "rxjs" 4 | import { ajax } from "rxjs/ajax" 5 | import { pluck, switchMap } from "rxjs/operators" 6 | 7 | const getTodo = pipe( 8 | switchMap(({ url, id }) => ajax(`${url}/${id}`)), 9 | pluck("response") 10 | ) 11 | 12 | const Todo = streamProps(getTodo) 13 | 14 | const url = process.env.DEV 15 | ? "/api/todos" 16 | : "https://dandelion-bonsai.glitch.me/todos" 17 | 18 | export default () => ( 19 |
20 |

Ajax Demo

21 | 22 | {({ text, id }) => ( 23 |
24 | {id}. {text} 25 |
26 | )} 27 |
28 | 29 | {({ text, id }) => ( 30 |
31 | {id}. {text} 32 |
33 | )} 34 |
35 |
36 | ) 37 | -------------------------------------------------------------------------------- /examples/api/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Stream, stream } from "react-streams" 3 | import { of, pipe } from "rxjs" 4 | import { delay, startWith, switchMap } from "rxjs/operators" 5 | 6 | const startWithAndDelay = (startMessage, time) => 7 | pipe( 8 | switchMap(({ message }) => of({ message })), 9 | delay(time), 10 | startWith({ message: startMessage }) 11 | ) 12 | 13 | const startWithWaitDelay500 = startWithAndDelay("Wait...", 500) 14 | 15 | const props = { 16 | message: "Hello", 17 | render: ({ message }) =>
{message}
18 | } 19 | const message$ = of(props) 20 | 21 | const MessageStream = stream(startWithWaitDelay500) 22 | const MessageStreamNeedsPipe = stream() 23 | 24 | export default () => ( 25 |
26 | 27 | 28 | 29 |
30 | ) 31 | -------------------------------------------------------------------------------- /examples/basic/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { streamProps } from "react-streams" 3 | import { pipe } from "rxjs" 4 | import { delay, startWith } from "rxjs/operators" 5 | 6 | const startWithAndDelay = (startMessage, time) => 7 | pipe(delay(time), startWith({ message: startMessage })) 8 | 9 | const MessageStream = streamProps(startWithAndDelay("Wait...", 500)) 10 | 11 | export default () => ( 12 | 13 | {({ message }) =>
{message}
} 14 |
15 | ) 16 | -------------------------------------------------------------------------------- /examples/combineSources/components.js: -------------------------------------------------------------------------------- 1 | const containerStyle = { 2 | border: "3px solid green", 3 | padding: "1rem", 4 | margin: "1rem" 5 | } 6 | 7 | export const NameOnly = ({ name, onUpdate }) => ( 8 |
9 |

Name Only

10 | 11 |

{name}

12 |
13 | ) 14 | 15 | export const CountOnly = ({ count, onInc, onDec }) => ( 16 |
17 |

Count Only

18 |

{count} apples

19 | 20 | 21 |
22 | ) 23 | 24 | export const NameAndCount = ({ count, onInc, onDec, name, onUpdate }) => ( 25 |
26 |

Name and Count

27 |

28 | {name} has {count} apples 29 |

30 | 31 | 32 | 33 |

{name}

34 | 35 |
36 | ) 37 | -------------------------------------------------------------------------------- /examples/combineSources/index.js: -------------------------------------------------------------------------------- 1 | import React, { createContext } from "react" 2 | import { combineSources, plan, scanPlans, Stream } from "react-streams" 3 | import { map, mapTo, pluck } from "rxjs/operators" 4 | import { CountOnly, NameAndCount, NameOnly } from "./components" 5 | import { of } from "rxjs" 6 | 7 | const nameState$ = of({ name: "John" }).pipe( 8 | scanPlans({ 9 | onUpdate: plan(pluck("target", "value"), map(name => () => ({ name }))) 10 | }) 11 | ) 12 | 13 | const countState$ = of({ count: 5 }).pipe( 14 | scanPlans({ 15 | onInc: plan(mapTo(({ count }) => ({ count: count + 1 }))), 16 | onDec: plan(mapTo(({ count }) => ({ count: count - 1 }))) 17 | }) 18 | ) 19 | 20 | const source$ = combineSources(nameState$, countState$) 21 | 22 | //for non-ui effectss 23 | source$.subscribe(state => console.log(state)) 24 | 25 | const { Consumer } = createContext({ source$ }) 26 | const NameAndCountStream = props => ( 27 | } 29 | /> 30 | ) 31 | 32 | export default () => ( 33 |
34 | 35 | 36 | 37 | 38 | {/* Simulating late subscribers */} 39 | 40 | {({ name }) => 41 | name.length > 4 ? : null 42 | } 43 | 44 | 45 | {({ count }) => 46 | count > 5 ? : null 47 | } 48 | 49 |
50 | ) 51 | -------------------------------------------------------------------------------- /examples/context/index.js: -------------------------------------------------------------------------------- 1 | import React, { createContext } from "react" 2 | import { Stream, scanPlans, plan } from "react-streams" 3 | import { of } from "rxjs" 4 | import { mapTo } from "rxjs/operators" 5 | 6 | const message$ = of({ message: "Hello" }) 7 | const on = plan(mapTo({ message: "On" })) 8 | const off = plan(mapTo({ message: "Off" })) 9 | const state$ = message$.pipe(scanPlans({ on, off })) 10 | 11 | const { Consumer } = createContext({ state$, on, off }) 12 | 13 | export default () => ( 14 |
15 | 16 | {({ state$ }) => ( 17 | 18 | {({ message }) =>

{message}

} 19 |
20 | )} 21 |
22 | 23 |
24 |
25 |
26 | 27 | {({ on, off }) => ( 28 |
29 | 32 | 35 |
36 | )} 37 |
38 |
39 |
40 |
41 |
42 | ) 43 | -------------------------------------------------------------------------------- /examples/counter/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { scanPlans, plan, streamProps } from "react-streams" 3 | import { map } from "rxjs/operators" 4 | 5 | const onInc = plan(map(() => state => ({ count: state.count + 2 }))) 6 | const onDec = plan(map(() => state => ({ count: state.count - 2 }))) 7 | const onReset = plan(map(() => state => ({ count: 4 }))) 8 | 9 | const Counter = streamProps(scanPlans({ onInc, onDec, onReset })) 10 | 11 | export default () => ( 12 | 13 | {({ count, onInc, onDec, onReset }) => ( 14 |
15 | 18 | 19 | {count} 20 | 21 | 24 | 27 |
28 | )} 29 |
30 | ) 31 | -------------------------------------------------------------------------------- /examples/drag/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { plan, stream, assign } from "react-streams" 3 | import { concat, from, of } from "rxjs" 4 | import { 5 | map, 6 | pluck, 7 | switchMap, 8 | takeUntil, 9 | startWith, 10 | tap 11 | } from "rxjs/operators" 12 | 13 | const makeDrag = (move, end) => start => 14 | from(start).pipe( 15 | pluck("currentTarget"), 16 | tap(value => console.log(value)), 17 | switchMap( 18 | currentTarget => 19 | console.log({ start, move, end }) || 20 | from(move).pipe( 21 | tap(value => console.log(`onMouseMove`, value)), 22 | map(moveEvent => ({ 23 | left: moveEvent.clientX - currentTarget.offsetWidth / 2, 24 | top: moveEvent.clientY - currentTarget.offsetHeight / 2, 25 | currentTarget 26 | })), 27 | takeUntil(end) 28 | ) 29 | ), 30 | startWith({}) 31 | ) 32 | 33 | const DragStream = props => { 34 | const onMouseDown = plan(tap(value => console.log(`onMouseDown`, value))) 35 | const drag$ = props.onDrag(onMouseDown).pipe(assign({ onMouseDown })) 36 | 37 | return stream(drag$)(props) 38 | } 39 | 40 | const onMouseMove = plan() 41 | const onMouseUp = plan(tap(value => console.log(`onMouseUp`, value))) 42 | 43 | export default () => ( 44 |
49 |

Drag Demo

50 | 51 | {({ top, left, onMouseDown }) => ( 52 |
53 |
65 |
66 | )} 67 | 68 | 69 | 70 | {({ top, left, onMouseDown }) => ( 71 |
72 |
84 |
85 | )} 86 | 87 | 88 | {({ top, left, onMouseDown }) => ( 89 |
90 |
102 |
103 | )} 104 | 105 |
106 | ) 107 | -------------------------------------------------------------------------------- /examples/generic/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Stream } from "react-streams" 3 | import { of, pipe } from "rxjs" 4 | import { delay, startWith } from "rxjs/operators" 5 | 6 | const startWithAndDelay = (message, time) => 7 | pipe( 8 | delay(time), 9 | startWith({ message }) 10 | ) 11 | 12 | const message$ = of({ message: "Hello" }) 13 | 14 | export default () => ( 15 |
16 |

Stream as a Component

17 | 18 | {({ message }) =>
{message}
} 19 |
20 | 21 | {({ message }) =>
{message}
} 22 |
23 |
24 | ) 25 | -------------------------------------------------------------------------------- /examples/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | 6 | body, 7 | input { 8 | font-family: "Avenir", sans-serif; 9 | font-size: 1rem; 10 | } 11 | 12 | input[type="submit"], 13 | button { 14 | border: 1px solid black; 15 | } 16 | 17 | input[type="submit"]:hover, 18 | button:hover { 19 | background-color: lightblue; 20 | } 21 | 22 | ul { 23 | padding: 0; 24 | list-style-type: none; 25 | } 26 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react" 2 | import { render } from "react-dom" 3 | import Loadable from "react-loadable" 4 | import { 5 | BrowserRouter as Router, 6 | Link, 7 | Route, 8 | Switch, 9 | Redirect 10 | } from "react-router-dom" 11 | import "./index.css" 12 | 13 | const examples = [ 14 | "generic", 15 | "stream", 16 | "pipe", 17 | "streamProps", 18 | "ajax", 19 | "nested", 20 | "plans", 21 | "scanPlans", 22 | "counter", 23 | "todos", 24 | "drag", 25 | "context", 26 | "combineSources", 27 | "stepper", 28 | "render", 29 | "shopping-cart", 30 | "ping", 31 | "sequence" 32 | ] 33 | 34 | const ExampleRoute = props => ( 35 | { 38 | const Example = Loadable({ 39 | loader: () => import(`.${props.path}`), 40 | loading: () => null 41 | }) 42 | return 43 | }} 44 | /> 45 | ) 46 | 47 | render( 48 | 49 | 50 | 57 |
58 | 59 | 60 | {examples.map(example => ( 61 | 62 | ))} 63 | 64 |
65 |
66 |
, 67 | document.querySelector("#root") 68 | ) 69 | -------------------------------------------------------------------------------- /examples/nested/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Stream, StreamProps } from "react-streams" 3 | import { map, filter } from "rxjs/operators" 4 | import { interval } from "rxjs" 5 | 6 | const count$ = interval(1000).pipe(map(count => ({ count }))) 7 | 8 | const odds = filter(({ count }) => count % 2) 9 | const evens = filter(({ count }) => !(count % 2)) 10 | 11 | export default () => ( 12 | 13 | {({ count }) => ( 14 |
15 |

Stream with Nested StreamProps Components

16 | 17 | {({ count }) =>
No filter: {count}
} 18 |
19 | 20 | {({ count }) =>
Odds: {count}
} 21 |
22 | 23 | {({ count }) =>
Evens: {count}
} 24 |
25 |
26 | )} 27 |
28 | ) 29 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-streams-examples", 3 | "dependencies": { 4 | "react": "^16.3.2", 5 | "react-dom": "^16.3.2", 6 | "rxjs": "^6.1.0", 7 | "react-router": "^4.2.0", 8 | "react-router-dom": "^4.2.2", 9 | "react-streams": "*", 10 | "react-loadable": "^5.4.0" 11 | } 12 | } -------------------------------------------------------------------------------- /examples/ping/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { assign, plan, stream } from "react-streams" 3 | import { concat, of } from "rxjs" 4 | import { delay, switchMap, startWith } from "rxjs/operators" 5 | 6 | const ping = plan( 7 | switchMap(() => 8 | concat(of({ isPinging: true }), of({ isPinging: false }).pipe(delay(1000))) 9 | ), 10 | startWith({ isPinging: false }) 11 | ) 12 | 13 | const Ping = stream(ping, assign({ ping })) 14 | 15 | export default () => ( 16 |
17 |

Ping

18 | 19 | {({ isPinging, ping }) => ( 20 |
21 |

is pinging: {JSON.stringify(isPinging)}

22 | 23 |
24 | )} 25 |
26 |
27 | ) 28 | -------------------------------------------------------------------------------- /examples/pipe/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { stream } from "react-streams" 3 | import { of } from "rxjs" 4 | import { map } from "rxjs/operators" 5 | 6 | const stream$ = of({ greeting: "Hello", name: "world" }) 7 | const mapToMessage = map(({ greeting, name }) => ({ 8 | message: `${greeting}, ${name}!` 9 | })) 10 | 11 | const Greeting = stream(stream$, mapToMessage) 12 | 13 | export default () => ( 14 |
15 |

Pipe Stream Values

16 | {({ message }) =>
{message}
}
17 |
18 | ) 19 | -------------------------------------------------------------------------------- /examples/plans/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { StreamProps, plan, scanPlans } from "react-streams" 3 | import { map, pluck } from "rxjs/operators" 4 | 5 | const onChange = plan(pluck("target", "value"), map(message => ({ message }))) 6 | 7 | export default () => ( 8 |
9 |

Update a Stream with Plans

10 | 11 | {({ message, onChange }) => ( 12 |
13 | 14 |
{message}
15 |
16 | )} 17 |
18 |
19 | ) 20 | -------------------------------------------------------------------------------- /examples/poi.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | progress: false, 5 | entry: "index.js", 6 | html: { 7 | template: "../cypress/index.ejs" 8 | }, 9 | 10 | devServer: { 11 | proxy: { 12 | "/api": { 13 | target: "http://localhost:4322", 14 | pathRewrite: { "^/api": "" } 15 | } 16 | } 17 | }, 18 | configureWebpack(config, context) { 19 | debugger 20 | config.resolve.alias = { 21 | "react-streams": path.resolve(__dirname, "../dist/react-streams.js") 22 | } 23 | 24 | config.module.rules.push({ 25 | test: /\.md?$/, 26 | use: [ 27 | { 28 | loader: "babel-loader", 29 | 30 | options: { 31 | presets: [ 32 | "env", 33 | ["@babel/preset-stage-0", { decoratorsLegacy: true }], 34 | "@babel/react" 35 | ] 36 | } 37 | }, 38 | "@mdx-js/loader" 39 | ] 40 | }) 41 | 42 | return config 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/render/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { StreamProps } from "react-streams" 3 | import { of, pipe } from "rxjs" 4 | import { catchError, map } from "rxjs/operators" 5 | 6 | const catchMissingMessage = pipe( 7 | map(({ getMessage }) => ({ message: getMessage() + "!" })), 8 | catchError(error => 9 | of({ 10 | render: () => ( 11 |
19 | ⚠️{error.message}⚠️ 20 |
21 | ) 22 | }) 23 | ) 24 | ) 25 | 26 | export default () => ( 27 |
28 |

Catch and Render

29 | "Hello"} pipe={catchMissingMessage}> 30 | {({ message }) =>
{message}
} 31 |
32 | 33 | {({ message }) =>
{message}
} 34 |
35 |
36 | ) 37 | -------------------------------------------------------------------------------- /examples/scanPlans/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { scanPlans, plan, streamProps } from "react-streams" 3 | import { pipe } from "rxjs" 4 | import { ajax } from "rxjs/ajax" 5 | import { debounceTime, distinctUntilChanged, map, pluck } from "rxjs/operators" 6 | 7 | const handleInput = pipe( 8 | pluck("target", "value"), 9 | debounceTime(250), 10 | distinctUntilChanged(), 11 | /** 12 | * map to a fn which returns an object, fn, or Observable (which returns an 13 | * object, fn, or Observable) 14 | */ 15 | map(term => props => { 16 | if (term.length < 2) return { people: [], term: "" } 17 | return ajax(`${props.url}?username_like=${term}`).pipe( 18 | pluck("response"), 19 | map(people => ({ term, people: people.slice(0, 10) })) 20 | ) 21 | }) 22 | ) 23 | 24 | const Typeahead = streamProps(scanPlans({ onChange: plan(handleInput) })) 25 | 26 | const url = process.env.DEV 27 | ? "/api/people" 28 | : "https://dandelion-bonsai.glitch.me/people" 29 | 30 | export default () => ( 31 | 32 | {({ term, people, onChange }) => ( 33 |
34 |

Search a username: {term}

35 | 41 |
    42 | {people.map(person => ( 43 |
  • 44 | {person.username} 45 | {person.username} 50 |
  • 51 | ))} 52 |
53 |
54 | )} 55 |
56 | ) 57 | -------------------------------------------------------------------------------- /examples/sequence/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { plan, scanPlans, stream, scanSequence } from "react-streams" 3 | import { from, of } from "rxjs" 4 | import { 5 | concatAll, 6 | delay, 7 | map, 8 | scan, 9 | share, 10 | switchMap, 11 | concatMap 12 | } from "rxjs/operators" 13 | 14 | const three = plan( 15 | delay(300), 16 | map(({ things }) => ({ things: [...things, "three"] })) 17 | ) 18 | 19 | const two = plan( 20 | delay(200), 21 | map(({ things }) => ({ things: [...things, "two"] })) 22 | ) 23 | 24 | const one = plan( 25 | delay(100), 26 | map(({ things }) => ({ things: [...things, "one"] })) 27 | ) 28 | 29 | const start = plan(scanSequence(one, two, three)) 30 | 31 | const Sequence = stream(of({ things: ["zero"] }).pipe(scanPlans({ start }))) 32 | 33 | export default () => ( 34 |
35 | 36 | {({ start, things }) => ( 37 |
38 | 39 | {things.map((thing, index) => ( 40 |
41 | {index}. {thing} 42 |
43 | ))} 44 |
45 | )} 46 |
47 |
48 | ) 49 | -------------------------------------------------------------------------------- /examples/shopping-cart/components/Cart.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | import CartItem from "./CartItem" 3 | import { StatusStream } from "../streams" 4 | 5 | export default class Cart extends Component { 6 | render() { 7 | const { 8 | items, 9 | total, 10 | error, 11 | checkoutPending, 12 | checkout, 13 | removeFromCart 14 | } = this.props 15 | 16 | const hasProducts = items.length > 0 17 | const checkoutAllowed = hasProducts && !checkoutPending 18 | 19 | const nodes = !hasProducts ? ( 20 | Please add some products to cart. 21 | ) : ( 22 | items 23 | .filter(({ quantity, inventory }) => quantity) 24 | .map(product => ( 25 | removeFromCart(product.id)} 29 | /> 30 | )) 31 | ) 32 | 33 | return ( 34 |
35 |

Your Cart

36 |
{nodes}
37 |

Total: ${total}

38 | 44 | 45 | {({ error }) =>
{error}
} 46 |
47 |
48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /examples/shopping-cart/components/CartItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | 3 | export default ({ price, quantity, title, onRemove }) => { 4 | return ( 5 |
6 | {title} - ${price} {quantity ? `x ${quantity}` : null} 7 | 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /examples/shopping-cart/components/Product.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | export default ({ price, quantity, title, children, inventory }) => ( 4 |
5 | {title} - ${price} {quantity ? `x ${quantity}` : null} {children} 6 | {inventory ? ` - ${inventory} remaining` : ``} 7 |
8 | ) 9 | -------------------------------------------------------------------------------- /examples/shopping-cart/components/ProductItem.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Product from "./Product" 3 | 4 | export default ({ 5 | title, 6 | inventory, 7 | price, 8 | quantity, 9 | remaining, 10 | onAddToCartClicked 11 | }) => { 12 | return ( 13 |
14 |
15 | {title} - ${price} 16 | {remaining ? ` - ${remaining} remaining` : ``} 17 | 23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /examples/shopping-cart/components/ProductList.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ProductItem from "./ProductItem" 3 | 4 | export default ({ items, addToCart }) => ( 5 |
6 |

Products

7 | {items.map(item => ( 8 | addToCart(item.id)} 12 | /> 13 | ))} 14 |
15 | ) 16 | -------------------------------------------------------------------------------- /examples/shopping-cart/index.js: -------------------------------------------------------------------------------- 1 | import React, { createContext } from "react" 2 | import ProductList from "./components/ProductList" 3 | import Cart from "./components/Cart" 4 | import { 5 | StatusStream, 6 | ProductsStream, 7 | CartStream, 8 | StoreStream, 9 | Debug 10 | } from "./streams" 11 | 12 | export default () => ( 13 |
14 |

Shopping Cart Example

15 |
16 | 17 |
18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 |
26 | ) 27 | -------------------------------------------------------------------------------- /examples/shopping-cart/pipes.js: -------------------------------------------------------------------------------- 1 | import { of, pipe } from "rxjs" 2 | import { map } from "rxjs/operators" 3 | 4 | export const removeFromCartPipe = map(id => ({ cart }) => { 5 | const cartItemIndex = cart.findIndex(({ id: _id }) => _id === id) 6 | 7 | const cartItem = cart[cartItemIndex] 8 | const updateCartItem = { ...cartItem, quantity: cartItem.quantity - 1 } 9 | 10 | return of({ 11 | cart: [ 12 | ...cart.slice(0, cartItemIndex), 13 | updateCartItem, 14 | ...cart.slice(cartItemIndex + 1) 15 | ] 16 | }) 17 | }) 18 | 19 | export const addToCartPipe = pipe( 20 | map(id => ({ cart, ...rest }) => { 21 | const cartItemIndex = cart.findIndex(({ id: _id }) => _id === id) 22 | const cartItem = cart[cartItemIndex] 23 | 24 | return { 25 | cart: [ 26 | ...cart.slice(0, cartItemIndex), 27 | { 28 | ...cartItem, 29 | quantity: cartItem.quantity + 1 30 | }, 31 | ...cart.slice(cartItemIndex + 1) 32 | ] 33 | } 34 | }) 35 | ) 36 | 37 | export const calcTotal = map(({ total, items, ...rest }) => ({ 38 | total: items 39 | .reduce((total, item) => total + item.price * item.quantity, 0) 40 | .toFixed(2), 41 | items, 42 | ...rest 43 | })) 44 | 45 | export const checkoutPipe = pipe() 46 | -------------------------------------------------------------------------------- /examples/shopping-cart/plans.js: -------------------------------------------------------------------------------- 1 | import { plan } from "react-streams" 2 | import { addToCartPipe, checkoutPipe, removeFromCartPipe } from "./pipes" 3 | 4 | export const addToCart = plan(addToCartPipe) 5 | 6 | export const removeFromCart = plan(removeFromCartPipe) 7 | 8 | export const checkout = plan(checkoutPipe) 9 | -------------------------------------------------------------------------------- /examples/shopping-cart/streams.js: -------------------------------------------------------------------------------- 1 | import { combineSources, scanPlans, stream } from "react-streams" 2 | import { concat, from, merge, of } from "rxjs" 3 | import { 4 | delay, 5 | map, 6 | mapTo, 7 | partition, 8 | scan, 9 | shareReplay, 10 | switchMap 11 | } from "rxjs/operators" 12 | import { calcTotal } from "./pipes" 13 | import { addToCart, checkout, removeFromCart } from "./plans" 14 | 15 | const products = [ 16 | { id: 1, title: "iPad 4 Mini", price: 500.01, inventory: 2 }, 17 | { id: 2, title: "H&M T-Shirt White", price: 10.99, inventory: 10 }, 18 | { id: 3, title: "Charli XCX - Sucker CD", price: 19.99, inventory: 5 } 19 | ] 20 | 21 | const checkout$ = from(checkout) 22 | 23 | const [checkoutValid$, checkoutInvalid$] = checkout$.pipe( 24 | partition(items => items.filter(item => item.quantity).length < 3) 25 | ) 26 | 27 | const checkoutRequest$ = checkoutValid$.pipe( 28 | switchMap(items => { 29 | //fake an ajax request delay 30 | return of(items).pipe(delay(1000)) 31 | }) 32 | ) 33 | 34 | const status$ = merge( 35 | checkout$.pipe(mapTo({ error: "Checkout pending..." })), 36 | checkoutInvalid$.pipe( 37 | mapTo({ error: "Can only checkout 2 unique items 🤷‍♀️" }) 38 | ), 39 | checkoutRequest$.pipe( 40 | switchMap(() => 41 | concat(of({ error: "Success" }), of({ error: "" }).pipe(delay(1000))) 42 | ) 43 | ) 44 | ).pipe(shareReplay(1)) 45 | 46 | const products$ = concat(of({ products }), checkoutRequest$).pipe( 47 | scan(({ products }, items) => { 48 | return { 49 | products: products.map(item => { 50 | const { quantity } = items.find(({ id }) => id === item.id) 51 | return { 52 | ...item, 53 | inventory: item.inventory - quantity 54 | } 55 | }) 56 | } 57 | }), 58 | shareReplay(1) 59 | ) 60 | 61 | // products$.subscribe(products => console.log({ products })) 62 | 63 | const cart$ = products$ 64 | .pipe( 65 | map(({ products }) => ({ 66 | cart: products.map(product => ({ id: product.id, quantity: 0 })) 67 | })), 68 | scanPlans({ addToCart, removeFromCart }) 69 | ) 70 | .pipe(shareReplay(1)) 71 | 72 | // cart$.subscribe(cart => console.log({ cart })) 73 | 74 | const store$ = combineSources(products$, cart$).pipe( 75 | map(({ products, cart, ...rest }) => ({ 76 | items: products.map(product => { 77 | const cartItem = cart.find(({ id }) => id === product.id) 78 | return { 79 | ...product, 80 | quantity: cartItem.quantity, 81 | remaining: product.inventory - cartItem.quantity 82 | } 83 | }), 84 | ...rest, 85 | checkout 86 | })), 87 | shareReplay(1) 88 | ) 89 | 90 | // store$.subscribe(store => console.log({ store })) 91 | 92 | export const StatusStream = stream(status$) 93 | export const ProductsStream = stream(products$) 94 | export const CartStream = stream(cart$) 95 | 96 | export const StoreStream = stream(store$, calcTotal) 97 | 98 | export const Debug = title => data => ( 99 |
106 |

{title}

107 | {JSON.stringify(data)} 108 |
109 | ) 110 | -------------------------------------------------------------------------------- /examples/stepper/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { scanPlans, plan, stream, streamProps } from "react-streams" 3 | import { from, merge, of, pipe } from "rxjs" 4 | import { map, mergeScan, scan, switchMap } from "rxjs/operators" 5 | 6 | const inputNumAs = key => pipe(map(e => ({ [key]: Number(e.target.value) }))) 7 | 8 | const StepperControl = streamProps( 9 | switchMap(({ min, max, step }) => { 10 | const onUpdateMin = plan(inputNumAs("min")) 11 | const onUpdateMax = plan(inputNumAs("max")) 12 | const onUpdateStep = plan(inputNumAs("step")) 13 | 14 | const props$ = of({ 15 | min, 16 | max, 17 | step, 18 | onUpdateMin, 19 | onUpdateMax, 20 | onUpdateStep 21 | }) 22 | 23 | //"converge" is inappropriate here due to the custom `scan` 24 | return merge( 25 | props$, 26 | from(onUpdateMin), 27 | from(onUpdateMax), 28 | from(onUpdateStep) 29 | ).pipe( 30 | scan(({ min, max, step }, next) => { 31 | const diff = max - min 32 | const updateStep = (step, diff) => 33 | step === diff && diff > 1 ? step - 1 : step 34 | 35 | if (next.min) { 36 | return { 37 | min: next.min >= max ? min : next.min, 38 | max, 39 | step: updateStep(step, diff) 40 | } 41 | } 42 | if (next.max) { 43 | return { 44 | min, 45 | max: next.max <= min ? max : next.max, 46 | step: updateStep(step, diff) 47 | } 48 | } 49 | 50 | if (next.step) { 51 | return { 52 | min, 53 | max, 54 | step: next.step === max - min + 1 ? step : next.step 55 | } 56 | } 57 | 58 | return { 59 | min, 60 | max, 61 | step 62 | } 63 | }) 64 | ) 65 | }) 66 | ) 67 | 68 | const Stepper = streamProps( 69 | //mergeScan when you need to compare original props to updated props 70 | mergeScan((prevProps, { min, max, step, defaultValue }) => { 71 | // Very helpful to compare prev/next props :) 72 | // console.table({ 73 | // props, 74 | // prevProps, 75 | // nextProps: { min, max, step, defaultValue } 76 | // }) 77 | const onDec = plan( 78 | map(() => ({ value }) => ({ 79 | value: value - step < min ? value : value - step 80 | })) 81 | ) 82 | const onInc = plan( 83 | map(() => ({ value }) => ({ 84 | value: value + step > max ? value : value + step 85 | })) 86 | ) 87 | const onChange = plan( 88 | map(e => Number(e.target.value)), 89 | map(value => () => ({ value })) 90 | ) 91 | 92 | const onBlur = plan( 93 | map(e => Number(e.target.value)), 94 | map(({ value }) => () => ({ 95 | value: Math.min(max, Math.max(min, value)) 96 | })) 97 | ) 98 | 99 | const value = prevProps 100 | ? Math.max(min, Math.min(max, prevProps.value)) 101 | : defaultValue 102 | 103 | return of({ 104 | value, 105 | min, 106 | max, 107 | step 108 | }).pipe( 109 | scanPlans({ 110 | onDec, 111 | onInc, 112 | onChange, 113 | onBlur 114 | }) 115 | ) 116 | }) 117 | ) 118 | 119 | export default () => ( 120 | 121 | {({ min, max, step, onUpdateMin, onUpdateMax, onUpdateStep }) => ( 122 |
123 |
124 | 127 | 130 | 133 |
134 | 135 | {({ onDec, value, onBlur, onInc, onChange, min, max, step }) => ( 136 |
137 | 140 | 148 | 151 |
152 | 153 | 161 |
162 | )} 163 |
164 |
165 | )} 166 |
167 | ) 168 | -------------------------------------------------------------------------------- /examples/stream/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { stream } from "react-streams" 3 | import { interval } from "rxjs" 4 | import { map } from "rxjs/operators" 5 | 6 | const count$ = interval(250).pipe(map(count => ({ count }))) 7 | 8 | const Counter = stream(count$) 9 | 10 | export default () => ( 11 |
12 |

Subscribe to a Stream

13 | {({ count }) =>
{count}
}
14 |
15 | ) 16 | -------------------------------------------------------------------------------- /examples/streamProps/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { streamProps } from "react-streams" 3 | import { map } from "rxjs/operators" 4 | 5 | const mapGreeting = map(({ greeting, name }) => ({ 6 | message: `${greeting}, ${name}!` 7 | })) 8 | 9 | const HelloWorld = streamProps(mapGreeting) 10 | 11 | export default () => ( 12 |
13 |

Stream Props to Children

14 | 15 | {({ message }) =>
{message}
} 16 |
17 | 18 | {({ message }) =>
{message}
} 19 |
20 |
21 | ) 22 | -------------------------------------------------------------------------------- /examples/todos/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { 3 | plan, 4 | scanPlans, 5 | scanStreams, 6 | stream, 7 | streamProps 8 | } from "react-streams" 9 | import { from, of, pipe } from "rxjs" 10 | import { ajax } from "rxjs/ajax" 11 | import { map, mapTo, switchMap, tap, withLatestFrom } from "rxjs/operators" 12 | 13 | const url = process.env.DEV 14 | ? "/api/todos" 15 | : // Get your own, free todos API 🙌 https://glitch.com/edit/#!/import/github/johnlindquist/todos-api 16 | "https://dandelion-bonsai.glitch.me/todos" 17 | 18 | const HEADERS = { "Content-Type": "application/json" } 19 | 20 | const onChange = plan(map(event => ({ current: event.target.value }))) 21 | 22 | const addTodo = plan( 23 | tap(event => event.preventDefault()), 24 | withLatestFrom(onChange, (_, { current }) => current), 25 | map(text => ({ url, todos }) => 26 | ajax.post(url, { text, done: false }, HEADERS).pipe( 27 | map(({ response: todo }) => ({ 28 | url, 29 | todos: [...todos, todo] 30 | })) 31 | ) 32 | ) 33 | ) 34 | 35 | const TodoForm = stream( 36 | of({ current: "", addTodo }).pipe( 37 | scanPlans({ onChange }), 38 | scanStreams(from(addTodo).pipe(mapTo({ current: "" }))) 39 | ) 40 | ) 41 | 42 | const toggleTodo = plan( 43 | map(todo => ({ url, todos }) => 44 | ajax 45 | .patch( 46 | `${url}/${todo.id}`, 47 | { 48 | ...todo, 49 | done: todo.done ? false : true 50 | }, 51 | HEADERS 52 | ) 53 | .pipe( 54 | map(({ response: todo }) => ({ 55 | url, 56 | todos: todos.map(_todo => (_todo.id === todo.id ? todo : _todo)) 57 | })) 58 | ) 59 | ) 60 | ) 61 | 62 | const deleteTodo = plan( 63 | map(todo => ({ url, todos }) => 64 | ajax 65 | .delete( 66 | `${url}/${todo.id}`, 67 | { 68 | ...todo, 69 | done: todo.done ? false : true 70 | }, 71 | HEADERS 72 | ) 73 | .pipe( 74 | mapTo({ 75 | url, 76 | todos: todos.filter(_todo => _todo.id !== todo.id) 77 | }) 78 | ) 79 | ) 80 | ) 81 | 82 | const Todo = ({ todo, toggleTodo, deleteTodo }) => ( 83 |
  • 88 | 94 | {todo.text} 95 | 96 | 99 | 102 |
  • 103 | ) 104 | 105 | const Todos = streamProps( 106 | pipe( 107 | switchMap(({ url }) => 108 | ajax(url).pipe(map(({ response: todos }) => ({ url, todos }))) 109 | ), 110 | scanPlans({ toggleTodo, deleteTodo, addTodo }) 111 | ) 112 | ) 113 | 114 | export default () => ( 115 |
    116 | 117 | {({ current, onChange, addTodo }) => ( 118 |
    122 | 131 | 132 |
    133 | )} 134 |
    135 | 136 | {({ todos, toggleTodo, deleteTodo }) => 137 | todos.map(todo => ( 138 | 139 | )) 140 | } 141 | 142 |
    143 | ) 144 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | EGH_React_RxJS_Library-Purple -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-streams", 3 | "version": "13.6.5", 4 | "description": "Simple Streams for React", 5 | "main": "dist/react-streams.js", 6 | "module": "dist/react-streams.esm.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/johnlindquist/react-streams.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/johnlindquist/react-streams/issues" 17 | }, 18 | "homepage": "https://github.com/johnlindquist/react-streams#readme", 19 | "scripts": { 20 | "build": "rollup -c rollup.config.js", 21 | "prepublish": "run-p build", 22 | "dev": "tsc -w", 23 | "test": "jest", 24 | "examples": "run-p examples:*", 25 | "examples:client": "cd examples && poi --port 4321", 26 | "examples:server": "cd cypress/fixtures && json-server --port 4322 db.js", 27 | "cypress:run": "cypress run", 28 | "cypress:run:video": "cypress run --config video=true --record", 29 | "cypress:open": "cypress open", 30 | "e2e": "concurrently -k --success first 'npm run examples' 'npm run cypress:run'", 31 | "e2e:video": "concurrently -k --success first 'npm run examples' 'npm run cypress:run:video'", 32 | "e2e:open": "run-p examples cypress:open", 33 | "docs": "poi --config docs/poi.config.js", 34 | "disable-prepublish-stop-running-on-install-before-build": "run-p e2e" 35 | }, 36 | "keywords": [ 37 | "react", 38 | "rxjs", 39 | "streams" 40 | ], 41 | "author": "John Lindquist", 42 | "license": "ISC", 43 | "devDependencies": { 44 | "@babel/preset-react": "^7.0.0-beta.46", 45 | "@babel/preset-stage-0": "^7.0.0-beta.46", 46 | "@cypress/webpack-preprocessor": "^2.0.1", 47 | "@mdx-js/loader": "^0.8.1", 48 | "@mdx-js/mdx": "^0.8.1", 49 | "@types/jest": "^22.2.3", 50 | "@types/react": "^16.3.2", 51 | "babel-preset-stage-0": "^6.24.1", 52 | "concurrently": "^3.5.1", 53 | "cypress": "^3.0.1", 54 | "jest": "^22.4.3", 55 | "json-server": "^0.12.2", 56 | "npm-run-all": "^4.1.2", 57 | "poi": "^10.1.5", 58 | "react": "^16.3.2", 59 | "react-dom": "^16.3.2", 60 | "react-loadable": "^5.4.0", 61 | "react-router": "^4.2.0", 62 | "react-router-dom": "^4.2.2", 63 | "rollup": "^0.60.1", 64 | "rollup-plugin-terser": "^1.0.1", 65 | "rollup-plugin-typescript2": "^0.14.0", 66 | "rollup-plugin-uglify": "^4.0.0", 67 | "rxjs": "^6.2.0", 68 | "ts-jest": "^22.4.4", 69 | "ts-snippet": "^3.1.1", 70 | "typescript": "^2.8.1" 71 | }, 72 | "jest": { 73 | "globals": { 74 | "ts-jest": { 75 | "tsConfigFile": "tsconfig-test.json" 76 | } 77 | }, 78 | "moduleFileExtensions": [ 79 | "ts", 80 | "tsx", 81 | "js", 82 | "jsx", 83 | "json", 84 | "node" 85 | ], 86 | "testPathIgnorePatterns": [ 87 | "/dist/", 88 | "/node_modules/" 89 | ], 90 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 91 | "transform": { 92 | "^.+\\.tsx?$": "ts-jest" 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2" 2 | import { uglify } from "rollup-plugin-uglify" 3 | import { terser } from "rollup-plugin-terser" 4 | 5 | module.exports = [ 6 | { 7 | input: "src/index.ts", 8 | output: { 9 | file: "dist/react-streams.esm.js", 10 | format: "es" 11 | }, 12 | plugins: [typescript(), terser()], 13 | external: ["react", "rxjs", "rxjs/operators"] 14 | }, 15 | { 16 | input: "src/index.ts", 17 | output: { 18 | file: "dist/react-streams.js", 19 | format: "umd", 20 | name: "ReactStreams", 21 | globals: { 22 | react: "React", 23 | rxjs: "Rx" 24 | } 25 | }, 26 | external: ["react", "rxjs", "rxjs/operators"], 27 | 28 | plugins: [typescript(), uglify()] 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { combineSources } from "./observable/combineSources" 2 | export { assign } from "./operators/assign" 3 | export { patchScan } from "./operators/patchScan" 4 | export { scanPlans } from "./operators/scanPlans" 5 | export { scanSequence } from "./operators/scanSequence" 6 | export { scanStreams } from "./operators/scanStreams" 7 | export { plan } from "./plan" 8 | export { Stream, stream } from "./stream" 9 | export { StreamProps, streamProps } from "./streamProps" 10 | -------------------------------------------------------------------------------- /src/observable/combineSources.ts: -------------------------------------------------------------------------------- 1 | import { combineLatest } from "rxjs" 2 | import { map } from "rxjs/operators" 3 | 4 | export const combineSources = (...sources) => 5 | combineLatest(...sources).pipe( 6 | map(values => values.reduce((a, c) => ({ ...a, ...c }), {})) 7 | ) 8 | -------------------------------------------------------------------------------- /src/operators/assign.ts: -------------------------------------------------------------------------------- 1 | import { map } from "rxjs/operators" 2 | 3 | export const assign = object => map(value => ({ ...value, ...object })) 4 | -------------------------------------------------------------------------------- /src/operators/patchScan.ts: -------------------------------------------------------------------------------- 1 | import { isObservable, of, pipe } from "rxjs" 2 | import { mergeScan, switchMap } from "rxjs/operators" 3 | 4 | export const patchScan: any = pipe( 5 | (mergeScan as any)((state, update) => { 6 | if (update instanceof Function) { 7 | const result = update(state) 8 | if (isObservable(result)) { 9 | return result.pipe( 10 | switchMap(foo => { 11 | if (foo instanceof Function) return of(foo(state)) 12 | return of(foo) 13 | }) 14 | ) 15 | } 16 | 17 | return of(result) 18 | } 19 | return of({ ...state, ...update }) 20 | }) 21 | ) 22 | -------------------------------------------------------------------------------- /src/operators/scanPlans.ts: -------------------------------------------------------------------------------- 1 | import { merge } from "rxjs" 2 | import { patchScan } from "../operators/patchScan" 3 | import { assign } from "../operators/assign" 4 | import { curry } from "../utils/curry" 5 | 6 | export const scanPlans = curry((plans, source) => 7 | merge(source, ...(Object.values(plans) as any[])).pipe( 8 | patchScan, 9 | assign(plans) 10 | ) 11 | ) 12 | -------------------------------------------------------------------------------- /src/operators/scanSequence.ts: -------------------------------------------------------------------------------- 1 | import { from } from "rxjs" 2 | import { concatMap, mergeScan } from "rxjs/operators" 3 | 4 | export const scanSequence = (...plans) => 5 | concatMap(value => 6 | from([...plans]).pipe( 7 | mergeScan( 8 | (prev, next: any) => { 9 | console.log({ prev, next }) 10 | return next(prev) 11 | }, 12 | value, 13 | 1 14 | ) 15 | ) 16 | ) 17 | -------------------------------------------------------------------------------- /src/operators/scanStreams.ts: -------------------------------------------------------------------------------- 1 | import { merge } from "rxjs" 2 | import { patchScan } from "../operators/patchScan" 3 | 4 | export const scanStreams = source => (...streams) => 5 | merge(source, ...streams).pipe(patchScan) 6 | -------------------------------------------------------------------------------- /src/plan.ts: -------------------------------------------------------------------------------- 1 | import { Observable, observable, from, asyncScheduler } from "rxjs" 2 | import { first, share } from "rxjs/operators" 3 | 4 | export function plan(...operators) { 5 | let next 6 | 7 | const o$ = new Observable(observer => { 8 | next = (...arg) => { 9 | observer.next(...arg) 10 | return o$.pipe(first()) 11 | } 12 | }).pipe( 13 | ...operators, 14 | share() 15 | ) 16 | 17 | const unsubscribe = o$.subscribe() 18 | next["unsubscribe"] = unsubscribe 19 | next[observable] = () => o$ 20 | return next 21 | } 22 | -------------------------------------------------------------------------------- /src/stream.ts: -------------------------------------------------------------------------------- 1 | import { Component, createElement } from "react" 2 | import { 3 | from, 4 | Observable, 5 | OperatorFunction, 6 | Subscription, 7 | throwError 8 | } from "rxjs" 9 | import { 10 | distinctUntilChanged, 11 | map 12 | } from "rxjs/operators" 13 | 14 | export class Stream extends Component< 15 | { 16 | pipe: OperatorFunction> 17 | }, 18 | any 19 | > { 20 | subscription?: Subscription 21 | _isMounted = false 22 | 23 | configureSource(props, config) { 24 | const { 25 | source = throwError("No source provided") 26 | } = config ? config : props 27 | return from(source) 28 | } 29 | 30 | constructor(props, context, config) { 31 | super(props, context) 32 | 33 | const { pipe: sourcePipe } = config ? config : props 34 | 35 | const state$ = this.configureSource( 36 | props, 37 | config 38 | ).pipe( 39 | distinctUntilChanged(), 40 | sourcePipe || (x => x), 41 | map((state: any) => ({ 42 | ...state, 43 | children: 44 | state.children || 45 | state.render || 46 | props.children || 47 | props.render 48 | })) 49 | ) 50 | 51 | this.subscription = state$.subscribe(state => { 52 | if (this._isMounted) { 53 | this.setState(() => state) 54 | } else { 55 | this.state = state 56 | } 57 | }) 58 | } 59 | 60 | componentDidMount() { 61 | this._isMounted = true 62 | } 63 | 64 | render(): any { 65 | return this.state 66 | ? createElement(this.state.children, this.state) 67 | : null 68 | } 69 | componentWillUnmount() { 70 | if (this.subscription) 71 | this.subscription.unsubscribe() 72 | } 73 | } 74 | 75 | export const stream = (source, pipe) => ( 76 | props, 77 | context 78 | ) => new Stream(props, context, { source, pipe }) 79 | -------------------------------------------------------------------------------- /src/streamProps.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from "./stream" 2 | import { plan } from "./plan" 3 | import { concat, of } from "rxjs" 4 | 5 | export class StreamProps extends Stream { 6 | updateProps 7 | 8 | configureSource(props) { 9 | this.updateProps = plan() 10 | return concat(of(props), this.updateProps) 11 | } 12 | 13 | componentDidUpdate() { 14 | this.updateProps(this.props) 15 | } 16 | } 17 | 18 | export const streamProps = pipe => (props, context) => 19 | new StreamProps(props, context, { pipe }) 20 | -------------------------------------------------------------------------------- /src/utils/curry.ts: -------------------------------------------------------------------------------- 1 | export const curry = fn => (...args) => 2 | args.length < fn.length ? curry(fn.bind(null, ...args)) : fn(...args) 3 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expecter } from "ts-snippet" 2 | 3 | const expectSnippet = expecter(code => ` 4 | import { from, Observable } from "rxjs" 5 | import { mapTo } from "rxjs/operators" 6 | import * as p from "ts-snippet/placeholders" 7 | import { PipedComponentType, pipeProps, source, SourceType } from "./src/index" 8 | ${code} 9 | `) 10 | 11 | // Note that in the overload signature tests, the type parameters for the first 12 | // pipeable operator have to be specified explicitly, as 'source' is not a 13 | // method that's called on an observable. And if only one type parameter is 14 | // specified, the remaining type parameters are inferred to be {}. 15 | 16 | describe("pipeProps", () => { 17 | describe("overload signatures", () => { 18 | it("should infer the correct types", () => { 19 | const componentType = t => `ComponentType<${t} & { children?: (props: ${t}) => ReactNode; render?: (props: ${t}) => ReactNode; }>` 20 | const expect = expectSnippet(` 21 | const m = mapTo 22 | const m1 = m(p.c1) 23 | const c0 = pipeProps() 24 | const c1 = pipeProps(m1) 25 | const c2 = pipeProps(m1, m(p.c2)) 26 | const c3 = pipeProps(m1, m(p.c2), m(p.c3)) 27 | const c4 = pipeProps(m1, m(p.c2), m(p.c3), m(p.c4)) 28 | const c5 = pipeProps(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5)) 29 | const c6 = pipeProps(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6)) 30 | const c7 = pipeProps(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6), m(p.c7)) 31 | const c8 = pipeProps(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6), m(p.c7), m(p.c8)) 32 | const c9 = pipeProps(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6), m(p.c7), m(p.c8), m(p.c9)) 33 | const c10 = pipeProps(m(p.c1), m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6), m(p.c7), m(p.c8), m(p.c9), m(p.c10)) 34 | `) 35 | expect.toSucceed() 36 | expect.toInfer("c0", componentType("T0")) 37 | expect.toInfer("c1", componentType("T1")) 38 | expect.toInfer("c2", componentType("T2")) 39 | expect.toInfer("c3", componentType("T3")) 40 | expect.toInfer("c4", componentType("T4")) 41 | expect.toInfer("c5", componentType("T5")) 42 | expect.toInfer("c6", componentType("T6")) 43 | expect.toInfer("c7", componentType("T7")) 44 | expect.toInfer("c8", componentType("T8")) 45 | expect.toInfer("c9", componentType("T9")) 46 | expect.toInfer("c10", componentType("T10")) 47 | }); 48 | }) 49 | }) 50 | 51 | describe("source", () => { 52 | describe("overload signatures", () => { 53 | it("should infer the correct types", () => { 54 | const expect = expectSnippet(` 55 | const m = mapTo 56 | const m1 = m(p.c1) 57 | const s0 = source() 58 | const s1 = source(m1) 59 | const s2 = source(m1, m(p.c2)) 60 | const s3 = source(m1, m(p.c2), m(p.c3)) 61 | const s4 = source(m1, m(p.c2), m(p.c3), m(p.c4)) 62 | const s5 = source(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5)) 63 | const s6 = source(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6)) 64 | const s7 = source(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6), m(p.c7)) 65 | const s8 = source(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6), m(p.c7), m(p.c8)) 66 | const s9 = source(m1, m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6), m(p.c7), m(p.c8), m(p.c9)) 67 | const s10 = source(m(p.c1), m(p.c2), m(p.c3), m(p.c4), m(p.c5), m(p.c6), m(p.c7), m(p.c8), m(p.c9), m(p.c10)) 68 | `) 69 | expect.toSucceed() 70 | expect.toInfer("s0", "SourceType") 71 | expect.toInfer("s1", "SourceType") 72 | expect.toInfer("s2", "SourceType") 73 | expect.toInfer("s3", "SourceType") 74 | expect.toInfer("s4", "SourceType") 75 | expect.toInfer("s5", "SourceType") 76 | expect.toInfer("s6", "SourceType") 77 | expect.toInfer("s7", "SourceType") 78 | expect.toInfer("s8", "SourceType") 79 | expect.toInfer("s9", "SourceType") 80 | expect.toInfer("s10", "SourceType") 81 | }); 82 | 83 | it("should be callable as a handler", () => { 84 | const expect = expectSnippet(` 85 | const m1 = mapTo(p.c1) 86 | const s1 = source(m1) 87 | s1(p.c0) 88 | `) 89 | expect.toSucceed() 90 | }) 91 | 92 | it("should enforce the handler's type", () => { 93 | const expect = expectSnippet(` 94 | const m1 = mapTo(p.c1) 95 | const s1 = source(m1) 96 | s1(p.c2) 97 | `) 98 | expect.toFail(/not assignable/i) 99 | }) 100 | 101 | it("should be convertible to an observable", () => { 102 | const expect = expectSnippet(` 103 | const m1 = mapTo(p.c1) 104 | const s1 = source(m1) 105 | const o1 = from(s1) 106 | `) 107 | expect.toSucceed() 108 | expect.toInfer("o1", "Observable") 109 | }) 110 | }) 111 | }) -------------------------------------------------------------------------------- /tsconfig-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.test.ts"] 4 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": 5 | "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */, 6 | "module": 7 | "es2015" /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 8 | "lib": [ 9 | "dom", 10 | "es2017" 11 | ] /* Specify library files to be included in the compilation: */, 12 | "allowJs": false /* Allow javascript files to be compiled. */, 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | "jsx": 15 | "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 16 | "declaration": true /* Generates corresponding '.d.ts' file. */, 17 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "./dist" /* Redirect output structure to the directory. */, 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | "removeComments": true /* Do not emit comments to output. */, 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | "moduleResolution": 42 | "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [] /* List of folders to include type definitions from. */, 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | 56 | /* Experimental Options */ 57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */, 59 | "skipLibCheck": true 60 | }, 61 | "include": ["./src/**/*.ts"] 62 | } 63 | --------------------------------------------------------------------------------