├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src ├── ReactiveQuery.js ├── __tests__ │ └── reduceStore.ts ├── defs.ts ├── index.ts └── reduceStore.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | coverage 4 | dist 5 | node_modules 6 | npm-debug.log 7 | package-lock.json 8 | typings 9 | yarn-error.log 10 | yarn.lock 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | node_modules 3 | typings 4 | tsconfig.json 5 | typings.json 6 | tslint.json 7 | dist/test 8 | yarn.lock 9 | coverage 10 | .vscode 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "6" 5 | - "4" 6 | install: 7 | - npm install -g coveralls 8 | - npm install 9 | 10 | script: 11 | - npm test 12 | - npm run coverage 13 | - coveralls < ./coverage/lcov.info || true # ignore coveralls error 14 | 15 | # Allow Travis tests to run in containers. 16 | # sudo: false 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### 0.1.1 4 | 5 | * Add support for Render Prop Function 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Theodor Diaconu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apollo Live Client 2 | 3 | This project is sponsored by [Cult of Coders](https://www.cultofcoders.com) 4 | 5 | This package provides an easy way to work with [`apollo-live-server`](https://github.com/cult-of-coders/apollo-live-server) subscriptions. 6 | 7 | ## Usage 8 | 9 | ```js 10 | import gql from 'graphql-tag'; 11 | import { ReactiveQuery } from 'apollo-live-client'; 12 | 13 | const GET_MESSAGES = gql` 14 | query { 15 | messages(threadId: String) { 16 | _id 17 | text 18 | createdAt 19 | } 20 | } 21 | `; 22 | 23 | const SUBSCRIBE_MESSAGES = gql` 24 | subscription { 25 | messages(threadId: String) { 26 | event 27 | doc { 28 | _id 29 | text 30 | createdAt 31 | } 32 | } 33 | } 34 | `; 35 | ``` 36 | 37 | For this system to work, the subscription root (`messages`) needs to be the same as the query . And you can query for more data from other fields in the RootQuery. 38 | 39 | ```js 40 | const MessagesWithData = () => ( 41 | 46 | {({ data: { notifications }, loading, error }) => { 47 | if (loading) return ; 48 | if (error) return ; 49 | 50 | return ; 51 | }} 52 | 53 | ); 54 | ``` 55 | 56 | Any other prop passed to `ReactiveQuery` that is not `subscription` is going to be passed to the actual `Query` object behind the scenes. 57 | 58 | ## Customisability 59 | 60 | You can customise the behavior of how you handle incomming data from subscriptions, by rolling out your 61 | own `subscribeToMore` and custom `updateQuery` method. 62 | 63 | Read more about this here: 64 | https://www.apollographql.com/docs/react/advanced/subscriptions.html 65 | 66 | ```js 67 | import { reduceStore } from 'apollo-live-client'; 68 | 69 | // And in your updateQuery handler of subscribeToMore: 70 | subscribeToMore({ 71 | document: SUBSCRIBE_MESSAGES, 72 | variables: {}, 73 | updateQuery: (prev, {subscriptionData}) { 74 | const reactiveEvent = subscriptionData.data.notifications; 75 | return Object.assign({}, prev, { 76 | notifications: reduceStore(reactiveEvent, prev.notifications) 77 | }) 78 | }, 79 | }) 80 | ``` 81 | 82 | ## Bare-bones subscription 83 | 84 | To provide you with even more flexibility, you can just roll your own subscription handler and reducer: 85 | 86 | ```js 87 | // client = Apollo Client 88 | const observable = client.subscribe({ query: SUBSCRIBE_NEWSFEED }); 89 | const subscription = observable.subscribe({ 90 | next({ data }) { 91 | // data is your payload 92 | // do something with it 93 | }, 94 | }); 95 | subscription.unsubscribe(); 96 | ``` 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-live-client", 3 | "version": "0.2.1", 4 | "description": "Handles reactive events to easily work with Live Queries", 5 | "main": "dist/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/cult-of-coders/apollo-live-client.git" 9 | }, 10 | "scripts": { 11 | "clean": "rimraf dist coverage", 12 | "compile": "tsc", 13 | "pretest": "npm run compile", 14 | "test": "npm run testonly --", 15 | "posttest": "npm run lint", 16 | "lint": "tslint --type-check --project ./tsconfig.json ./src/**/*", 17 | "watch": "tsc -w", 18 | "testonly": "mocha --reporter spec --full-trace ./dist/__tests__/*.js", 19 | "testonly-watch": "mocha --reporter spec --full-trace ./dist/__tests__/*.js --watch", 20 | "coverage": "node ./node_modules/istanbul/lib/cli.js cover _mocha -- --full-trace ./dist/__tests__/*.js", 21 | "postcoverage": "remap-istanbul --input coverage/coverage.raw.json --type lcovonly --output coverage/lcov.info", 22 | "prepublishOnly": "npm run clean && npm run compile" 23 | }, 24 | "peerDependencies": { 25 | "react": "16.x", 26 | "react-apollo": "2.x", 27 | "prop-types": "15.x" 28 | }, 29 | "devDependencies": { 30 | "@types/graphql": "^0.11.3", 31 | "@types/mocha": "^2.2.39", 32 | "@types/node": "^8.0.28", 33 | "@types/react": "16.3.5", 34 | "chai": "^4.1.2", 35 | "chai-as-promised": "^7.1.1", 36 | "graphql": "^0.13.0", 37 | "istanbul": "^1.0.0-alpha.2", 38 | "mocha": "^6.1.1", 39 | "remap-istanbul": "^0.9.1", 40 | "rimraf": "^2.6.2", 41 | "sinon": "^7.3.1", 42 | "sinon-chai": "^2.9.0", 43 | "tslint": "^5.2.0", 44 | "typescript": "^3.4.2" 45 | }, 46 | "typings": "dist/index.d.ts", 47 | "typescript": { 48 | "definition": "dist/index.d.ts" 49 | }, 50 | "license": "MIT" 51 | } 52 | -------------------------------------------------------------------------------- /src/ReactiveQuery.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | import { reduceStore } from './reduceStore'; 4 | import { Query } from 'react-apollo'; 5 | 6 | export default class ReactiveQuery extends React.Component { 7 | static propTypes = { 8 | children: PropTypes.func.isRequired, 9 | query: PropTypes.object.isRequired, 10 | subscription: PropTypes.object.isRequired, 11 | }; 12 | 13 | render() { 14 | const { children, subscription, query, variables, ...rest } = this.props; 15 | 16 | return ( 17 | 18 | {props => { 19 | return ( 20 | 25 | {() => { 26 | return children(props); 27 | }} 28 | 29 | ); 30 | }} 31 | 32 | ); 33 | } 34 | } 35 | 36 | class Subscription extends React.Component { 37 | componentDidMount = () => { 38 | const { subscribeToMore, subscription, variables } = this.props; 39 | 40 | subscribeToMore({ 41 | document: subscription, 42 | variables: variables, 43 | updateQuery: (prev, { subscriptionData }) => { 44 | if (!subscriptionData.data) return prev; 45 | 46 | const storeName = Object.keys(subscriptionData.data)[0]; 47 | 48 | const newStore = Object.assign({}, prev, { 49 | [storeName]: reduceStore( 50 | subscriptionData.data[storeName], 51 | prev[storeName] 52 | ), 53 | }); 54 | 55 | return newStore; 56 | }, 57 | }); 58 | }; 59 | 60 | render() { 61 | const { children, ...rest } = this.props; 62 | 63 | return children(rest); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/__tests__/reduceStore.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { reduceStore } from '../reduceStore'; 3 | import { ReactiveEvent, StoreObject, Event } from '../defs'; 4 | 5 | describe('reduceStore', function() { 6 | it('Should run an update correctly', function() { 7 | const newStore = reduceStore( 8 | { 9 | event: Event.CHANGED, 10 | doc: { 11 | _id: '123', 12 | __typename: 'update', 13 | newField: 'new', 14 | }, 15 | }, 16 | [ 17 | { 18 | _id: '123', 19 | __typename: 'current', 20 | title: 'abc', 21 | }, 22 | { 23 | _id: '124', 24 | __typename: 'current', 25 | title: 'abcd', 26 | }, 27 | ] 28 | ); 29 | 30 | assert.lengthOf(newStore, 2); 31 | assert.isObject(newStore[0]); 32 | assert.isDefined(newStore[0].title); 33 | assert.isDefined(newStore[0].newField); 34 | // assert.equal('current', newStore[0].__typename); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/defs.ts: -------------------------------------------------------------------------------- 1 | export enum Event { 2 | ADDED = 'added', 3 | CHANGED = 'changed', 4 | REMOVED = 'removed', 5 | } 6 | 7 | export type ReactiveEvent = { 8 | event: Event; 9 | doc: StoreObject; 10 | }; 11 | 12 | export interface StoreObject { 13 | _id: string | number; 14 | __typename: string; 15 | [key: string]: any; 16 | } 17 | 18 | export type SubscribeMoreParam = { 19 | type: string; 20 | value: any; 21 | }; 22 | 23 | export interface SubscribeMoreConfig { 24 | name: string; 25 | params: { 26 | [key: string]: SubscribeMoreParam; 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { reduceStore } from './reduceStore'; 2 | export { default as ReactiveQuery } from './ReactiveQuery'; 3 | -------------------------------------------------------------------------------- /src/reduceStore.ts: -------------------------------------------------------------------------------- 1 | import { ReactiveEvent, StoreObject, Event } from './defs'; 2 | 3 | export function reduceStore( 4 | reactiveEvent: ReactiveEvent, 5 | store: StoreObject[] | StoreObject 6 | ) { 7 | if (store instanceof Array) { 8 | return reduceStoreArray(reactiveEvent, store); 9 | } 10 | return reduceStoreObject(reactiveEvent, store); 11 | } 12 | 13 | export function reduceStoreObject( 14 | reactiveEvent: ReactiveEvent, 15 | store: StoreObject 16 | ) { 17 | const { event, doc } = reactiveEvent; 18 | const { __typename, ...rest } = doc; 19 | 20 | if (event === Event.ADDED) { 21 | // check if it exists 22 | 23 | if (store) { 24 | return Object.assign({}, store, rest); 25 | } else { 26 | return doc; 27 | } 28 | } 29 | 30 | if (event === Event.CHANGED) { 31 | return Object.assign({}, store || {}, rest); 32 | } 33 | 34 | if (event === Event.REMOVED) { 35 | return null; 36 | } 37 | } 38 | 39 | export function reduceStoreArray( 40 | reactiveEvent: ReactiveEvent, 41 | store: StoreObject[] 42 | ): StoreObject[] { 43 | const { event, doc } = reactiveEvent; 44 | 45 | if (!doc._id) { 46 | throw new Error( 47 | 'The document does not have _id set, is it present in the subscription?' 48 | ); 49 | } 50 | 51 | if (event === Event.ADDED) { 52 | // check if it exists 53 | const { idx, found } = findIndexInStore(store, doc._id); 54 | 55 | if (found) { 56 | return [...store.slice(0, idx), found, ...store.slice(idx + 1)]; 57 | } 58 | 59 | return [doc, ...store]; 60 | } 61 | 62 | if (event === Event.CHANGED) { 63 | const { idx, found } = findIndexInStore(store, doc._id); 64 | 65 | if (!found) { 66 | return store; 67 | } 68 | 69 | const newFound = Object.assign({}, found, doc); 70 | return [...store.slice(0, idx), newFound, ...store.slice(idx + 1)]; 71 | } 72 | 73 | if (event === Event.REMOVED) { 74 | return store.filter(item => item._id !== doc._id); 75 | } 76 | } 77 | 78 | /** 79 | * @param store 80 | * @param _id 81 | */ 82 | function findIndexInStore(store, _id) { 83 | let foundIdx; 84 | const found = store.find((item, idx) => { 85 | if (item._id === _id) { 86 | foundIdx = idx; 87 | return true; 88 | } 89 | }); 90 | 91 | return { idx: foundIdx, found }; 92 | } 93 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "allowJs": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "lib": ["es6", "dom", "esnext"], 9 | "noImplicitAny": false, 10 | "rootDir": "./src", 11 | "outDir": "./dist", 12 | "allowSyntheticDefaultImports": true, 13 | "pretty": true, 14 | "jsx": "react", 15 | "removeComments": true, 16 | "typeRoots": ["node_modules/@types"] 17 | }, 18 | 19 | "include": ["**/*.ts", "**/*.tsx", "**/*.jsx"], 20 | "exclude": ["node_modules", "dist"] 21 | } 22 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "align": [ 4 | false, 5 | "parameters", 6 | "arguments", 7 | "statements" 8 | ], 9 | "ban": false, 10 | "class-name": true, 11 | "curly": true, 12 | "eofline": true, 13 | "forin": true, 14 | "indent": [ 15 | true, 16 | "spaces" 17 | ], 18 | "interface-name": false, 19 | "jsdoc-format": true, 20 | "label-position": true, 21 | "max-line-length": [ 22 | true, 23 | 140 24 | ], 25 | "member-access": true, 26 | "member-ordering": [ 27 | true, 28 | "public-before-private", 29 | "static-before-instance", 30 | "variables-before-functions" 31 | ], 32 | "no-any": false, 33 | "no-arg": true, 34 | "no-bitwise": true, 35 | "no-conditional-assignment": true, 36 | "no-consecutive-blank-lines": false, 37 | "no-console": [ 38 | true, 39 | "log", 40 | "debug", 41 | "info", 42 | "time", 43 | "timeEnd", 44 | "trace" 45 | ], 46 | "no-construct": true, 47 | "no-debugger": true, 48 | "no-duplicate-variable": true, 49 | "no-empty": true, 50 | "no-eval": true, 51 | "no-inferrable-types": false, 52 | "no-internal-module": true, 53 | "no-null-keyword": false, 54 | "no-require-imports": false, 55 | "no-shadowed-variable": true, 56 | "no-switch-case-fall-through": true, 57 | "no-trailing-whitespace": true, 58 | "no-unused-expression": true, 59 | "no-unused-variable": true, 60 | "no-use-before-declare": true, 61 | "no-var-keyword": true, 62 | "no-var-requires": true, 63 | "object-literal-sort-keys": false, 64 | "one-line": [ 65 | true, 66 | "check-open-brace", 67 | "check-catch", 68 | "check-else", 69 | "check-finally", 70 | "check-whitespace" 71 | ], 72 | "quotemark": [ 73 | true, 74 | "single", 75 | "avoid-escape" 76 | ], 77 | "radix": true, 78 | "semicolon": [ 79 | true, 80 | "always" 81 | ], 82 | "switch-default": true, 83 | "trailing-comma": [ 84 | true, 85 | { 86 | "multiline": "always", 87 | "singleline": "never" 88 | } 89 | ], 90 | "triple-equals": [ 91 | true, 92 | "allow-null-check" 93 | ], 94 | "typedef": [ 95 | false, 96 | "call-signature", 97 | "parameter", 98 | "arrow-parameter", 99 | "property-declaration", 100 | "variable-declaration", 101 | "member-variable-declaration" 102 | ], 103 | "typedef-whitespace": [ 104 | true, 105 | { 106 | "call-signature": "nospace", 107 | "index-signature": "nospace", 108 | "parameter": "nospace", 109 | "property-declaration": "nospace", 110 | "variable-declaration": "nospace" 111 | }, 112 | { 113 | "call-signature": "space", 114 | "index-signature": "space", 115 | "parameter": "space", 116 | "property-declaration": "space", 117 | "variable-declaration": "space" 118 | } 119 | ], 120 | "variable-name": [ 121 | true, 122 | "check-format", 123 | "allow-leading-underscore", 124 | "ban-keywords", 125 | "allow-pascal-case" 126 | ], 127 | "whitespace": [ 128 | true, 129 | "check-branch", 130 | "check-decl", 131 | "check-operator", 132 | "check-separator", 133 | "check-type" 134 | ] 135 | } 136 | } --------------------------------------------------------------------------------