removeFromCart(product.id)}
29 | />
30 | ))
31 | )
32 |
33 | return (
34 |
35 |
Your Cart
36 |
{nodes}
37 |
Total: ${total}
38 |
checkout(items)}
40 | disabled={checkoutAllowed ? "" : "disabled"}
41 | >
42 | Checkout
43 |
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 | {" X "}
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 | 0 ? "" : "disabled"}
20 | >
21 | {remaining > 0 ? "Add to cart" : "Sold Out"}
22 |
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 |
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 | toggleTodo(todo)}>
97 | ✓
98 |
99 | deleteTodo(todo)}>
100 | X
101 |
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 |
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 |
--------------------------------------------------------------------------------