├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .flowconfig ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── example ├── .gitignore ├── README.md ├── package.json ├── public │ └── index.html ├── src │ ├── App.js │ ├── Login.js │ └── index.js └── yarn.lock ├── flow-typed └── npm │ ├── firebase_v4.x.x.js │ ├── flux-standard-action_v1.x.x.js │ ├── redux-thunk_vx.x.x.js │ └── redux_v3.x.x.js ├── package.json ├── rollup.config.js ├── setupTests.js ├── src ├── Provider.js ├── __tests__ │ ├── __snapshots__ │ │ ├── actions.test.js.snap │ │ └── selectors.test.js.snap │ ├── actions.test.js │ ├── connect.test.js │ ├── connectAuth.test.js │ └── selectors.test.js ├── actions.js ├── connect.js ├── connectAuth.js ├── index.js ├── middleware │ └── batch.js ├── modules │ ├── __tests__ │ │ ├── actionTypes.test.js │ │ ├── fetchStatus.test.js │ │ └── query.test.js │ ├── actionTypes.js │ ├── fetchStatus.js │ ├── query.js │ └── requestAction.js ├── reducers │ ├── __tests__ │ │ ├── auth.test.js │ │ ├── collections.test.js │ │ ├── listeners.test.js │ │ └── queries.test.js │ ├── auth.js │ ├── collections.js │ ├── flux-standard-action.js │ ├── index.js │ ├── listeners.js │ ├── queries.js │ └── storage.js └── selectors.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "production": { 4 | "presets": [["env", { "modules": false }], "flow"], 5 | "plugins": [ 6 | "external-helpers", 7 | "syntax-flow", 8 | "transform-object-rest-spread", 9 | "transform-class-properties", 10 | "transform-react-jsx" 11 | ] 12 | }, 13 | "development": { 14 | "presets": ["env", "flow"], 15 | "plugins": [ 16 | "syntax-flow", 17 | "transform-object-rest-spread", 18 | "transform-class-properties", 19 | "transform-react-jsx" 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | flow-typed/**/*.js 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "jest/globals": true 6 | }, 7 | "parser": "babel-eslint", 8 | "extends": [ 9 | "prettier", 10 | "eslint:recommended", 11 | "plugin:react/recommended", 12 | "plugin:jest/recommended", 13 | "plugin:promise/recommended" 14 | ], 15 | "plugins": ["react", "flowtype", "jest", "promise"], 16 | "rules": { 17 | "no-use-before-define": ["error", "nofunc"], 18 | "flowtype/define-flow-type": "error", 19 | "promise/always-return": "off", 20 | "promise/avoid-new": "off" 21 | }, 22 | "globals": { "process": true, "Promise": true } 23 | } 24 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/build/.* 3 | .*/example/.* 4 | 5 | [include] 6 | 7 | [libs] 8 | flow-typed 9 | 10 | [lints] 11 | 12 | [options] 13 | 14 | [strict] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | *node_modules* 3 | 4 | # testing 5 | /coverage 6 | 7 | # production 8 | /build 9 | /lib 10 | 11 | # misc 12 | .DS_Store 13 | *.local 14 | *.log 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 120, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | cache: yarn 2 | language: node_js 3 | node_js: 4 | - "8" 5 | script: 6 | - yarn format:check 7 | - yarn lint:check 8 | - yarn flow check 9 | - yarn test 10 | - yarn build 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Paul Armstrong 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Stateful Firestore [![build status](https://img.shields.io/travis/paularmstrong/react-stateful-firestore/master.svg?style=flat-square)](https://travis-ci.org/paularmstrong/react-stateful-firestore) [![npm version](https://img.shields.io/npm/v/react-stateful-firestore.svg?style=flat-square)](https://www.npmjs.com/package/react-stateful-firestore) [![npm downloads](https://img.shields.io/npm/dm/react-stateful-firestore.svg?style=flat-square)](https://www.npmjs.com/package/react-stateful-firestore) 2 | 3 | Provides bindings for authentication, Firestore, messaging, and storage data in React. Caches Firestore and authentication with Redux to prevent lag on data that has already been queried. Updates in real-time by default. 4 | 5 | ## Key Goals 6 | 7 | * No new query language: uses Firestore queries, collections, etc. 8 | * Minimal setup 9 | * Speed 10 | * Stateful cache with real-time updating 11 | * Secure 12 | 13 | ## Quick Start 14 | 15 | ```sh 16 | npm install react-stateful-firestore 17 | # or 18 | yarn add react-stateful-firestore 19 | ``` 20 | 21 | #### Set up Firebase 22 | 23 | Install the Firebase dependencies: 24 | 25 | ```sh 26 | yarn add @firebase/app \ 27 | @firebase/auth \ 28 | @firebase/firestore \ 29 | @firebase/messaging \ 30 | @firebase/storage 31 | # or 32 | npm install --save @firebase/app \ 33 | @firebase/auth \ 34 | @firebase/firestore \ 35 | @firebase/messaging \ 36 | @firebase/storage 37 | ``` 38 | 39 | _Note: We install these packages independently instead of `firebase` to substantially reduce your final bundle size. You can still use `firebase` if you want, but it's not recommended._ 40 | 41 | Next, initialize your Firebase app. 42 | 43 | ```js 44 | import app from '@firebase/app'; 45 | 46 | const myApp = app.initializeApp({ 47 | apiKey: '', 48 | authDomain: '', 49 | databaseURL: '', 50 | projectId: '', 51 | storageBucket: '', 52 | messagingSenderId: '' 53 | }); 54 | ``` 55 | 56 | #### Provide the store 57 | 58 | Once your firebase application is initialized, create the React-Stateful-Firestore instance and render it in the Provider component. 59 | 60 | ```js 61 | import initReactFirestore, { Provider } from 'react-stateful-firestore'; 62 | 63 | initReactFirestore(myApp).then((store) => { 64 | ReactDOM.render( 65 | 66 | 67 | , 68 | document.getElementById('#root') 69 | ); 70 | }); 71 | ``` 72 | 73 | ## API 74 | 75 | Default Export: [`initReactFirestore`](#initreactfirestoreapp-usercollection) 76 | Other Exports: 77 | 78 | * [`connect`](#connectgetselectors) 79 | * [`connectAuth`](#connectauthhandleauthstate) 80 | * [`FetchStatus`](#fetchstatus) 81 | * [`resolveFetchStatus`](#resolvefetchstatusitems) 82 | * [`resolveInitialFetchStatus`](#resolveinitialfetchstatusitems) 83 | * [`Provider`](#providerstore-store) 84 | * [`Types`](#types) 85 | 86 | ```js 87 | import initReactFirestore, { 88 | connect, 89 | connectAuth, 90 | FetchStatus, 91 | Provider, 92 | resolveFetchStatus, 93 | resolveInitialFetchStatus 94 | } from 'react-stateful-firestore'; 95 | ``` 96 | 97 | ### initReactFirestore(app, userCollection?) 98 | 99 | This method initializes the backing store and authentication handling for your firebase/firestore application. 100 | 101 | | | argument | type | description | 102 | | ------- | ---------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 103 | | @param | `app` | firebase.app.App | Your firebase app, created with `firebase.initializeApp` | 104 | | @param | `userCollection` | string? | Optional. The collection name of where you store extra data about users. If provided, data will appear on the `authUserDoc` property provided by [`connectAuth`](#connectauthhandleauthstate). | 105 | | @return | | `Promise` | A promise providing a store object to send to the `Provider` component | 106 | 107 | **Example:** 108 | 109 | ```js 110 | import initReactFirestore, { Provider } from 'react-stateful-firestore'; 111 | 112 | initReactFirestore(app).then((store) => { 113 | ReactDOM.render( 114 | 115 | 116 | , 117 | document.getElementById('#root') 118 | ); 119 | }); 120 | ``` 121 | 122 | ### <Provider store={store}> 123 | 124 | This component is necessary to use [`connect`](#connectgetselectors) and [`connectAuth`](#connectauthhandleauthstate) within your application. It provides your Firebase app's instance and special data selectors used internally. It must be provided the `store` prop, as returned in the promise from [`initReactFirestore`](#initreactfirestoreapp-usercollection). 125 | 126 | ### connect(getSelectors) 127 | 128 | This is a higher order component creator function used to connect your components to data from your Firestore. It accepts a single argument, [getSelectors](#getselectorsselect-apis-props). 129 | 130 | Aside from your defined props in `getSelectors`, `connect` will also provide the following props to your component: 131 | 132 | | prop | type | description | 133 | | ----------- | ---------------------------- | -------------------------------------- | 134 | | `auth` | firebase.auth.Auth | Your app's firebase.auth instance | 135 | | `firestore` | firebase.firestore.Firestore | Your app's firebase.firestore instance | 136 | | `messaging` | firebase.messaging.Messaging | Your app's firebase.messaging | 137 | | `storage` | firebase.storage.Storage | Your app's firebase.storage | 138 | 139 | **Example:** 140 | 141 | ```js 142 | import { connect } from 'react-stateful-firestore'; 143 | 144 | class Article extends Component { 145 | static propTypes = { 146 | article: shape({ 147 | error: any, 148 | fetchStatus: oneOf(['none', 'loading', 'loaded', 'failed']).isRequired, // $Values 149 | doc: object // NOTE: `select(firestore.doc(...))` will provide `doc` (singular) 150 | }).isRequired, 151 | articleId: string.isRequired, 152 | comments: shape({ 153 | error: any, 154 | fetchStatus: oneOf(['none', 'loading', 'loaded', 'failed']).isRequired, // $Values 155 | docs: arrayOf(object) // NOTE: `select(firestore.collection(...))` will provide `docs` (plural) 156 | }).isRequired, 157 | promoImage: shape({ 158 | error: any, 159 | fetchStatus: oneOf(['none', 'loading', 'loaded', 'failed']).isRequired, // $Values, 160 | downloadUrl: string 161 | }), 162 | // Automatically provided by `connect()` 163 | auth: object.isRequired, 164 | firestore: object.isRequired, 165 | messaging: object.isRequired, 166 | storage: object.isRequired 167 | }; 168 | 169 | render() { ... } 170 | } 171 | 172 | export default connect((select, { firestore, storage }, props) => ({ 173 | article: select(firestore.doc(`articles/${props.articleId}`)), 174 | comments: select(firestore.collection('comments').where('articleId', '==', props.articleId)), 175 | promoImage: select(storage.ref('promoimage.jpg')) 176 | }))(Article); 177 | 178 | // render(); 179 | ``` 180 | 181 | #### getSelectors(select, apis, props) 182 | 183 | A function that returns a map of props to data selectors supplied to your final rendered component. 184 | 185 | | | argument | type | description | 186 | | ------- | -------- | ------------------------- | ---------------------------------------------------------- | 187 | | @param | `select` | [Select](#select) | A function that selects data from Firestore and/or Storage | 188 | | @param | `apis` | [SelectApis](#selectapis) | Your app's firebase APIs | 189 | | @param | `props` | object | The props provided to your component | 190 | | @return | | object | A map of selectors to prop names | 191 | 192 | #### Select 193 | 194 | | | argument | type | description | example | 195 | | ------- | --------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | 196 | | @param | `ref` | firebase.firestore.DocumentReference or firebase.firestore.CollectionReference of firebase.storage.Storage | A Document or Collection reference to your Firestore data or a reference to a Storage item | `firestore.doc('users/123');` or `firestore.collection('users');` or `storage.ref('thing')` | 197 | | @param | `options` | [SelectOptions](#selectoptions)? | Options for the selector | `{ subscribe: false }` or `{ metadata: true }` | 198 | | @return | | function | | | 199 | 200 | #### SelectApis 201 | 202 | | prop | type | description | 203 | | ----------- | ---------------------------- | -------------------------------------- | 204 | | `auth` | firebase.auth.Auth | Your app's firebase.auth instance | 205 | | `firestore` | firebase.firestore.Firestore | Your app's firebase.firestore instance | 206 | | `messaging` | firebase.messaging.Messaging | Your app's firebase.messaging | 207 | | `storage` | firebase.storage.Storage | Your app's firebase.storage | 208 | 209 | #### SelectOptions 210 | 211 | | prop | type | default | description | 212 | | ----------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 213 | | `subscribe` | boolean | `true` | Firestore-only. Add a subscription/listener for Firestore changes. When `true` (default), we will add a listener for database changes and update them as they happen. | 214 | | `metadata` | boolean | `false` | Storage-only. Get the full metadata of the stored object. | 215 | 216 | ### connectAuth(handleAuthState) 217 | 218 | This is a higher order component creator function used to connect your components to authentication state from your Firestore. It accepts a single argument, [handleAuthState](#handleauthstatusstate-auth-props). 219 | 220 | `connectAuth` can be used as a gating function to require an authentication status to determine what should be rendered and how to handle authentication state changes. 221 | 222 | | prop | type | description | 223 | | ----------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 224 | | `authUserDoc` | object? | If not logged in: `undefined`
If no `userCollection` provided to [`initReactFirestore`](#initreactfirestoreapp-usercollection): empty object (`{}`)
Otherwise when `userCollection` is provided: an object containing the data from the document at `${userCollection}/${auth.currentUser.uid}` | 225 | | `authFetchStatus` | [FetchStatus](#fetchstatus) | The fetch status of the user doc | 226 | | `auth` | firebase.auth.Auth | Your app's firebase.auth instance | 227 | | `firestore` | firebase.firestore.Firestore | Your app's firebase.firestore instance | 228 | | `messaging` | firebase.messaging.Messaging | Your app's firebase.messaging | 229 | | `storage` | firebase.storage.Storage | Your app's firebase.storage | 230 | 231 | ```js 232 | import { connectAuth } from 'react-stateful-firestore'; 233 | 234 | class LoginPage extends Component { 235 | static propTypes = { 236 | authUserDoc: object, 237 | authFetchStatus: oneOf(['none', 'loading', 'loaded', 'failed']).isRequired, 238 | // Also provided by `connectAuth()` 239 | auth: object.isRequired, // Use this to access the auth user, `auth.currentUser` 240 | firestore: object.isRequired, 241 | messaging: object.isRequired, 242 | storage: object.isRequired 243 | }; 244 | 245 | render() { ... } 246 | } 247 | 248 | class Loading extends Component { 249 | render() { 250 | return 'Loading…'; 251 | } 252 | } 253 | 254 | export default connectAuth(({ action, doc, fetchStatus }, auth, props) => { 255 | if (action === 'signin') { 256 | // If the user becomes signed in, push them to the home page. 257 | props.history.push('/'); 258 | } 259 | }, Loading)(LoginPage); 260 | 261 | // render(); 262 | ``` 263 | 264 | #### handleAuthState(state, auth, props) 265 | 266 | A custom function that you create, passed to [`connectAuth`](#connectauthhandleauthstate) to handle the state of authentication within your React application. 267 | 268 | | | argument | type | description | 269 | | ------- | -------- | ----------------------- | -------------------------------------------------- | 270 | | @param | `state` | [AuthState](#authstate) | The state and state change of user authentication. | 271 | | @param | `auth` | firebase.auth.Auth | Your app's firebase.auth instance | 272 | | @param | `props` | object | The props provided to your component | 273 | | @return | | void | | 274 | 275 | #### AuthState 276 | 277 | | | key | type | description | 278 | | ----- | ------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | 279 | | @prop | `action` | string? | Either `undefined` (no new action), `'signin'` when the user state has changed to signed-in or `'signout'` when the user state has changed to signed-out | 280 | | @prop | `doc` | object? | The document from firestore including extra data about the logged in user | 281 | | @prop | `fetchStatus` | [FetchStatus](#fetchstatus) | The fetch status of the doc. | 282 | 283 | ### FetchStatus 284 | 285 | A string representing the status of the query for a document. One of the following: 286 | 287 | | key | value | description | 288 | | --------- | ----------- | --------------------------------------------------- | 289 | | `NONE` | `'none'` | The document has not started loading yet | 290 | | `LOADING` | `'loading'` | The document is currently in the process of loading | 291 | | `LOADED` | `'loaded'` | The document has been successfully received | 292 | | `FAILED` | `'failed'` | There was an error requesting the document | 293 | 294 | #### resolveFetchStatus(...items) 295 | 296 | Returns a single `FetchStatus` value given the state of multiple Collections or Documents. This method requires that _all_ items are loaded before returning `FetchStatus.LOADED`. 297 | 298 | | | argument | type | description | 299 | | ------ | -------- | ---------------------------------------------- | ----------- | 300 | | @param | …`items` | [`Collection`](#types) or [`Document`](#types) | | 301 | 302 | #### resolveInitialFetchStatus(...items) 303 | 304 | Returns a single `FetchStatus` value given the _initial_ state of multiple Collections or Documents. This method will return `FetchStatus.LOADED` if _any_ item is loaded. 305 | 306 | | | argument | type | description | 307 | | ------ | -------- | ---------------------------------------------- | ----------- | 308 | | @param | …`items` | [`Collection`](#types) or [`Document`](#types) | | 309 | 310 | ## Types 311 | 312 | React Stateful Firestore also exposes a few flow types. 313 | 314 | ### $FetchStatus 315 | 316 | A FetchStatus value. 317 | 318 | ### Document 319 | 320 | Creates a type for documents provided as props on your `connect`ed components. 321 | 322 | ```js 323 | type MyDocument = Document<{ name: string }> 324 | 325 | const doc: MyDocument; 326 | console.log(doc.fetchStatus); // -> A $FetchStatus 327 | console.log(doc.id); // -> The Firestore document id 328 | console.log(doc.doc); // -> The data in the document on Firestore 329 | ``` 330 | 331 | ### Collection 332 | 333 | Creates a type for collections provided as props on your `connect`ed components 334 | 335 | ```js 336 | type MyCollection = Collection<{ name: string }> 337 | 338 | const collection: MyCollection; 339 | console.log(collection.fetchStatus); // -> A $FetchStatus 340 | console.log(collection.docs); // -> Array of Documents 341 | ``` 342 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app). 2 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@firebase/app": "^0.1.5", 7 | "@firebase/auth": "^0.3.1", 8 | "@firebase/firestore": "^0.2.2", 9 | "@firebase/messaging": "^0.1.6", 10 | "@firebase/storage": "^0.1.5", 11 | "react": "^16.2.0", 12 | "react-dom": "^16.2.0", 13 | "react-router-dom": "^4.2.2", 14 | "react-scripts": "1.0.17", 15 | "react-stateful-firestore": "^0.0.2" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test --env=jsdom", 21 | "eject": "react-scripts eject" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React App 7 | 8 | 9 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import { connect, connectAuth } from 'react-stateful-firestore'; 2 | import React, { Component } from 'react'; 3 | import Login from './Login'; 4 | import { object } from 'prop-types'; 5 | import { Link, Route, Switch } from 'react-router-dom'; 6 | 7 | class App extends Component { 8 | static propTypes = { 9 | auth: object, 10 | authUserDoc: object, 11 | history: object.isRequired 12 | }; 13 | 14 | render() { 15 | const { auth, authUserDoc, items } = this.props; 16 | return ( 17 |
18 | {auth.currentUser ? ( 19 |
20 | Logged in as: {auth.currentUser.displayName} Sign Out 21 |
22 | ) : ( 23 | Log in 24 | )} 25 | 26 | 27 | 28 |
29 | {items.fetchStatus === 'loaded' ? ( 30 | items.docs.map((item) =>

{JSON.stringify(item, null, 2)}

) 31 | ) : ( 32 | 33 | )} 34 |
35 |
36 |
37 |
38 | ); 39 | } 40 | 41 | _handleSignOut = () => { 42 | this.props.auth.signOut(); 43 | }; 44 | } 45 | 46 | const Loading = () =>
Loading…
; 47 | 48 | export default connectAuth(({ action }, props) => { 49 | if (action === 'signout') { 50 | props.history.push('/login'); 51 | } 52 | }, Loading)( 53 | connect((select, firestore) => ({ 54 | items: select(firestore.collection('items')) 55 | }))(App) 56 | ); 57 | -------------------------------------------------------------------------------- /example/src/Login.js: -------------------------------------------------------------------------------- 1 | import { connectAuth } from 'react-stateful-firestore'; 2 | import React, { Component } from 'react'; 3 | import { object } from 'prop-types'; 4 | 5 | class Login extends Component { 6 | static propTypes = { 7 | auth: object, 8 | authUserDoc: object, 9 | history: object.isRequired 10 | }; 11 | 12 | constructor(props, context) { 13 | super(props, context); 14 | this.state = { email: '', password: '' }; 15 | } 16 | 17 | render() { 18 | const { auth } = this.props; 19 | const { error } = this.state; 20 | return ( 21 |
22 | {error ?
{error.message}
: null} 23 | 27 | 31 | 34 |
35 | ); 36 | } 37 | 38 | _setEmail = (event) => { 39 | this.setState({ email: event.target.value }); 40 | }; 41 | 42 | _setPassword = (event) => { 43 | this.setState({ password: event.target.value }); 44 | }; 45 | 46 | _handleSignIn = (event) => { 47 | event.preventDefault(); 48 | const { email, password } = this.state; 49 | const { auth, history } = this.props; 50 | auth 51 | .signInWithEmailAndPassword(email, password) 52 | .then(() => { 53 | history.push('/'); 54 | }) 55 | .catch((error) => { 56 | this.setState({ error }); 57 | }); 58 | }; 59 | } 60 | 61 | const Loading = () =>
Loading…
; 62 | 63 | export default connectAuth(({ action, auth }, props) => { 64 | // Already signed in or currently signing in. Kick back to main page 65 | if (auth.currentUser || action === 'signin') { 66 | props.history.replace('/'); 67 | } 68 | }, Loading)(Login); 69 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import App from './App'; 2 | import app from '@firebase/app'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import initFirestore, { Provider } from 'react-stateful-firestore'; 6 | import { Route, BrowserRouter as Router } from 'react-router-dom'; 7 | 8 | const myApp = app.initializeApp({ 9 | apiKey: '', 10 | authDomain: '', 11 | databaseURL: '', 12 | projectId: '', 13 | storageBucket: '', 14 | messagingSenderId: '' 15 | }); 16 | 17 | const rootEl = document.getElementById('root'); 18 | 19 | initFirestore(myApp).then((store) => { 20 | if (rootEl) { 21 | ReactDOM.render( 22 | 23 | 24 | 25 | 26 | , 27 | rootEl, 28 | () => { 29 | registerServiceWorker(); 30 | } 31 | ); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /flow-typed/npm/firebase_v4.x.x.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /** ** firebase ****/ 3 | 4 | declare interface $npm$firebase$Config { 5 | apiKey: string; 6 | authDomain?: string; 7 | databaseURL?: string; 8 | projectId?: string; 9 | storageBucket?: string; 10 | messagingSenderId?: string; 11 | } 12 | 13 | declare class $npm$firebase$auth$Error { 14 | code: 15 | | 'auth/app-deleted' 16 | | 'auth/app-not-authorized' 17 | | 'auth/argument-error' 18 | | 'auth/invalid-api-key' 19 | | 'auth/invalid-user-token' 20 | | 'auth/network-request-failed' 21 | | 'auth/operation-not-allowed' 22 | | 'auth/requires-recent-login' 23 | | 'auth/too-many-requests' 24 | | 'auth/unauthorized-domain' 25 | | 'auth/user-disabled' 26 | | 'auth/user-token-expired' 27 | | 'auth/web-storage-unsupported' 28 | | 'auth/invalid-email' 29 | | 'auth/account-exists-with-different-credential' 30 | | 'auth/invalid-credential' 31 | | 'auth/user-not-found' 32 | | 'auth/wrong-password' 33 | | 'auth/invalid-verification-code' 34 | | 'auth/invalid-verification-id' 35 | | 'auth/expired-action-code' 36 | | 'auth/invalid-action-code' 37 | | 'auth/invalid-verification-code' 38 | | 'auth/missing-verification-code' 39 | | 'auth/captcha-check-failed' 40 | | 'auth/invalid-phone-number' 41 | | 'auth/missing-phone-number' 42 | | 'auth/quota-exceeded' 43 | | 'auth/credential-already-in-use' 44 | | 'auth/email-already-in-use' 45 | | 'auth/provider-already-linked' 46 | | 'auth/auth-domain-config-required' 47 | | 'auth/cancelled-popup-request' 48 | | 'auth/popup-blocked' 49 | | 'auth/operation-not-supported-in-this-environment' 50 | | 'auth/popup-closed-by-user' 51 | | 'auth/unauthorized-domain' 52 | | 'auth/no-such-provider'; 53 | message: string; 54 | } 55 | 56 | declare interface $npm$firebase$Error { 57 | code: $PropertyType<$npm$firebase$auth$Error, 'code'> | 'app/no-app'; 58 | message: string; 59 | name: string; 60 | stack: ?string; 61 | } 62 | 63 | /** *** app *****/ 64 | declare class $npm$firebase$App { 65 | name: string; 66 | +options: $npm$firebase$Config; 67 | auth(): $npm$firebase$auth$Auth; 68 | database(): $npm$firebase$database$Database; 69 | storage(): $npm$firebase$storage$Storage; 70 | delete(): Promise; 71 | } 72 | 73 | /** **** auth *******/ 74 | declare interface $npm$firebase$auth$ActionCodeInfo { 75 | data: { email: string }; 76 | } 77 | 78 | declare interface $npm$firebase$auth$ApplicationVerifier { 79 | type: string; 80 | verify(): Promise; 81 | } 82 | 83 | declare class $npm$firebase$auth$UserInfo { 84 | displayName: ?string; 85 | email: ?string; 86 | photoURL: ?string; 87 | providerId: string; 88 | uid: string; 89 | } 90 | 91 | declare class $npm$firebase$auth$User extends $npm$firebase$auth$UserInfo { 92 | displayName: ?string; 93 | email: ?string; 94 | emailVerified: boolean; 95 | isAnonymous: boolean; 96 | phoneNumber: ?string; 97 | photoUrl: ?string; 98 | providerData: Array<$npm$firebase$auth$UserInfo>; 99 | providerId: string; 100 | refreshToken: string; 101 | uid: string; 102 | delete(): Promise; 103 | getIdToken(forceRefresh?: boolean): Promise; 104 | getToken(forceRefresh?: boolean): Promise; 105 | linkAndRetrieveDataWithCredential( 106 | credential: $npm$firebase$auth$AuthCredential 107 | ): Promise<$npm$firebase$auth$UserCredential>; 108 | linkWithCredential(credential: $npm$firebase$auth$AuthCredential): Promise<$npm$firebase$auth$User>; 109 | linkWithPhoneNumber( 110 | phoneNumber: string, 111 | applicationVerifier: $npm$firebase$auth$ApplicationVerifier 112 | ): Promise<$npm$firebase$auth$ConfirmationResult>; 113 | linkWithPopup(provider: $npm$firebase$auth$OAuthProvider): Promise<$npm$firebase$auth$UserCredential>; 114 | reauthenticateAndRetrieveDataWithCredential(credential: $npm$firebase$auth$AuthCredential): Promise; 115 | reauthenticateWithCredential(credential: $npm$firebase$auth$AuthCredential): Promise; 116 | reauthenticateWithPhoneNumber( 117 | phoneNumber: string, 118 | applicationVerifier: $npm$firebase$auth$ApplicationVerifier 119 | ): Promise<$npm$firebase$auth$ConfirmationResult>; 120 | reload(): Promise; 121 | sendEmailVerification(): Promise; 122 | toJSON(): Object; 123 | unlink(providerId: string): Promise<$npm$firebase$auth$User>; 124 | updateEmail(newEmail: string): Promise; 125 | updatePassword(newPassword: string): Promise; 126 | updatePhoneNumber(phoneCredential: $npm$firebase$auth$AuthCredential): Promise; 127 | updateProfile(profile: $npm$firebase$auth$UserProfile): Promise; 128 | } 129 | 130 | declare class $npm$firebase$auth$Auth { 131 | app: $npm$firebase$App; 132 | currentUser: $npm$firebase$auth$User; 133 | applyActionCode(code: string): Promise; 134 | checkActionCode(code: string): Promise<$npm$firebase$auth$ActionCodeInfo>; 135 | confirmPasswordReset(code: string, newPassword: string): Promise; 136 | createCustomToken(uid: string, developerClaims?: {}): string; 137 | createUserWithEmailAndPassword(email: string, password: string): Promise<$npm$firebase$auth$User>; 138 | fetchProvidersForEmail(email: string): Promise>; 139 | onAuthStateChanged( 140 | nextOrObserver: (?$npm$firebase$auth$User) => void, 141 | error?: (error: $npm$firebase$auth$Error) => void, 142 | completed?: () => void 143 | ): () => void; 144 | onIdTokenChanged( 145 | nextOrObserver: Object | ((user?: $npm$firebase$auth$User) => void), 146 | error?: (error: $npm$firebase$auth$Error) => void, 147 | completed?: () => void 148 | ): () => void; 149 | sendPasswordResetEmail(email: string): Promise; 150 | signInAndRetrieveDataWithCredential( 151 | credential: $npm$firebase$auth$AuthCredential 152 | ): Promise<$npm$firebase$auth$UserCredential>; 153 | signInAnonymously(): Promise<$npm$firebase$auth$User>; 154 | signInWithCredential(credential: $npm$firebase$auth$AuthCredential): Promise<$npm$firebase$auth$User>; 155 | signInWithCustomToken(token: string): Promise<$npm$firebase$auth$User>; 156 | signInWithEmailAndPassword(email: string, password: string): Promise<$npm$firebase$auth$User>; 157 | signInWithPhoneNumber( 158 | phoneNumber: string, 159 | applicationVerifier: $npm$firebase$auth$ApplicationVerifier 160 | ): Promise<$npm$firebase$auth$ConfirmationResult>; 161 | signInWithPopup(provider: $npm$firebase$auth$AuthProvider): Promise<$npm$firebase$auth$UserCredential>; 162 | signOut(): Promise; 163 | verifyIdToken(idToken: string): Promise; 164 | verifyPasswordResetCode(code: string): Promise; 165 | } 166 | 167 | declare interface $npm$firebase$auth$AuthCredential { 168 | providerId: string; 169 | } 170 | 171 | declare class $npm$firebase$auth$AuthProvider { 172 | providerId: string; 173 | } 174 | 175 | declare interface $npm$firebase$auth$ConfirmationResult { 176 | verificationId: string; 177 | confirm(verificationCode: string): Promise<$npm$firebase$auth$UserCredential>; 178 | } 179 | 180 | declare type $npm$firebase$auth$UserProfile = { 181 | displayName?: string, 182 | photoURL?: string 183 | }; 184 | 185 | declare interface $npm$firebase$auth$AdditionalUserInfo { 186 | providerId: string; 187 | profile?: $npm$firebase$auth$UserProfile; 188 | username?: string; 189 | } 190 | 191 | declare interface $npm$firebase$auth$UserCredential { 192 | user: $npm$firebase$auth$User; 193 | credential?: $npm$firebase$auth$AuthCredential; 194 | operationType?: string; 195 | additionalUserInfo?: $npm$firebase$auth$AdditionalUserInfo; 196 | } 197 | 198 | declare class $npm$firebase$auth$EmailAuthProvider extends $npm$firebase$auth$AuthProvider { 199 | PROVIDER_ID: string; 200 | providerId: string; 201 | credential(email: string, password: string): $npm$firebase$auth$AuthCredential; 202 | } 203 | 204 | declare class $npm$firebase$auth$FacebookAuthProvider extends $npm$firebase$auth$AuthProvider { 205 | PROVIDER_ID: string; 206 | credential(token: string): $npm$firebase$auth$AuthCredential; 207 | addScope(scope: string): $npm$firebase$auth$FacebookAuthProvider; 208 | setCustomParameters(customOAuthParameters: Object): $npm$firebase$auth$FacebookAuthProvider; 209 | } 210 | 211 | declare class $npm$firebase$auth$GithubAuthProvider extends $npm$firebase$auth$AuthProvider { 212 | PROVIDER_ID: string; 213 | credential(token: string): $npm$firebase$auth$AuthCredential; 214 | addScope(scope: string): $npm$firebase$auth$GithubAuthProvider; 215 | setCustomParameters(customOAuthParameters: Object): $npm$firebase$auth$GithubAuthProvider; 216 | } 217 | 218 | declare class $npm$firebase$auth$GoogleAuthProvider extends $npm$firebase$auth$AuthProvider { 219 | PROVIDER_ID: string; 220 | credential(idToken?: string, accessToken?: string): $npm$firebase$auth$AuthCredential; 221 | addScope(scope: string): $npm$firebase$auth$GoogleAuthProvider; 222 | setCustomParameters(customOAuthParameters: Object): $npm$firebase$auth$GoogleAuthProvider; 223 | } 224 | 225 | declare class $npm$firebase$auth$PhoneAuthProvider extends $npm$firebase$auth$AuthProvider { 226 | PROVIDER_ID: string; 227 | constructor(auth?: $npm$firebase$auth$Auth): $npm$firebase$auth$PhoneAuthProvider; 228 | credential(verificationId: string, verificationCode: string): $npm$firebase$auth$AuthCredential; 229 | verifyPhoneNumber(phoneNumber: string, applicationVerifier: $npm$firebase$auth$ApplicationVerifier): Promise; 230 | } 231 | 232 | declare class $npm$firebase$auth$TwitterAuthProvider extends $npm$firebase$auth$AuthProvider { 233 | PROVIDER_ID: string; 234 | credential(token: string, secret: string): $npm$firebase$auth$AuthCredential; 235 | setCustomParameters(customOAuthParameters: Object): this; 236 | } 237 | 238 | declare type $npm$firebase$auth$OAuthProvider = 239 | | $npm$firebase$auth$FacebookAuthProvider 240 | | $npm$firebase$auth$GithubAuthProvider 241 | | $npm$firebase$auth$GoogleAuthProvider 242 | | $npm$firebase$auth$TwitterAuthProvider; 243 | 244 | /** **** database ******/ 245 | declare type $npm$firebase$database$Value = any; 246 | declare type $npm$firebase$database$OnCompleteCallback = (error: ?Object) => void; 247 | declare type $npm$firebase$database$QueryEventType = 248 | | 'value' 249 | | 'child_added' 250 | | 'child_changed' 251 | | 'child_removed' 252 | | 'child_moved'; 253 | declare type $npm$firebase$database$Priority = string | number | null; 254 | 255 | declare class $npm$firebase$database$Database { 256 | app: $npm$firebase$App; 257 | goOffline(): void; 258 | goOnline(): void; 259 | ref(path?: string): $npm$firebase$database$Reference; 260 | refFromURL(url: string): $npm$firebase$database$Reference; 261 | } 262 | 263 | declare class $npm$firebase$database$DataSnapshot { 264 | key: ?string; 265 | ref: $npm$firebase$database$Reference; 266 | child(path?: string): $npm$firebase$database$DataSnapshot; 267 | exists(): boolean; 268 | exportVal(): $npm$firebase$database$Value; 269 | forEach(action: ($npm$firebase$database$DataSnapshot) => boolean): boolean; 270 | getPriority(): $npm$firebase$database$Priority; 271 | hasChild(path: string): boolean; 272 | hasChildren(): boolean; 273 | numChildren(): number; 274 | toJSON(): Object; 275 | val(): $npm$firebase$database$Value; 276 | } 277 | 278 | declare class $npm$firebase$database$OnDisconnect { 279 | cancel(onComplete?: $npm$firebase$database$OnCompleteCallback): Promise; 280 | remove(onComplete?: $npm$firebase$database$OnCompleteCallback): Promise; 281 | set(value: $npm$firebase$database$Value, onComplete?: $npm$firebase$database$OnCompleteCallback): Promise; 282 | setWithPriority( 283 | value: $npm$firebase$database$Value, 284 | priority: number | string | null, 285 | onComplete?: $npm$firebase$database$OnCompleteCallback 286 | ): Promise; 287 | update( 288 | values: { +[path: string]: $npm$firebase$database$Value }, 289 | onComplete?: $npm$firebase$database$OnCompleteCallback 290 | ): Promise; 291 | } 292 | 293 | declare type $npm$firebase$database$Callback = ($npm$firebase$database$DataSnapshot, ?string) => void; 294 | 295 | declare class $npm$firebase$database$Query { 296 | ref: $npm$firebase$database$Reference; 297 | endAt(value: number | string | boolean | null, key?: string): $npm$firebase$database$Query; 298 | equalTo(value: number | string | boolean | null, key?: string): $npm$firebase$database$Query; 299 | isEqual(other: $npm$firebase$database$Query): boolean; 300 | limitToFirst(limit: number): $npm$firebase$database$Query; 301 | limitToLast(limit: number): $npm$firebase$database$Query; 302 | off( 303 | eventType?: $npm$firebase$database$QueryEventType, 304 | callback?: $npm$firebase$database$Callback, 305 | context?: Object 306 | ): void; 307 | on( 308 | eventType: $npm$firebase$database$QueryEventType, 309 | callback: $npm$firebase$database$Callback, 310 | cancelCallbackOrContext?: (error: Object) => void | Object, 311 | context?: $npm$firebase$database$Callback 312 | ): $npm$firebase$database$Callback; 313 | once( 314 | eventType: $npm$firebase$database$QueryEventType, 315 | successCallback?: $npm$firebase$database$Callback, 316 | failureCallbackOrContext?: (error: Object) => void | Object, 317 | context?: Object 318 | ): Promise<$npm$firebase$database$DataSnapshot>; 319 | orderByChild(path: string): $npm$firebase$database$Query; 320 | orderByKey(): $npm$firebase$database$Query; 321 | orderByPriority(): $npm$firebase$database$Query; 322 | orderByValue(): $npm$firebase$database$Query; 323 | startAt(value: number | string | boolean | null, key?: string): $npm$firebase$database$Query; 324 | toJSON(): Object; 325 | toString(): string; 326 | } 327 | 328 | declare class $npm$firebase$database$Reference extends $npm$firebase$database$Query { 329 | key: ?string; 330 | parent?: $npm$firebase$database$Reference; 331 | root: $npm$firebase$database$Reference; 332 | child(path: string): $npm$firebase$database$Reference; 333 | onDisconnect(): $npm$firebase$database$OnDisconnect; 334 | push( 335 | value?: $npm$firebase$database$Value, 336 | onComplete?: $npm$firebase$database$OnCompleteCallback 337 | ): $npm$firebase$database$ThenableReference & Promise; 338 | remove(onComplete?: $npm$firebase$database$OnCompleteCallback): Promise; 339 | set(value: $npm$firebase$database$Value, onComplete?: $npm$firebase$database$OnCompleteCallback): Promise; 340 | setPriority( 341 | priority: $npm$firebase$database$Priority, 342 | onComplete?: $npm$firebase$database$OnCompleteCallback 343 | ): Promise; 344 | setWithPriority( 345 | newVal: $npm$firebase$database$Value, 346 | newPriority: $npm$firebase$database$Priority, 347 | onComplete?: $npm$firebase$database$OnCompleteCallback 348 | ): Promise; 349 | transaction( 350 | transactionUpdate: (data: $npm$firebase$database$Value) => $npm$firebase$database$Value, 351 | onComplete?: (error: null | Object, committed: boolean, snapshot: $npm$firebase$database$DataSnapshot) => void, 352 | applyLocally?: boolean 353 | ): Promise<{ 354 | committed: boolean, 355 | snapshot: $npm$firebase$database$DataSnapshot | null 356 | }>; 357 | update( 358 | values: { [path: string]: $npm$firebase$database$Value }, 359 | onComplete?: $npm$firebase$database$OnCompleteCallback 360 | ): Promise; 361 | } 362 | 363 | declare class $npm$firebase$database$ServerValue { 364 | static TIMESTAMP: {}; 365 | } 366 | 367 | declare class $npm$firebase$database$ThenableReference extends $npm$firebase$database$Reference {} 368 | 369 | /** **** firestore ******/ 370 | declare class $npm$firebase$firestore$Firestore { 371 | app: $npm$firebase$App; 372 | batch(): $npm$firebase$firestore$WriteBatch; 373 | collection(collectionPath: string): $npm$firebase$firestore$CollectionReference; 374 | doc(documentPath: string): $npm$firebase$firestore$Query; 375 | enablePersistence(): Promise; 376 | runTransaction(updateFunction: (transaction: $npm$firebase$firestore$Transaction) => void): Promise; 377 | setLogLevel(logLevel: 'debug' | 'error' | 'silent'): void; 378 | settings(settings: $npm$firebase$firestore$Settings): void; 379 | } 380 | 381 | declare interface $npm$firebase$firestore$Blob { 382 | fromBase64String(base64: string): $npm$firebase$firestore$Blob; 383 | fromUint8Array(array: Uint8Array): $npm$firebase$firestore$Blob; 384 | toBase64(): string; 385 | toUintArray(): Uint8Array; 386 | } 387 | 388 | declare interface $npm$firebase$firestore$QueryListenOptions { 389 | includeMetadataChanges: boolean; 390 | includeQueryMetadataChanges: boolean; 391 | } 392 | declare type $npm$firebase$firestore$observer = (snapshot: $npm$firebase$firestore$DocumentSnapshot) => void; 393 | declare type $npm$firebase$firestore$observerError = (error: $npm$firebase$Error) => void; 394 | 395 | declare class $npm$firebase$firestore$Query { 396 | firestore: $npm$firebase$firestore$Firestore; 397 | endAt(snapshotOrVarArgs: $npm$firebase$firestore$DocumentSnapshot | {}): $npm$firebase$firestore$Query; 398 | endBefore(snapshotOrVarArgs: $npm$firebase$firestore$DocumentSnapshot | {}): $npm$firebase$firestore$Query; 399 | get(): Promise<$npm$firebase$firestore$QuerySnapshot>; 400 | limit(limit: number): $npm$firebase$firestore$Query; 401 | onSnapshot( 402 | optionsOrObserverOrOnNext: $npm$firebase$firestore$QueryListenOptions | $npm$firebase$firestore$observer, 403 | observerOrOnNextOrOnError?: | $npm$firebase$firestore$QueryListenOptions 404 | | $npm$firebase$firestore$observer 405 | | $npm$firebase$firestore$observerError, 406 | onError?: $npm$firebase$firestore$observerError 407 | ): void; 408 | orderBy( 409 | fieldPath: typeof $npm$firebase$firestore$FieldPath | string, 410 | directionStr: 'asc' | 'desc' 411 | ): $npm$firebase$firestore$Query; 412 | startAfter(snapshotOrVarArgs: $npm$firebase$firestore$DocumentSnapshot | {}): $npm$firebase$firestore$Query; 413 | startAt(snapshotOrVarArgs: $npm$firebase$firestore$DocumentSnapshot | {}): $npm$firebase$firestore$Query; 414 | where(fieldPath: string, opStr: '<' | '<=' | '==' | '>' | '>=', value: any): $npm$firebase$firestore$Query; 415 | } 416 | 417 | declare class $npm$firebase$firestore$CollectionReference extends $npm$firebase$firestore$Query { 418 | (): $npm$firebase$firestore$CollectionReference; 419 | id: string; 420 | parent: $npm$firebase$firestore$DocumentReference | null; 421 | add(data: {}): Promise; 422 | doc(documentPath?: string): $npm$firebase$firestore$DocumentReference; 423 | } 424 | 425 | declare interface $npm$firebase$firestore$DocumentChange { 426 | type: 'added' | 'removed' | 'modified'; 427 | } 428 | 429 | declare class $npm$firebase$firestore$DocumentReference { 430 | firestore: $npm$firebase$firestore$Firestore; 431 | id: string; 432 | parent: typeof $npm$firebase$firestore$CollectionReference; 433 | collection(collectionPath: string): typeof $npm$firebase$firestore$CollectionReference; 434 | delete(): Promise; 435 | get(): Promise<$npm$firebase$firestore$DocumentSnapshot>; 436 | onSnapshot( 437 | optionsOrObserverOrOnNext: $npm$firebase$firestore$QueryListenOptions | $npm$firebase$firestore$observer, 438 | observerOrOnNextOrOnError?: | $npm$firebase$firestore$QueryListenOptions 439 | | $npm$firebase$firestore$observer 440 | | $npm$firebase$firestore$observerError, 441 | onError?: $npm$firebase$firestore$observerError 442 | ): void; 443 | set(data: {}, options?: { merge: boolean } | null): Promise; 444 | update(...args: any): Promise; 445 | } 446 | 447 | declare class $npm$firebase$firestore$DocumentSnapshot { 448 | data(): {}; 449 | get(fieldpath: typeof $npm$firebase$firestore$FieldPath): any; 450 | exists: boolean; 451 | id: string; 452 | metadata: $npm$firebase$firestore$SnapshotMetadata; 453 | ref: $npm$firebase$firestore$DocumentReference; 454 | } 455 | 456 | declare class $npm$firebase$firestore$FieldPath { 457 | (...args: any): $npm$firebase$firestore$FieldPath; 458 | documentId(): typeof $npm$firebase$firestore$FieldPath; 459 | } 460 | 461 | declare interface $npm$firebase$firestore$FieldValue { 462 | delete(): $npm$firebase$firestore$FieldValue; 463 | serverTimestamp(): $npm$firebase$firestore$FieldValue; 464 | } 465 | 466 | declare type $npm$firebase$firestore$FirestoreError = 467 | | 'cancelled' 468 | | 'unknown' 469 | | 'invalid-argument' 470 | | 'deadline-exceeded' 471 | | 'not-found' 472 | | 'already-exists' 473 | | 'permission-denied' 474 | | 'resource-exhausted' 475 | | 'failed-precondition' 476 | | 'aborted' 477 | | 'out-of-range' 478 | | 'unimplemented' 479 | | 'internal' 480 | | 'unavailable' 481 | | 'data-loss' 482 | | 'unauthenticated'; 483 | 484 | declare class $npm$firebase$firestore$GeoPoint { 485 | (latitude: number, longitude: number): $npm$firebase$firestore$GeoPoint; 486 | latitude: number; 487 | longitude: number; 488 | } 489 | 490 | declare class $npm$firebase$firestore$QuerySnapshot { 491 | docChanges(): Array<$npm$firebase$firestore$DocumentChange>; 492 | docs: Array<$npm$firebase$firestore$DocumentSnapshot>; 493 | empty: boolean; 494 | metadata: $npm$firebase$firestore$SnapshotMetadata; 495 | query: $npm$firebase$firestore$Query; 496 | size: number; 497 | forEach((snapshot: $npm$firebase$firestore$DocumentSnapshot, thisArg?: any) => void): void; 498 | } 499 | 500 | declare interface $npm$firebase$firestore$Settings {} 501 | 502 | declare interface $npm$firebase$firestore$SnapshotMetadata { 503 | fromCache: boolean; 504 | hasPendingWrites: boolean; 505 | } 506 | 507 | declare interface $npm$firebase$firestore$Transaction { 508 | delete(documentRef: $npm$firebase$firestore$DocumentReference): $npm$firebase$firestore$Transaction; 509 | get(documentRef: $npm$firebase$firestore$DocumentReference): Promise<$npm$firebase$firestore$DocumentReference>; 510 | set( 511 | documentRef: $npm$firebase$firestore$DocumentReference, 512 | data: {}, 513 | options?: { merge: boolean } 514 | ): $npm$firebase$firestore$Transaction; 515 | update(documentRef: $npm$firebase$firestore$DocumentReference, ...args: any): $npm$firebase$firestore$Transaction; 516 | } 517 | 518 | declare interface $npm$firebase$firestore$WriteBatch { 519 | commit(): Promise; 520 | delete(documentRef: $npm$firebase$firestore$DocumentReference): $npm$firebase$firestore$WriteBatch; 521 | set( 522 | documentRef: $npm$firebase$firestore$DocumentReference, 523 | data: {}, 524 | options?: { merge: boolean } 525 | ): $npm$firebase$firestore$WriteBatch; 526 | update(documentRef: $npm$firebase$firestore$DocumentReference, ...args: any): $npm$firebase$firestore$WriteBatch; 527 | } 528 | 529 | /** **** messaging ******/ 530 | declare class $npm$firebase$messaging$Messaging { 531 | deleteToken(token: string): Promise; 532 | getToken(): Promise; 533 | onMessage(nextOrObserver: ({}) => void | {}): () => void; 534 | onTokenRefresh(nextOrObserver: ({}) => void | {}): () => void; 535 | requestPermission(): Promise; 536 | setBackgroundMessageHandler(callback: (value: {}) => void): void; 537 | useServiceWorker(registration: any): void; 538 | } 539 | 540 | /** **** storage ******/ 541 | declare type $npm$firebase$storage$StringFormat = 'raw' | 'base64' | 'base64url' | 'data_url'; 542 | declare type $npm$firebase$storage$TaskEvent = 'state_changed'; 543 | declare type $npm$firebase$storage$TaskState = 'running' | 'paused' | 'success' | 'canceled' | 'error'; 544 | 545 | declare class $npm$firebase$storage$Storage { 546 | app: $npm$firebase$App; 547 | maxOperationRetryTime: number; 548 | maxUploadRetryTime: number; 549 | ref(path?: string): $npm$firebase$storage$Reference; 550 | refFromURL(url: string): $npm$firebase$storage$Reference; 551 | setMaxOperationRetryTime(time: number): void; 552 | setMaxUploadRetryTime(time: number): void; 553 | } 554 | 555 | declare class $npm$firebase$storage$FullMetadata extends $npm$firebase$storage$UploadMetadata { 556 | bucket: string; 557 | downloadURLs: Array; 558 | fullPath: string; 559 | generation: string; 560 | metageneration: string; 561 | name: string; 562 | size: number; 563 | timeCreated: string; 564 | updated: string; 565 | } 566 | 567 | declare class $npm$firebase$storage$Reference { 568 | bucket: string; 569 | fullPath: string; 570 | name: string; 571 | parent?: $npm$firebase$storage$Reference; 572 | root: $npm$firebase$storage$Reference; 573 | storage: $npm$firebase$storage$Storage; 574 | child(path: string): $npm$firebase$storage$Reference; 575 | delete(): Promise; 576 | getDownloadURL(): Promise; 577 | getMetadata(): Promise<$npm$firebase$storage$FullMetadata>; 578 | put( 579 | data: Blob | Uint8Array | ArrayBuffer, 580 | metadata?: $npm$firebase$storage$UploadMetadata 581 | ): $npm$firebase$storage$UploadTask; 582 | putString( 583 | data: string, 584 | format: $npm$firebase$storage$StringFormat, 585 | metadata?: $npm$firebase$storage$UploadMetadata 586 | ): $npm$firebase$storage$UploadTask; 587 | toString(): string; 588 | updateMetadata(metadata: $npm$firebase$storage$SettableMetadata): Promise<$npm$firebase$storage$FullMetadata>; 589 | } 590 | 591 | declare class $npm$firebase$storage$SettableMetadata { 592 | cacheControl?: string; 593 | contentDisposition?: string; 594 | contentEncoding?: string; 595 | contentLanguage?: string; 596 | contentType?: string; 597 | customMetadata?: { [key: string]: string | void }; 598 | } 599 | 600 | declare class $npm$firebase$storage$UploadMetadata extends $npm$firebase$storage$SettableMetadata { 601 | md5Hash?: string; 602 | } 603 | 604 | declare interface $npm$firebase$storage$Observer { 605 | next: (snapshot: $npm$firebase$storage$UploadTaskSnapshot) => void; 606 | error?: (error: Error) => void; 607 | complete?: () => void; 608 | } 609 | 610 | declare type $npm$firebase$storage$Unsubscribe = () => void; 611 | 612 | declare type $npm$firebase$storage$Subscribe = ( 613 | observerOrNext: $npm$firebase$storage$Observer | ((snapshot: $npm$firebase$storage$UploadTaskSnapshot) => void), 614 | onError?: (error: Error) => void, 615 | onComplete?: () => void 616 | ) => $npm$firebase$storage$Unsubscribe; 617 | 618 | declare class $npm$firebase$storage$UploadTask extends Promise<$npm$firebase$storage$UploadTaskSnapshot> { 619 | snapshot: $npm$firebase$storage$UploadTaskSnapshot; 620 | cancel(): boolean; 621 | on(event: $npm$firebase$storage$TaskEvent, ...rest: Array): $npm$firebase$storage$Subscribe; 622 | on( 623 | event: $npm$firebase$storage$TaskEvent, 624 | observerOrNext: $npm$firebase$storage$Observer | ((snapshot: $npm$firebase$storage$UploadTaskSnapshot) => void), 625 | onError?: (error: Error) => void, 626 | onComplete?: () => void 627 | ): $npm$firebase$storage$Unsubscribe; 628 | pause(): boolean; 629 | resume(): boolean; 630 | } 631 | 632 | declare class $npm$firebase$storage$UploadTaskSnapshot { 633 | bytesTransferred: number; 634 | downloadURL?: string; 635 | metadata: $npm$firebase$storage$FullMetadata; 636 | ref: $npm$firebase$storage$Reference; 637 | state: $npm$firebase$storage$TaskState; 638 | task: $npm$firebase$storage$UploadTask; 639 | totalBytes: number; 640 | } 641 | 642 | declare interface $npm$firebase$app { 643 | (name?: string): $npm$firebase$App; 644 | App: typeof $npm$firebase$App; 645 | auth: $npm$firebase$auth; 646 | database: $npm$firebase$database; 647 | firestore: $npm$firebase$firestore; 648 | initializeApp(options: $npm$firebase$Config, name?: string): $npm$firebase$App; 649 | messaging: $npm$firebase$messaging; 650 | storage: $npm$firebase$storage; 651 | } 652 | 653 | declare interface $npm$firebase$auth { 654 | (app?: $npm$firebase$App): $npm$firebase$auth$Auth; 655 | FirebaseAdditionalUserInfo: $npm$firebase$auth$AdditionalUserInfo; 656 | FirebaseUserCredential: $npm$firebase$auth$UserCredential; 657 | ActionCodeInfo: $npm$firebase$auth$ActionCodeInfo; 658 | ApplicationVerifier: $npm$firebase$auth$ApplicationVerifier; 659 | Auth: typeof $npm$firebase$auth$Auth; 660 | AuthCredential: $npm$firebase$auth$AuthCredential; 661 | AuthProvider: $npm$firebase$auth$AuthProvider; 662 | ConfirmationResult: $npm$firebase$auth$ConfirmationResult; 663 | EmailAuthProvider: typeof $npm$firebase$auth$EmailAuthProvider; 664 | Error: typeof $npm$firebase$auth$Error; 665 | FacebookAuthProvider: typeof $npm$firebase$auth$FacebookAuthProvider; 666 | GithubAuthProvider: typeof $npm$firebase$auth$GithubAuthProvider; 667 | GoogleAuthProvider: typeof $npm$firebase$auth$GoogleAuthProvider; 668 | PhoneAuthProvider: typeof $npm$firebase$auth$PhoneAuthProvider; 669 | TwitterAuthProvider: typeof $npm$firebase$auth$TwitterAuthProvider; 670 | } 671 | 672 | declare interface $npm$firebase$database { 673 | (app?: $npm$firebase$App): $npm$firebase$database$Database; 674 | enableLogging(logger?: boolean | ((msg: string) => void), persistent?: boolean): void; 675 | DataSnapshot: typeof $npm$firebase$database$DataSnapshot; 676 | Database: typeof $npm$firebase$database$Database; 677 | OnDisconnect: typeof $npm$firebase$database$OnDisconnect; 678 | Query: typeof $npm$firebase$database$Query; 679 | Reference: typeof $npm$firebase$database$Reference; 680 | ServerValue: typeof $npm$firebase$database$ServerValue; 681 | ThenableReference: typeof $npm$firebase$database$ThenableReference; 682 | } 683 | 684 | declare interface $npm$firebase$firestore { 685 | (app?: $npm$firebase$App): $npm$firebase$firestore$Firestore; 686 | Blob: $npm$firebase$firestore$Blob; 687 | CollectionReference: typeof $npm$firebase$firestore$CollectionReference; 688 | DocumentChange: $npm$firebase$firestore$DocumentChange; 689 | DocumentReference: typeof $npm$firebase$firestore$DocumentReference; 690 | DocumentSnapshot: typeof $npm$firebase$firestore$DocumentSnapshot; 691 | FieldPath: typeof $npm$firebase$firestore$FieldPath; 692 | FieldValue: $npm$firebase$firestore$FieldValue; 693 | Firestore: typeof $npm$firebase$firestore$Firestore; 694 | FirestoreError: $npm$firebase$firestore$FirestoreError; 695 | GeoPoint: typeof $npm$firebase$firestore$GeoPoint; 696 | Query: typeof $npm$firebase$firestore$Query; 697 | QueryListenOptions: $npm$firebase$firestore$QueryListenOptions; 698 | QuerySnapshot: typeof $npm$firebase$firestore$QuerySnapshot; 699 | Settings: $npm$firebase$firestore$Settings; 700 | SnapshotMetadata: $npm$firebase$firestore$SnapshotMetadata; 701 | Transaction: $npm$firebase$firestore$Transaction; 702 | WriteBatch: $npm$firebase$firestore$WriteBatch; 703 | } 704 | 705 | declare interface $npm$firebase$messaging { 706 | (app?: $npm$firebase$App): $npm$firebase$messaging$Messaging; 707 | Messaging: typeof $npm$firebase$messaging$Messaging; 708 | } 709 | 710 | declare interface $npm$firebase$storage { 711 | (app?: $npm$firebase$App): $npm$firebase$storage$Storage; 712 | Storage: typeof $npm$firebase$storage$Storage; 713 | FullMetadata: typeof $npm$firebase$storage$FullMetadata; 714 | Reference: typeof $npm$firebase$storage$Reference; 715 | SettableMetadata: typeof $npm$firebase$storage$SettableMetadata; 716 | UploadMetadata: typeof $npm$firebase$storage$UploadMetadata; 717 | UploadTask: typeof $npm$firebase$storage$UploadTask; 718 | UploadTaskSnapshot: typeof $npm$firebase$storage$UploadTaskSnapshot; 719 | } 720 | 721 | // Exporting the types 722 | declare module 'firebase' { 723 | declare module.exports: { 724 | +apps: Array<$npm$firebase$App>, 725 | initializeApp(options: $npm$firebase$Config, name?: string): $npm$firebase$App, 726 | SDK_VERSION: string, 727 | FirebaseError: $npm$firebase$Error, 728 | FirebaseConfig: $npm$firebase$Config, 729 | FirebaseUser: typeof $npm$firebase$auth$User, 730 | FirebaseUserInfo: typeof $npm$firebase$auth$UserInfo, 731 | app: $npm$firebase$app, 732 | auth: $npm$firebase$auth, 733 | database: $npm$firebase$database, 734 | firestore: $npm$firebase$firestore, 735 | messaging: $npm$firebase$messaging, 736 | storage: $npm$firebase$storage 737 | }; 738 | } 739 | declare module 'firebase/app' { 740 | declare module.exports: $npm$firebase$app; 741 | } 742 | declare module 'firebase/auth' { 743 | declare module.exports: $npm$firebase$auth; 744 | } 745 | declare module 'firebase/database' { 746 | declare module.exports: $npm$firebase$database; 747 | } 748 | declare module 'firebase/firestore' { 749 | declare module.exports: $npm$firebase$firestore; 750 | } 751 | declare module 'firebase/messaging' { 752 | declare module.exports: $npm$firebase$messaging; 753 | } 754 | declare module 'firebase/storage' { 755 | declare module.exports: $npm$firebase$storage; 756 | } 757 | 758 | declare module '@firebase/app' { 759 | declare module.exports: $npm$firebase$app; 760 | } 761 | declare module '@firebase/auth' { 762 | declare module.exports: $npm$firebase$auth; 763 | } 764 | declare module '@firebase/database' { 765 | declare module.exports: $npm$firebase$database; 766 | } 767 | declare module '@firebase/firestore' { 768 | declare module.exports: $npm$firebase$firestore; 769 | } 770 | declare module '@firebase/messaging' { 771 | declare module.exports: $npm$firebase$messaging; 772 | } 773 | declare module '@firebase/storage' { 774 | declare module.exports: $npm$firebase$storage; 775 | } 776 | -------------------------------------------------------------------------------- /flow-typed/npm/flux-standard-action_v1.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 5a858164a708eb1f14434f6d4dcf0a67 2 | // flow-typed version: 2bbf4ce564/flux-standard-action_v1.x.x/flow_>=v0.38.x 3 | 4 | declare module 'flux-standard-action' { 5 | declare type FluxStandardAction = { 6 | /** 7 | * The `type` of an action identifies to the consumer the nature of the action that has occurred. 8 | * Two actions with the same `type` MUST be strictly equivalent (using `===`) 9 | */ 10 | type: ActionType, 11 | /** 12 | * The optional `payload` property MAY be any type of value. 13 | * It represents the payload of the action. 14 | * Any information about the action that is not the type or status of the action should be part of the `payload` field. 15 | * By convention, if `error` is `true`, the `payload` SHOULD be an error object. 16 | * This is akin to rejecting a promise with an error object. 17 | */ 18 | payload?: Payload, 19 | /** 20 | * The optional `error` property MAY be set to true if the action represents an error. 21 | * An action whose `error` is true is analogous to a rejected Promise. 22 | * By convention, the `payload` SHOULD be an error object. 23 | * If `error` has any other value besides `true`, including `undefined`, the action MUST NOT be interpreted as an error. 24 | */ 25 | error?: boolean, 26 | /** 27 | * The optional `meta` property MAY be any type of value. 28 | * It is intended for any extra information that is not part of the payload. 29 | */ 30 | meta?: Meta 31 | }; 32 | declare function isFSA(action: any): boolean; 33 | declare function isError(action: any): boolean; 34 | } 35 | -------------------------------------------------------------------------------- /flow-typed/npm/redux-thunk_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 84deef78be6a32dbcb6a212e34dc0d12 2 | // flow-typed version: <>/redux-thunk_v^2.2.0/flow_v0.62.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'redux-thunk' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'redux-thunk' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'redux-thunk/dist/redux-thunk' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'redux-thunk/dist/redux-thunk.min' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'redux-thunk/es/index' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'redux-thunk/lib/index' { 38 | declare module.exports: any; 39 | } 40 | 41 | declare module 'redux-thunk/src/index' { 42 | declare module.exports: any; 43 | } 44 | 45 | // Filename aliases 46 | declare module 'redux-thunk/dist/redux-thunk.js' { 47 | declare module.exports: $Exports<'redux-thunk/dist/redux-thunk'>; 48 | } 49 | declare module 'redux-thunk/dist/redux-thunk.min.js' { 50 | declare module.exports: $Exports<'redux-thunk/dist/redux-thunk.min'>; 51 | } 52 | declare module 'redux-thunk/es/index.js' { 53 | declare module.exports: $Exports<'redux-thunk/es/index'>; 54 | } 55 | declare module 'redux-thunk/lib/index.js' { 56 | declare module.exports: $Exports<'redux-thunk/lib/index'>; 57 | } 58 | declare module 'redux-thunk/src/index.js' { 59 | declare module.exports: $Exports<'redux-thunk/src/index'>; 60 | } 61 | -------------------------------------------------------------------------------- /flow-typed/npm/redux_v3.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: ec7daead5cb4fec5ab25fedbedef29e8 2 | // flow-typed version: 2c04631d20/redux_v3.x.x/flow_>=v0.55.x 3 | 4 | declare module 'redux' { 5 | /* 6 | 7 | S = State 8 | A = Action 9 | D = Dispatch 10 | 11 | */ 12 | 13 | declare export type DispatchAPI = (action: A) => A; 14 | declare export type Dispatch }> = DispatchAPI; 15 | 16 | declare export type MiddlewareAPI> = { 17 | dispatch: D, 18 | getState(): S 19 | }; 20 | 21 | declare export type Store> = { 22 | // rewrite MiddlewareAPI members in order to get nicer error messages (intersections produce long messages) 23 | dispatch: D, 24 | getState(): S, 25 | subscribe(listener: () => void): () => void, 26 | replaceReducer(nextReducer: Reducer): void 27 | }; 28 | 29 | declare export type Reducer = (state: S, action: A) => S; 30 | 31 | declare export type CombinedReducer = (state: ($Shape & {}) | void, action: A) => S; 32 | 33 | declare export type Middleware> = (api: MiddlewareAPI) => (next: D) => D; 34 | 35 | declare export type StoreCreator> = { 36 | (reducer: Reducer, enhancer?: StoreEnhancer): Store, 37 | (reducer: Reducer, preloadedState: S, enhancer?: StoreEnhancer): Store 38 | }; 39 | 40 | declare export type StoreEnhancer> = (next: StoreCreator) => StoreCreator; 41 | 42 | declare export function createStore( 43 | reducer: Reducer, 44 | enhancer?: StoreEnhancer 45 | ): Store; 46 | declare export function createStore( 47 | reducer: Reducer, 48 | preloadedState: S, 49 | enhancer?: StoreEnhancer 50 | ): Store; 51 | 52 | declare export function applyMiddleware(...middlewares: Array>): StoreEnhancer; 53 | 54 | declare export type ActionCreator = (...args: Array) => A; 55 | declare export type ActionCreators = { [key: K]: ActionCreator }; 56 | 57 | declare export function bindActionCreators, D: DispatchAPI>( 58 | actionCreator: C, 59 | dispatch: D 60 | ): C; 61 | declare export function bindActionCreators, D: DispatchAPI>( 62 | actionCreators: C, 63 | dispatch: D 64 | ): C; 65 | 66 | declare export function combineReducers( 67 | reducers: O 68 | ): CombinedReducer<$ObjMap(r: Reducer) => S>, A>; 69 | 70 | declare export var compose: $Compose; 71 | } 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-stateful-firestore", 3 | "version": "0.5.3", 4 | "description": "Firestore query and data management for React", 5 | "main": "build", 6 | "module": "build/src", 7 | "files": ["build/*", "LICENSE", "README.md"], 8 | "author": "Paul Armstrong ", 9 | "bugs": { 10 | "url": "https://github.com/paularmstrong/react-stateful-firestore/issues" 11 | }, 12 | "homepage": "https://github.com/paularmstrong/react-stateful-firestore", 13 | "repository": { 14 | "url": "https://github.com/paularmstrong/react-stateful-firestore.git", 15 | "type": "git" 16 | }, 17 | "license": "MIT", 18 | "scripts": { 19 | "build": "npm-run-all build:cjs build:es build:flow", 20 | "build:cjs": "NODE_ENV=production rollup -c", 21 | "build:es": "babel src/*.js src/**/*.js -d build/ --sourceMaps inline --source-maps true --ignore '/__tests__/'", 22 | "build:flow": "flow-copy-source -i **__tests__/*.js src build/src", 23 | "format:check": "find src -name '*.js' | xargs prettier --debug-check", 24 | "lint": "eslint src/ --fix", 25 | "lint:check": "eslint src/", 26 | "release": "yarn build && yarn publish", 27 | "prebuild": "rimraf build", 28 | "precommit": "flow && lint-staged", 29 | "test": "BABEL_ENV=development jest" 30 | }, 31 | "lint-staged": { 32 | "*.js": ["test --findRelatedTests", "eslint --fix", "git add"], 33 | "*.{js,json,css,md}": ["prettier --write", "git add"] 34 | }, 35 | "peerDependencies": { 36 | "@firebase/app": ">=0.1.5", 37 | "@firebase/auth": ">=0.3.1", 38 | "@firebase/firestore": ">=0.2.2", 39 | "@firebase/messaging": ">=0.1.6", 40 | "@firebase/storage": ">=0.1.5", 41 | "prop-types": ">=15.6.0", 42 | "react": ">=16.0.0" 43 | }, 44 | "dependencies": { 45 | "redux": "^3.7.2", 46 | "redux-logger": "^3.0.6", 47 | "redux-thunk": "^2.2.0", 48 | "reselect": "^3.0.1" 49 | }, 50 | "devDependencies": { 51 | "babel-cli": "^6.26.0", 52 | "babel-core": "^6.26.0", 53 | "babel-eslint": "^8.1.2", 54 | "babel-jest": "^22.0.4", 55 | "babel-plugin-external-helpers": "^6.22.0", 56 | "babel-plugin-syntax-flow": "^6.18.0", 57 | "babel-plugin-transform-class-properties": "^6.24.1", 58 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 59 | "babel-plugin-transform-react-jsx": "^6.24.1", 60 | "babel-preset-env": "^1.6.1", 61 | "babel-preset-flow": "^6.23.0", 62 | "enzyme": "^3.3.0", 63 | "enzyme-adapter-react-16": "^1.1.1", 64 | "enzyme-to-json": "^3.3.0", 65 | "eslint": "^4.14.0", 66 | "eslint-config-prettier": "^2.9.0", 67 | "eslint-plugin-flowtype": "^2.40.1", 68 | "eslint-plugin-jest": "^21.4.2", 69 | "eslint-plugin-promise": "^3.6.0", 70 | "eslint-plugin-react": "^7.5.1", 71 | "flow-bin": "^0.62.0", 72 | "flow-copy-source": "^1.2.1", 73 | "flow-typed": "^2.2.3", 74 | "husky": "^0.14.3", 75 | "jest": "^22.0.4", 76 | "lint-staged": "^6.0.0", 77 | "npm-run-all": "^4.1.2", 78 | "prettier": "^1.9.2", 79 | "prop-types": "^15.6.0", 80 | "react": "^16.2.0", 81 | "react-dom": "^16.2.0", 82 | "redux-mock-store": "^1.4.0", 83 | "regenerator-runtime": "^0.11.1", 84 | "rimraf": "^2.6.2", 85 | "rollup": "^0.53.2", 86 | "rollup-plugin-babel": "^3.0.3", 87 | "rollup-plugin-commonjs": "^8.2.6", 88 | "rollup-plugin-node-resolve": "^3.0.0" 89 | }, 90 | "jest": { 91 | "rootDir": "src/", 92 | "setupTestFrameworkScriptFile": "/../setupTests.js", 93 | "transform": { 94 | "^.+\\.jsx?$": "babel-jest" 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import resolve from 'rollup-plugin-node-resolve'; 4 | 5 | const plugins = [ 6 | babel({ 7 | exclude: 'node_modules/**' 8 | }), 9 | commonjs(), 10 | resolve({ 11 | customResolveOptions: { 12 | moduleDirectory: 'node_modules' 13 | } 14 | }) 15 | ]; 16 | const external = [ 17 | '@firebase/app', 18 | '@firebase/auth', 19 | '@firebase/firestore', 20 | '@firebase/messaging', 21 | '@firebase/storage', 22 | 'react', 23 | 'prop-types' 24 | ]; 25 | 26 | export default [ 27 | { 28 | input: 'src/index.js', 29 | output: [ 30 | { 31 | file: 'build/index.js', 32 | format: 'cjs', 33 | sourcemap: true, 34 | exports: 'named' 35 | } 36 | ], 37 | plugins, 38 | external 39 | } 40 | ]; 41 | -------------------------------------------------------------------------------- /setupTests.js: -------------------------------------------------------------------------------- 1 | const enzyme = require('enzyme'); 2 | const Adapter = require('enzyme-adapter-react-16'); 3 | 4 | enzyme.configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /src/Provider.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { Component } from 'react'; 3 | import firebase from '@firebase/app'; 4 | import '@firebase/auth'; 5 | import '@firebase/firestore'; 6 | import '@firebase/messaging'; 7 | import '@firebase/storage'; 8 | import { any } from 'prop-types'; 9 | 10 | import type { App } from '@firebase/app'; 11 | 12 | type FirestoreContext = { 13 | app: App, 14 | select: () => any, 15 | selectAuth: () => any, 16 | selectStorage: () => any, 17 | store: any 18 | }; 19 | 20 | type Props = { 21 | children: React$Node, 22 | store: FirestoreContext 23 | }; 24 | 25 | export default class Provider extends Component { 26 | static childContextTypes = { 27 | firebase: any 28 | }; 29 | 30 | getChildContext() { 31 | const { app, select, selectAuth, selectStorage, store } = this.props.store; 32 | return { 33 | firebase: { 34 | app, 35 | auth: firebase.auth(app), 36 | firestore: firebase.firestore(app), 37 | messaging: firebase.messaging(app), 38 | select, 39 | selectAuth, 40 | selectStorage, 41 | storage: firebase.storage(app), 42 | store 43 | } 44 | }; 45 | } 46 | 47 | render() { 48 | return this.props.children; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/actions.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Actions addListener starts the listener, then adds it 1`] = ` 4 | Array [ 5 | Object { 6 | "meta": Object { 7 | "query": Object { 8 | "get": [MockFunction], 9 | "id": "foo", 10 | "onSnapshot": [MockFunction] { 11 | "calls": Array [ 12 | Array [ 13 | [Function], 14 | ], 15 | ], 16 | }, 17 | "path": "foo", 18 | }, 19 | "queryId": "foo", 20 | }, 21 | "payload": Array [ 22 | Object { 23 | "meta": Object { 24 | "query": Object { 25 | "get": [MockFunction], 26 | "id": "foo", 27 | "onSnapshot": [MockFunction] { 28 | "calls": Array [ 29 | Array [ 30 | [Function], 31 | ], 32 | ], 33 | }, 34 | "path": "foo", 35 | }, 36 | }, 37 | "payload": Object {}, 38 | "type": "firestore/collections/MODIFY_ONE", 39 | }, 40 | Object { 41 | "meta": Object { 42 | "queryId": "foo", 43 | }, 44 | "payload": Object {}, 45 | "type": "firestore/queries/SUCCESS", 46 | }, 47 | ], 48 | "type": "BATCH", 49 | }, 50 | Object { 51 | "meta": Object { 52 | "queryId": "foo", 53 | }, 54 | "payload": undefined, 55 | "type": "firestore/listeners/ADD", 56 | }, 57 | ] 58 | `; 59 | 60 | exports[`Actions addQuery adds the query and executes it 1`] = ` 61 | Array [ 62 | Object { 63 | "meta": Object { 64 | "queryId": "foo", 65 | }, 66 | "payload": Object { 67 | "get": [MockFunction] { 68 | "calls": Array [ 69 | Array [], 70 | ], 71 | }, 72 | "id": "foo", 73 | "onSnapshot": [MockFunction], 74 | "path": "foo", 75 | }, 76 | "type": "firestore/queries/REQUEST", 77 | }, 78 | Object { 79 | "meta": Object { 80 | "query": Object { 81 | "get": [MockFunction] { 82 | "calls": Array [ 83 | Array [], 84 | ], 85 | }, 86 | "id": "foo", 87 | "onSnapshot": [MockFunction], 88 | "path": "foo", 89 | }, 90 | "queryId": "foo", 91 | }, 92 | "payload": Array [ 93 | Object { 94 | "meta": Object { 95 | "query": Object { 96 | "get": [MockFunction] { 97 | "calls": Array [ 98 | Array [], 99 | ], 100 | }, 101 | "id": "foo", 102 | "onSnapshot": [MockFunction], 103 | "path": "foo", 104 | }, 105 | }, 106 | "payload": Object {}, 107 | "type": "firestore/collections/MODIFY_ONE", 108 | }, 109 | Object { 110 | "meta": Object { 111 | "queryId": "foo", 112 | }, 113 | "payload": Object {}, 114 | "type": "firestore/queries/SUCCESS", 115 | }, 116 | ], 117 | "type": "BATCH", 118 | }, 119 | ] 120 | `; 121 | 122 | exports[`Actions addQuery adds the query with appropriate documentIds if collection query exists (filtered) 1`] = ` 123 | Array [ 124 | Object { 125 | "meta": Object { 126 | "queryId": "foo:date cb()); 7 | const defaultState = { auth: {}, collections: {}, listeners: {}, queries: {}, storage: {} }; 8 | 9 | describe('Actions', () => { 10 | let store; 11 | let query; 12 | let firestore; 13 | let middlewares; 14 | let mockStore; 15 | beforeEach(() => { 16 | query = { id: 'foo', path: 'foo', get: jest.fn(() => Promise.resolve({})), onSnapshot: jest.fn((cb) => cb({})) }; 17 | firestore = { 18 | doc: (path) => ({ ...query, id: path, path }) 19 | }; 20 | middlewares = [thunk.withExtraArgument({ firestore }), batchMiddleware]; 21 | mockStore = configureStore(middlewares); 22 | store = mockStore(defaultState); 23 | }); 24 | 25 | describe('addQuery', () => { 26 | test('adds the query and executes it', () => { 27 | return store.dispatch(Actions.addQuery(query)).then(() => { 28 | expect(store.getActions()).toMatchSnapshot(); 29 | expect(query.get).toHaveBeenCalled(); 30 | }); 31 | }); 32 | 33 | test('does nothing if the query is already cached', () => { 34 | store = mockStore({ ...defaultState, queries: { foo: {} } }); 35 | return store.dispatch(Actions.addQuery(query)).then(() => { 36 | expect(store.getActions()).toEqual([]); 37 | expect(query.get).not.toHaveBeenCalled(); 38 | }); 39 | }); 40 | 41 | test('adds the query with appropriate documentIds if collection query exists (simple)', () => { 42 | store = mockStore({ ...defaultState, collections: { foo: { '123': { id: '123' } } }, queries: { foo: {} } }); 43 | return store.dispatch(Actions.addQuery({ ...query, id: 'foo/123', path: 'foo/123' })).then(() => { 44 | expect(store.getActions()).toMatchSnapshot(); 45 | expect(query.get).not.toHaveBeenCalled(); 46 | }); 47 | }); 48 | 49 | test('adds the query with appropriate documentIds if collection query exists (filtered)', () => { 50 | store = mockStore({ 51 | ...defaultState, 52 | collections: { 53 | foo: { 54 | '123': { id: '123' }, 55 | '456': { id: '456', date: new Date(1600000000000) }, 56 | '789': { id: '789', date: new Date(1400000000000) } 57 | } 58 | }, 59 | queries: { foo: {} } 60 | }); 61 | return store 62 | .dispatch( 63 | Actions.addQuery({ 64 | _query: { 65 | id: 'foo', 66 | path: { segments: ['foo'] }, 67 | filters: [ 68 | { 69 | field: { segments: ['date'] }, 70 | op: { name: '<' }, 71 | value: { 72 | internalValue: { 73 | seconds: 1500000000000 74 | }, 75 | toString() { 76 | return 'mock date'; 77 | } 78 | } 79 | } 80 | ] 81 | } 82 | }) 83 | ) 84 | .then(() => { 85 | expect(store.getActions()).toMatchSnapshot(); 86 | expect(query.get).not.toHaveBeenCalled(); 87 | }); 88 | }); 89 | 90 | test('allows prefixing the queryId', () => { 91 | return store.dispatch(Actions.addQuery(query, 'tacos|')).then(() => { 92 | const queryIds = store 93 | .getActions() 94 | .filter((action) => !!action.meta.queryId) 95 | .map((action) => action.meta.queryId); 96 | expect(queryIds).toEqual(['tacos|foo', 'tacos|foo']); 97 | }); 98 | }); 99 | }); 100 | 101 | describe('addListener', () => { 102 | test('starts the listener, then adds it', () => { 103 | return store.dispatch(Actions.addListener(query)).then(() => { 104 | expect(store.getActions()).toMatchSnapshot(); 105 | expect(query.onSnapshot).toHaveBeenCalled(); 106 | }); 107 | }); 108 | 109 | test('does nothing if the listener is active', () => { 110 | store = mockStore({ ...defaultState, listeners: { foo: {} } }); 111 | return store.dispatch(Actions.addListener(query)).then(() => { 112 | expect(store.getActions()).toEqual([]); 113 | expect(query.onSnapshot).not.toHaveBeenCalled(); 114 | }); 115 | }); 116 | 117 | test('does nothing if a listener for the collection is active', () => { 118 | store = mockStore({ ...defaultState, listeners: { foo: {} } }); 119 | return store.dispatch(Actions.addListener({ ...query, id: 'foo/123', path: 'foo/123' })).then(() => { 120 | expect(store.getActions()).toEqual([]); 121 | expect(query.onSnapshot).not.toHaveBeenCalled(); 122 | }); 123 | }); 124 | 125 | test('allows prefixing the queryId', () => { 126 | return store.dispatch(Actions.addListener(query, 'tacos|')).then(() => { 127 | const queryIds = store 128 | .getActions() 129 | .filter((action) => !!action.meta.queryId) 130 | .map((action) => action.meta.queryId); 131 | expect(queryIds).toEqual(['tacos|foo', 'tacos|foo']); 132 | }); 133 | }); 134 | }); 135 | 136 | describe('removeListener', () => { 137 | test('unsubscribes from the listener before removing', () => { 138 | const unsubscribe = jest.fn(); 139 | const store = mockStore({ ...defaultState, listeners: { foo: { unsubscribe } } }); 140 | return store.dispatch(Actions.removeListener(query)).then(() => { 141 | expect(unsubscribe).toHaveBeenCalled(); 142 | expect(store.getActions()).toEqual([{ type: Actions.LISTENERS.REMOVE, meta: { queryId: 'foo' } }]); 143 | }); 144 | }); 145 | }); 146 | 147 | describe('setUser', () => { 148 | test('adds queries and listeners', () => { 149 | return store.dispatch(Actions.setUser({ uid: '123' }, 'users')).then(() => { 150 | expect(store.getActions()).toMatchSnapshot(); 151 | }); 152 | }); 153 | 154 | test('triggers only auth change action if no collection', () => { 155 | return store.dispatch(Actions.setUser({ uid: '123' })).then(() => { 156 | expect(store.getActions()).toMatchSnapshot(); 157 | }); 158 | }); 159 | }); 160 | 161 | describe('unsetUser', () => { 162 | test('removes queries, listeners, and the user from the collection', () => { 163 | store = mockStore({ ...defaultState, listeners: { 'auth|users/123': { unsubscribe: jest.fn() } } }); 164 | return store.dispatch(Actions.unsetUser('123', 'users')).then(() => { 165 | expect(store.getActions()).toMatchSnapshot(); 166 | }); 167 | }); 168 | 169 | test('triggers only auth change action if no collection', () => { 170 | store = mockStore(defaultState); 171 | return store.dispatch(Actions.unsetUser('123')).then(() => { 172 | expect(store.getActions()).toMatchSnapshot(); 173 | }); 174 | }); 175 | }); 176 | 177 | describe('getStorageDownloadUrl', () => { 178 | test('requests download URL', () => { 179 | store = mockStore(defaultState); 180 | const getDownloadURL = jest.fn(() => Promise.resolve()); 181 | return store.dispatch(Actions.getStorageDownloadUrl({ fullPath: '/thing', getDownloadURL })).then(() => { 182 | expect(store.getActions()).toMatchSnapshot(); 183 | expect(getDownloadURL).toHaveBeenCalled(); 184 | }); 185 | }); 186 | 187 | test('does nothing if already in state', () => { 188 | store = mockStore({ ...defaultState, storage: { '/thing': { downloadUrl: '/thing' } } }); 189 | const getDownloadURL = jest.fn(() => Promise.resolve()); 190 | return store.dispatch(Actions.getStorageDownloadUrl({ fullPath: '/thing', getDownloadURL })).then(() => { 191 | expect(store.getActions()).toEqual([]); 192 | expect(getDownloadURL).not.toHaveBeenCalled(); 193 | }); 194 | }); 195 | }); 196 | 197 | describe('getStorageMetadata', () => { 198 | test('requests metadata', () => { 199 | store = mockStore(defaultState); 200 | const getMetadata = jest.fn(() => Promise.resolve()); 201 | return store.dispatch(Actions.getStorageMetadata({ fullPath: '/thing', getMetadata })).then(() => { 202 | expect(store.getActions()).toMatchSnapshot(); 203 | expect(getMetadata).toHaveBeenCalled(); 204 | }); 205 | }); 206 | 207 | test('does nothing if already in state', () => { 208 | store = mockStore({ ...defaultState, storage: { '/thing': { downloadUrl: '/thing', metadata: {} } } }); 209 | const getMetadata = jest.fn(() => Promise.resolve()); 210 | return store.dispatch(Actions.getStorageMetadata({ fullPath: '/thing', getMetadata })).then(() => { 211 | expect(store.getActions()).toEqual([]); 212 | expect(getMetadata).not.toHaveBeenCalled(); 213 | }); 214 | }); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /src/__tests__/connect.test.js: -------------------------------------------------------------------------------- 1 | import configureStore from 'redux-mock-store'; 2 | import connect from '../connect'; 3 | import { FetchStatus } from '../modules/fetchStatus'; 4 | import { shallow } from 'enzyme'; 5 | import React, { Component } from 'react'; 6 | 7 | const mockStore = configureStore(); 8 | 9 | class MockComponent extends Component { 10 | render() { 11 | return 'mock'; 12 | } 13 | } 14 | 15 | const defaultState = { 16 | auth: {}, 17 | collections: {}, 18 | listeners: {}, 19 | queries: {}, 20 | storage: {} 21 | }; 22 | 23 | describe('connect', () => { 24 | let mockGetState; 25 | let mockFirebase; 26 | let mockSelect; 27 | let mockSelectStorage; 28 | let context; 29 | 30 | beforeEach(() => { 31 | mockSelect = jest.fn(() => () => () => ({ 32 | fetchStatus: FetchStatus.NONE, 33 | doc: undefined 34 | })); 35 | mockSelectStorage = jest.fn(() => () => () => ({ 36 | fetchStatus: FetchStatus.NONE, 37 | downloadUrl: undefined 38 | })); 39 | mockGetState = jest.fn(() => defaultState); 40 | mockFirebase = { 41 | auth: {}, 42 | firestore: { doc: (path) => ({ path, firestore: true }), collection: (path) => ({ path, firestore: true }) }, 43 | messaging: {}, 44 | select: mockSelect, 45 | selectStorage: mockSelectStorage, 46 | storage: { ref: (path) => ({ path, storage: true }) }, 47 | store: mockStore(mockGetState) 48 | }; 49 | context = { firebase: mockFirebase }; 50 | }); 51 | 52 | test('passes props to the wrapped component', () => { 53 | const user = { fetchStatus: FetchStatus.LOADED, doc: { uid: '123', isAdmin: true } }; 54 | const things = { fetchStatus: FetchStatus.LOADED, docs: [] }; 55 | mockSelect.mockReturnValueOnce(() => () => user).mockReturnValueOnce(() => () => things); 56 | const ConnectedComponent = connect((select, { firestore }, props) => ({ 57 | user: select(firestore.doc(`users/${props.uid}`)), 58 | things: select(firestore.collection('things')) 59 | }))(MockComponent); 60 | 61 | const wrapper = shallow(, { context }); 62 | 63 | expect(wrapper.find(MockComponent).props()).toEqual({ 64 | foo: 'bar', 65 | auth: mockFirebase.auth, 66 | firestore: mockFirebase.firestore, 67 | messaging: mockFirebase.messaging, 68 | storage: mockFirebase.storage, 69 | uid: '123', 70 | user, 71 | things 72 | }); 73 | }); 74 | 75 | test('updates the component when state changes', () => { 76 | const things = { fetchStatus: FetchStatus.LOADING, docs: [] }; 77 | const selector = jest.fn(() => things); 78 | mockSelect.mockReturnValue(() => selector); 79 | const ConnectedComponent = connect((select, { firestore }) => ({ 80 | things: select(firestore.collection('things')) 81 | }))(MockComponent); 82 | const wrapper = shallow(, { context }); 83 | expect(wrapper.find(MockComponent).prop('things')).toEqual(things); 84 | const newThings = { fetchStatus: FetchStatus.LOADED, docs: ['123'] }; 85 | selector.mockReturnValue(newThings); 86 | mockFirebase.store.dispatch({ type: 'fake' }); 87 | wrapper.update(); 88 | expect(wrapper.find(MockComponent).prop('things')).toEqual(newThings); 89 | }); 90 | 91 | test('calls appropriate selector depending on type given', () => { 92 | const userOptions = { a: 1 }; 93 | const thingOptions = { b: 2 }; 94 | const imageOptions = { c: 3 }; 95 | const ConnectedComponent = connect((select, { firestore, storage }) => ({ 96 | user: select(firestore.doc('user'), userOptions), 97 | things: select(firestore.collection('things'), thingOptions), 98 | image: select(storage.ref('image'), imageOptions) 99 | }))(MockComponent); 100 | 101 | shallow(, { context }); 102 | expect(mockSelect).toHaveBeenCalledWith({ firestore: true, path: 'user' }, userOptions); 103 | expect(mockSelect).toHaveBeenCalledWith({ firestore: true, path: 'things' }, thingOptions); 104 | expect(mockSelectStorage).toHaveBeenCalledWith({ storage: true, path: 'image' }, imageOptions); 105 | }); 106 | 107 | test('allows non-selector selectors', () => { 108 | const ConnectedComponent = connect((select, { firestore }) => ({ 109 | foo: null, 110 | bar: '123', 111 | user: select(firestore.doc('user')) 112 | }))(MockComponent); 113 | 114 | const wrapper = shallow(, { context }); 115 | expect(wrapper.find(MockComponent).prop('foo')).toEqual(null); 116 | expect(wrapper.find(MockComponent).prop('bar')).toEqual('123'); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/__tests__/connectAuth.test.js: -------------------------------------------------------------------------------- 1 | import configureStore from 'redux-mock-store'; 2 | import connectAuth from '../connectAuth'; 3 | import { FetchStatus } from '../modules/fetchStatus'; 4 | import { shallow } from 'enzyme'; 5 | import React, { Component } from 'react'; 6 | 7 | const mockStore = configureStore(); 8 | 9 | class MockComponent extends Component { 10 | render() { 11 | return 'mock'; 12 | } 13 | } 14 | 15 | class Loading extends Component { 16 | render() { 17 | return 'loading'; 18 | } 19 | } 20 | 21 | const defaultState = { 22 | auth: {}, 23 | collections: {}, 24 | listeners: {}, 25 | queries: {} 26 | }; 27 | 28 | describe('connectAuth', () => { 29 | let mockGetState; 30 | let mockFirebase; 31 | let mockSelectAuth; 32 | let context; 33 | 34 | beforeEach(() => { 35 | mockSelectAuth = jest.fn(() => ({ fetchStatus: FetchStatus.NONE, doc: undefined })); 36 | mockGetState = jest.fn(() => defaultState); 37 | mockFirebase = { 38 | auth: {}, 39 | firestore: {}, 40 | messaging: {}, 41 | selectAuth: mockSelectAuth, 42 | storage: {}, 43 | store: mockStore(mockGetState) 44 | }; 45 | context = { firebase: mockFirebase }; 46 | }); 47 | 48 | test('renders null while loading', () => { 49 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADING }); 50 | const ConnectedComponent = connectAuth()(MockComponent); 51 | const wrapper = shallow(, { context }); 52 | expect(wrapper.getElement()).toBe(null); 53 | }); 54 | 55 | test('renders a loading component while loading', () => { 56 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADING }); 57 | const ConnectedComponent = connectAuth(() => {}, Loading)(MockComponent); 58 | const wrapper = shallow(, { context }); 59 | expect(wrapper.is(Loading)).toBe(true); 60 | }); 61 | 62 | test('passes props to the wrapped component', () => { 63 | const userDoc = { uid: '123', isAdmin: true }; 64 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADED, doc: userDoc }); 65 | const ConnectedComponent = connectAuth()(MockComponent); 66 | const wrapper = shallow(, { context }); 67 | expect(wrapper.find(MockComponent).props()).toEqual({ 68 | foo: 'bar', 69 | auth: mockFirebase.auth, 70 | authFetchStatus: FetchStatus.LOADED, 71 | authUserDoc: userDoc, 72 | firestore: mockFirebase.firestore, 73 | messaging: mockFirebase.messaging, 74 | storage: mockFirebase.storage 75 | }); 76 | }); 77 | 78 | test('updates the component when state changes', () => { 79 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADING, doc: {} }); 80 | const userDoc = { uid: '123', isAdmin: true }; 81 | const ConnectedComponent = connectAuth()(MockComponent); 82 | const wrapper = shallow(, { context }); 83 | expect(wrapper.getElement()).toBe(null); 84 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADED, doc: userDoc }); 85 | mockFirebase.store.dispatch({ type: 'fake' }); 86 | wrapper.update(); 87 | expect(wrapper.is(MockComponent)).toBe(true); 88 | }); 89 | 90 | test('calls the handler function on update if the state has changed', () => { 91 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADING, doc: undefined }); 92 | const userDoc = { uid: '123', isAdmin: true }; 93 | const handler = jest.fn(); 94 | const ConnectedComponent = connectAuth(handler)(MockComponent); 95 | const wrapper = shallow(, { context }); 96 | expect(handler).not.toHaveBeenCalled(); 97 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADED, doc: userDoc }); 98 | mockFirebase.store.dispatch({ type: 'fake' }); 99 | wrapper.update(); 100 | expect(handler).toHaveBeenCalledWith( 101 | { 102 | action: 'signin', 103 | doc: userDoc, 104 | fetchStatus: FetchStatus.LOADED 105 | }, 106 | mockFirebase.auth, 107 | { foo: 'bar' } 108 | ); 109 | }); 110 | 111 | test('calls the handler function before render if already loaded', () => { 112 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADED }); 113 | const handler = jest.fn(); 114 | const ConnectedComponent = connectAuth(handler)(MockComponent); 115 | shallow(, { context }); 116 | expect(handler).toHaveBeenCalled(); 117 | }); 118 | 119 | test('renders null if the handler function returns false', () => { 120 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADED, doc: undefined }); 121 | const handler = jest.fn(() => false); 122 | const ConnectedComponent = connectAuth(handler)(MockComponent); 123 | const wrapper = shallow(, { context }); 124 | expect(wrapper.get(0)).toBeNull(); 125 | }); 126 | 127 | test('renders if the handler function returns anything else', () => { 128 | mockSelectAuth.mockReturnValue({ fetchStatus: FetchStatus.LOADED, doc: undefined }); 129 | const handler = jest.fn(); 130 | const ConnectedComponent = connectAuth(handler)(MockComponent); 131 | const wrapper = shallow(, { context }); 132 | expect(wrapper.get(0)).not.toBeNull(); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/__tests__/selectors.test.js: -------------------------------------------------------------------------------- 1 | import { batchMiddleware } from '../middleware/batch'; 2 | import configureStore from 'redux-mock-store'; 3 | import { FetchStatus } from '../modules/fetchStatus'; 4 | import thunk from 'redux-thunk'; 5 | import { initSelect, initSelectAuth } from '../selectors'; 6 | 7 | const middlewares = [thunk, batchMiddleware]; 8 | const mockStore = configureStore(middlewares); 9 | const taco = { id: '123', type: 'delicious' }; 10 | const mockState = { 11 | auth: { uid: '123' }, 12 | collections: { tacos: { [taco.id]: taco } }, 13 | queries: { 'tacos/123': { documentIds: ['123'], fetchStatus: FetchStatus.LOADED } }, 14 | listeners: {} 15 | }; 16 | window.requestIdleCallback = jest.fn((cb) => cb()); 17 | 18 | describe('selectors', () => { 19 | const query = { id: 'tacos', path: 'tacos', get: () => Promise.resolve(), onSnapshot: jest.fn() }; 20 | let store; 21 | let selector; 22 | 23 | beforeEach(() => { 24 | store = mockStore(mockState); 25 | selector = initSelect(store)(query); 26 | }); 27 | 28 | describe('select', () => { 29 | test('dispatches adding the query and listener', () => { 30 | selector(); 31 | expect(store.getActions()).toMatchSnapshot(); 32 | }); 33 | 34 | test('returns fetchStatus and user doc', () => { 35 | const selector = initSelect(store)({ ...query, id: `tacos/${taco.id}`, path: `tacos/${taco.id}` }); 36 | const selectData = selector(); 37 | expect(selectData(mockState)).toMatchSnapshot(); 38 | }); 39 | 40 | test('memoizes the response', () => { 41 | const selector = initSelect(store)({ ...query, id: `tacos/${taco.id}`, path: `tacos/${taco.id}` }); 42 | const selectData = selector(); 43 | const firstState = { ...mockState, collections: { ...mockState.collections, foobar: {} } }; 44 | const secondState = { ...mockState, collections: { ...firstState.collections, foobar: { '456': {} } } }; 45 | expect(selectData(firstState)).toBe(selectData(secondState)); 46 | }); 47 | 48 | test('allows not adding a subscription listener', () => { 49 | const selector = initSelect(store)(query, { subscribe: false }); 50 | const selectData = selector(); 51 | expect(selectData(mockState)).toMatchSnapshot(); 52 | expect(store.getActions()).toMatchSnapshot(); 53 | }); 54 | }); 55 | 56 | describe('selectAuth', () => { 57 | test('if no userCollection provided, returns empty object', () => { 58 | let currentUserJSON = { uid: '123' }; 59 | const selectData = initSelectAuth({ currentUser: { uid: '123', toJSON: () => currentUserJSON } }); 60 | expect(selectData(mockState)).toMatchSnapshot(); 61 | }); 62 | 63 | test('returns fetchStatus and user doc', () => { 64 | let currentUserJSON = { uid: '123' }; 65 | const selectData = initSelectAuth({ currentUser: { uid: '123', toJSON: () => currentUserJSON } }, 'tacos'); 66 | expect(selectData(mockState)).toMatchSnapshot(); 67 | }); 68 | 69 | test('memoizes the response', () => { 70 | let currentUserJSON = { uid: '123' }; 71 | const selectData = initSelectAuth({ currentUser: { uid: '123', toJSON: () => currentUserJSON } }, 'tacos'); 72 | const firstState = { ...mockState, collections: { ...mockState.collections, foobar: {} } }; 73 | const secondState = { ...mockState, collections: { ...mockState.collections, foobar: { '456': {} } } }; 74 | expect(selectData(firstState)).toBe(selectData(secondState)); 75 | }); 76 | 77 | test('current user busts memoization', () => { 78 | const toJSON = jest.fn(() => { 79 | '123'; 80 | }); 81 | const selectData = initSelectAuth({ currentUser: { uid: '123', toJSON } }, 'tacos'); 82 | const firstData = selectData(mockState); 83 | const secondState = { ...mockState }; 84 | toJSON.mockReturnValueOnce({ uid: '123', foo: 'bar' }); 85 | expect(firstData).not.toBe(selectData(secondState)); 86 | }); 87 | 88 | test('current user busts memoization even if no userCollection provided', () => { 89 | const toJSON = jest.fn(() => { 90 | '123'; 91 | }); 92 | const selectData = initSelectAuth({ currentUser: { uid: '123', toJSON } }); 93 | const firstData = selectData(mockState); 94 | const secondState = { ...mockState }; 95 | toJSON.mockReturnValueOnce({ uid: '123', foo: 'bar' }); 96 | expect(firstData).not.toBe(selectData(secondState)); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { getCollectionQueryPath, getQueryId, getDocumentIdsForQuery } from './modules/query'; 3 | import { createActionType, createRequestActionTypes } from './modules/actionTypes'; 4 | 5 | import type { Auth } from '@firebase/auth'; 6 | import type { Firestore, Query } from '@firebase/firestore'; 7 | import type { Reference } from '@firebase/storage'; 8 | import type { StoreState } from './reducers'; 9 | import type { FluxStandardAction } from './reducers/flux-standard-action'; 10 | 11 | type Action = FluxStandardAction; 12 | type $ThunkAction = (dispatch: Dispatch, getState: GetState, args: ThunkArgs) => R; 13 | type ThunkAction = $ThunkAction; 14 | type Dispatch = (action: Action | ThunkAction) => any; 15 | type GetState = () => StoreState; 16 | type ThunkArgs = { auth: Auth, firestore: Firestore }; 17 | 18 | export const COLLECTIONS = { 19 | MODIFY: createActionType('collections/MODIFY'), 20 | MODIFY_ONE: createActionType('collections/MODIFY_ONE'), 21 | REMOVE: createActionType('collections/REMOVE') 22 | }; 23 | 24 | export const QUERIES = { 25 | ...createRequestActionTypes('queries'), 26 | ADD: createActionType('queries/ADD'), 27 | REMOVE: createActionType('queries/REMOVE') 28 | }; 29 | 30 | const onIdle = (fn: () => void) => (window.requestIdleCallback ? window.requestIdleCallback(fn) : setTimeout(fn, 1)); 31 | 32 | const _handleReceiveSnapshot = (dispatch: Dispatch, query, queryId) => (snapshot) => { 33 | const actions = []; 34 | if (snapshot.docChanges) { 35 | actions.push({ type: COLLECTIONS.MODIFY, payload: snapshot, meta: { query } }); 36 | } else { 37 | actions.push({ type: COLLECTIONS.MODIFY_ONE, payload: snapshot, meta: { query } }); 38 | } 39 | actions.push({ type: QUERIES.SUCCESS, payload: snapshot, meta: { queryId } }); 40 | dispatch(actions); 41 | }; 42 | 43 | export const addQuery = (query: Query, queryIdPrefix: string = '') => ( 44 | dispatch: Dispatch, 45 | getState: GetState 46 | ): Promise => { 47 | const state = getState(); 48 | const { queries } = state; 49 | const queryId = `${queryIdPrefix}${getQueryId(query)}`; 50 | const collectionPath = `${queryIdPrefix}${getCollectionQueryPath(query)}`; 51 | 52 | const meta = { queryId }; 53 | 54 | if (queryId !== collectionPath && collectionPath in queries) { 55 | const documentIds = getDocumentIdsForQuery(query, state); 56 | return Promise.resolve(dispatch({ type: QUERIES.ADD, payload: documentIds, meta })); 57 | } 58 | 59 | if (queryId in queries) { 60 | return Promise.resolve(); 61 | } 62 | 63 | dispatch({ type: QUERIES.REQUEST, payload: query, meta }); 64 | 65 | return query 66 | .get() 67 | .then(_handleReceiveSnapshot(dispatch, query, queryId)) 68 | .catch((error) => { 69 | dispatch({ error: true, type: QUERIES.FAILURE, payload: error, meta }); 70 | }); 71 | }; 72 | 73 | export const removeQuery = (query: Query, queryIdPrefix: string = '') => ({ 74 | type: QUERIES.REMOVE, 75 | payload: query, 76 | meta: { queryId: `${queryIdPrefix}${getQueryId(query)}` } 77 | }); 78 | 79 | export const LISTENERS = { 80 | ADD: createActionType('listeners/ADD'), 81 | REMOVE: createActionType('listeners/REMOVE') 82 | }; 83 | 84 | export const addListener = (query: Query, queryIdPrefix: string = '') => ( 85 | dispatch: Dispatch, 86 | getState: GetState 87 | ): Promise => { 88 | const { listeners } = getState(); 89 | const queryId = `${queryIdPrefix}${getQueryId(query)}`; 90 | const collectionPath = `${queryIdPrefix}${getCollectionQueryPath(query)}`; 91 | 92 | if (queryId in listeners || collectionPath in listeners) { 93 | return Promise.resolve(); 94 | } 95 | 96 | return new Promise((resolve) => { 97 | onIdle(() => { 98 | const unsubscribe = query.onSnapshot(_handleReceiveSnapshot(dispatch, query, queryId)); 99 | resolve(dispatch({ type: LISTENERS.ADD, payload: unsubscribe, meta: { queryId } })); 100 | }); 101 | }); 102 | }; 103 | 104 | export const removeListener = (query: Query, queryIdPrefix: string = '') => ( 105 | dispatch: Dispatch, 106 | getState: GetState 107 | ): Promise => { 108 | const queryId = `${queryIdPrefix}${getQueryId(query)}`; 109 | const { listeners } = getState(); 110 | 111 | if (listeners[queryId]) { 112 | listeners[queryId].unsubscribe(); 113 | return Promise.resolve( 114 | dispatch({ 115 | type: LISTENERS.REMOVE, 116 | meta: { queryId } 117 | }) 118 | ); 119 | } 120 | 121 | return Promise.resolve(); 122 | }; 123 | 124 | const _authQueryPrefix = 'auth|'; 125 | export const AUTH = { 126 | CHANGE: createActionType('auth/CHANGE') 127 | }; 128 | 129 | export const setUser = (currentUser?: { uid: string }, userCollection?: string) => ( 130 | dispatch: Dispatch, 131 | getState: GetState, 132 | { firestore }: ThunkArgs 133 | ): Promise => { 134 | const actions = []; 135 | if (currentUser && userCollection) { 136 | const query = firestore.doc(`${userCollection}/${currentUser.uid}`); 137 | actions.push(addQuery(query, _authQueryPrefix)); 138 | actions.push(addListener(query, _authQueryPrefix)); 139 | } 140 | actions.push({ type: AUTH.CHANGE, payload: currentUser }); 141 | return Promise.resolve(dispatch(actions)); 142 | }; 143 | 144 | export const unsetUser = (uid?: string, userCollection?: string) => ( 145 | dispatch: Dispatch, 146 | getState: GetState, 147 | { firestore }: ThunkArgs 148 | ): Promise => { 149 | const actions = []; 150 | if (uid) { 151 | if (userCollection) { 152 | const query = firestore.doc(`${userCollection}/${uid}`); 153 | actions.push(removeQuery(query, _authQueryPrefix)); 154 | actions.push(removeListener(query, _authQueryPrefix)); 155 | actions.push({ type: COLLECTIONS.REMOVE, payload: { id: uid }, meta: { query } }); 156 | } 157 | actions.push({ type: AUTH.CHANGE }); 158 | dispatch(actions); 159 | } 160 | return Promise.resolve(); 161 | }; 162 | 163 | export const STORAGE = { 164 | URL: createRequestActionTypes('storage/downloadUrl'), 165 | METADATA: createRequestActionTypes('storage/metadata') 166 | }; 167 | 168 | export const getStorageDownloadUrl = (reference: Reference) => ( 169 | dispatch: Dispatch, 170 | getState: GetState 171 | ): Promise => { 172 | const meta = { reference }; 173 | const state = getState(); 174 | const ref = state.storage[reference.fullPath]; 175 | if (ref && ref.downloadUrl) { 176 | return Promise.resolve(ref); 177 | } 178 | 179 | dispatch({ type: STORAGE.URL.REQUEST, meta }); 180 | return reference 181 | .getDownloadURL() 182 | .then((url) => { 183 | return dispatch({ type: STORAGE.URL.SUCCESS, payload: url, meta }); 184 | }) 185 | .catch((error) => { 186 | dispatch({ error: true, type: STORAGE.URL.FAILURE, payload: error, meta }); 187 | throw error; 188 | }); 189 | }; 190 | 191 | export const getStorageMetadata = (reference: Reference) => (dispatch: Dispatch, getState: GetState): Promise => { 192 | const meta = { reference }; 193 | const state = getState(); 194 | const ref = state.storage[reference.fullPath]; 195 | if (ref && ref.metadata) { 196 | return Promise.resolve(ref); 197 | } 198 | 199 | dispatch({ type: STORAGE.METADATA.REQUEST, meta }); 200 | return reference 201 | .getMetadata() 202 | .then((data) => { 203 | return dispatch({ type: STORAGE.METADATA.SUCCESS, payload: data, meta }); 204 | }) 205 | .catch((error) => { 206 | dispatch({ error: true, type: STORAGE.METADATA.FAILURE, payload: error, meta }); 207 | throw error; 208 | }); 209 | }; 210 | -------------------------------------------------------------------------------- /src/connect.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { Component } from 'react'; 4 | import { object } from 'prop-types'; 5 | 6 | import type firebase from 'firebase'; 7 | import type { StoreState } from './reducers'; 8 | import type { Store } from 'redux'; 9 | 10 | type Props = {}; 11 | type State = { [key: string]: any }; 12 | 13 | type Select = ( 14 | ref: firebase.firestore.DocumentReference | firebase.firestore.CollectionReference | firebase.storage.Reference, 15 | options: {} 16 | ) => any; 17 | type SelectFirestore = ( 18 | ref: firebase.firestore.DocumentReference | firebase.firestore.CollectionReference, 19 | options: {} 20 | ) => any; 21 | type SelectStorage = (ref: firebase.storage.Reference, options: {}) => any; 22 | type SelectorQueryMap = { 23 | [key: string]: () => (state: any, props: Props) => any 24 | }; 25 | 26 | type Apis = { 27 | auth: firebase.auth.Auth, 28 | firestore: firebase.firestore.Firestore, 29 | storage: firebase.storage.Storage 30 | }; 31 | 32 | export const connect = (getSelectors: (select: Select, apis: Apis, props: Props) => SelectorQueryMap) => ( 33 | WrappedComponent: React$ComponentType<*> 34 | ): React$ComponentType<*> => { 35 | return class extends Component { 36 | _unsubscribe: null | (() => void); 37 | _selectors: { [key: string]: (state: any, props: Props) => any }; 38 | context: { 39 | firebase: { 40 | auth: firebase.auth.Auth, 41 | firestore: firebase.firestore.Firestore, 42 | messaging: firebase.messaging.Messaging, 43 | select: SelectFirestore, 44 | selectStorage: SelectStorage, 45 | storage: firebase.storage.Storage, 46 | store: Store 47 | } 48 | }; 49 | 50 | static displayName = 'Connect'; 51 | static WrappedComponent = WrappedComponent; 52 | 53 | static contextTypes = { 54 | firebase: object.isRequired 55 | }; 56 | 57 | constructor(props: Props, context: any) { 58 | super(props, context); 59 | this.state = {}; 60 | } 61 | 62 | componentWillMount() { 63 | const { auth, firestore, select, selectStorage, storage, store } = this.context.firebase; 64 | const selector: Select = (ref, options) => { 65 | if ('firestore' in ref) { 66 | // $FlowFixMe 67 | return select(ref, options); 68 | } else if ('storage' in ref) { 69 | // $FlowFixMe 70 | return selectStorage(ref, options); 71 | } 72 | throw new Error('Invalid object sent to select.'); 73 | }; 74 | const querySelectors = getSelectors(selector, { auth, firestore, storage }, this.props); 75 | this._selectors = Object.keys(querySelectors).reduce((memo, propName: string) => { 76 | const selector = querySelectors[propName]; 77 | memo[propName] = typeof selector === 'function' ? selector() : selector; 78 | return memo; 79 | }, {}); 80 | this._unsubscribe = store.subscribe(this._handleState); 81 | const storeState = store.getState(); 82 | this.setState((state) => this._reduceSelectors(storeState, state)); 83 | } 84 | 85 | componentWillUnmount() { 86 | if (this._unsubscribe) { 87 | this._unsubscribe(); 88 | this._unsubscribe = null; 89 | } 90 | } 91 | 92 | render() { 93 | const { auth, firestore, messaging, storage } = this.context.firebase; 94 | return ( 95 | 103 | ); 104 | } 105 | 106 | _handleState = () => { 107 | if (this._unsubscribe) { 108 | const { store } = this.context.firebase; 109 | const storeState = store.getState(); 110 | this.setState((state) => this._reduceSelectors(storeState, state)); 111 | } 112 | }; 113 | 114 | _reduceSelectors = (storeState: StoreState, state: State) => 115 | Object.keys(this._selectors).reduce((memo, key: string) => { 116 | const selector = this._selectors[key]; 117 | state[key] = typeof selector === 'function' ? selector(storeState, this.props) : selector; 118 | return state; 119 | }, state); 120 | }; 121 | }; 122 | 123 | export default connect; 124 | -------------------------------------------------------------------------------- /src/connectAuth.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { FetchStatus } from './modules/fetchStatus'; 3 | import { object } from 'prop-types'; 4 | import React, { Component } from 'react'; 5 | 6 | import type firebase from 'firebase'; 7 | import type { StoreState } from './reducers'; 8 | import type { Store } from 'redux'; 9 | 10 | type Props = {}; 11 | type State = { 12 | doc: {}, 13 | fetchStatus: $Values 14 | }; 15 | 16 | type AuthStatusHandler = ({ action?: 'signin' | 'signout', ...State }, auth: firebase.auth.Auth, props: any) => void; 17 | 18 | const emptyObject = {}; 19 | 20 | export const connectAuth = (handleAuthStatus?: AuthStatusHandler, WrappedLoadingComponent?: React$ComponentType<*>) => ( 21 | WrappedComponent: React$ComponentType<*> 22 | ): React$ComponentType<*> => { 23 | return class extends Component { 24 | _unsubscribe: null | (() => void); 25 | _loaded: boolean; 26 | 27 | context: { 28 | firebase: { 29 | auth: firebase.auth.Auth, 30 | firestore: firebase.firestore.Firestore, 31 | messaging: firebase.messaging.Messaging, 32 | selectAuth: (state: StoreState) => any, 33 | storage: firebase.storage.Storage, 34 | store: Store 35 | } 36 | }; 37 | 38 | static displayName = 'ConnectAuth'; 39 | static WrappedComponent = WrappedComponent; 40 | 41 | static contextTypes = { 42 | firebase: object.isRequired 43 | }; 44 | 45 | constructor(props: Props, context: any) { 46 | super(props, context); 47 | this.state = { doc: emptyObject, fetchStatus: FetchStatus.LOADING }; 48 | this._loaded = false; 49 | } 50 | 51 | componentWillMount() { 52 | const { store } = this.context.firebase; 53 | this._unsubscribe = store.subscribe(this._handleState); 54 | this._handleState(); 55 | } 56 | 57 | componentWillUnmount() { 58 | if (this._unsubscribe) { 59 | this._unsubscribe(); 60 | this._unsubscribe = null; 61 | } 62 | } 63 | 64 | componentWillUpdate(nextProps: Props, nextState: State) { 65 | const prevState = this.state; 66 | const { auth } = this.context.firebase; 67 | if (nextState.fetchStatus === FetchStatus.LOADED || nextState.fetchStatus === FetchStatus.FAILED) { 68 | this._loaded = true; 69 | } 70 | if (handleAuthStatus && prevState !== nextState) { 71 | const action = 72 | prevState.doc && !nextState.doc ? 'signout' : !prevState.doc && nextState.doc ? 'signin' : undefined; 73 | handleAuthStatus({ action, doc: nextState.doc, fetchStatus: nextState.fetchStatus }, auth, nextProps); 74 | } 75 | } 76 | 77 | render() { 78 | const { auth, firestore, messaging, storage } = this.context.firebase; 79 | const { fetchStatus, doc } = this.state; 80 | const props = { 81 | auth, 82 | authFetchStatus: fetchStatus, 83 | authUserDoc: doc, 84 | firestore, 85 | messaging, 86 | storage 87 | }; 88 | if (this._loaded && fetchStatus === FetchStatus.LOADING) { 89 | return ; 90 | } 91 | switch (fetchStatus) { 92 | case FetchStatus.LOADED: 93 | case FetchStatus.FAILED: { 94 | return ; 95 | } 96 | default: 97 | return WrappedLoadingComponent ? : null; 98 | } 99 | } 100 | 101 | _handleState = () => { 102 | if (this._unsubscribe) { 103 | const { auth, selectAuth, store } = this.context.firebase; 104 | const { doc, fetchStatus } = selectAuth(store.getState()); 105 | if (handleAuthStatus && !this._loaded && fetchStatus === FetchStatus.LOADED) { 106 | const blockHandling = handleAuthStatus({ doc, fetchStatus }, auth, this.props); 107 | if (blockHandling === false) { 108 | return; 109 | } 110 | } 111 | this.setState(() => ({ doc, fetchStatus })); 112 | } 113 | }; 114 | }; 115 | }; 116 | 117 | export default connectAuth; 118 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { batchMiddleware } from './middleware/batch'; 3 | import connect from './connect'; 4 | import connectAuth from './connectAuth'; 5 | import firebase from '@firebase/app'; 6 | import '@firebase/auth'; 7 | import '@firebase/firestore'; 8 | import { FetchStatus, resolveInitialFetchStatus, resolveFetchStatus } from './modules/fetchStatus'; 9 | import Provider from './Provider'; 10 | import reducers from './reducers'; 11 | import { initSelect, initSelectAuth, initSelectStorage } from './selectors'; 12 | import { setUser, unsetUser } from './actions'; 13 | import thunk from 'redux-thunk'; 14 | import { createStore, applyMiddleware } from 'redux'; 15 | 16 | import type { App } from '@firebase/app'; 17 | import type { Error as AuthError } from '@firebase/auth'; 18 | 19 | let store; 20 | 21 | export default function init(app: App, userCollection?: string): Promise { 22 | if (store) { 23 | throw new Error('Cannot initialize store more than once.'); 24 | } 25 | 26 | const firestore = firebase.firestore(app); 27 | const auth = firebase.auth(app); 28 | 29 | const thunkArgs = { auth, firestore }; 30 | const middleware = [thunk.withExtraArgument(thunkArgs), batchMiddleware]; 31 | 32 | if (process.env.NODE_ENV !== 'production') { 33 | const createLogger = require('redux-logger').createLogger; 34 | const logger = createLogger({ collapsed: true, diff: true }); 35 | middleware.push(logger); 36 | } 37 | 38 | store = createStore(reducers, applyMiddleware(...middleware)); 39 | if (process.env.NODE_ENV !== 'production') { 40 | window.redux = store; 41 | } 42 | 43 | const select = initSelect(store); 44 | const selectAuth = initSelectAuth(auth, userCollection); 45 | const selectStorage = initSelectStorage(store); 46 | 47 | const currentUser = auth.currentUser; 48 | let currentUid; 49 | if (currentUser) { 50 | currentUid = currentUser.uid; 51 | store.dispatch(setUser(currentUser, userCollection)); 52 | } 53 | 54 | return new Promise((resolve, reject) => { 55 | auth.onAuthStateChanged( 56 | (newUser?: any) => { 57 | store 58 | .dispatch(setUser(newUser, userCollection)) 59 | .then(() => { 60 | resolve({ app, select, selectAuth, selectStorage, store }); 61 | }) 62 | .catch((error) => { 63 | reject(error); 64 | }); 65 | if (newUser) { 66 | currentUid = newUser.uid; 67 | } else { 68 | store.dispatch(unsetUser(currentUid, userCollection)); 69 | } 70 | }, 71 | (error: AuthError) => { 72 | reject(error); 73 | } 74 | ); 75 | }); 76 | } 77 | 78 | export type $FetchStatus = $Values; 79 | export type Document = { id: string, fetchStatus: $FetchStatus, doc: D }; 80 | export type Collection = { fetchStatus: $FetchStatus, docs: Array> }; 81 | 82 | export { connect, connectAuth, FetchStatus, Provider, resolveInitialFetchStatus, resolveFetchStatus }; 83 | -------------------------------------------------------------------------------- /src/middleware/batch.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { StoreState } from '../reducers'; 4 | import type { FluxStandardAction } from '../reducers/flux-standard-action'; 5 | import type { Reducer } from 'redux'; 6 | 7 | type Action = FluxStandardAction; 8 | type Dispatch = (action: Action) => any; 9 | type Next = (action: Action) => any; 10 | 11 | const BATCH_ACTION = 'BATCH'; 12 | 13 | const isPlainAction = (action: { type: string } | string): boolean => 14 | typeof action === 'object' && typeof action.type === 'string'; 15 | 16 | const actionBatch = (actions: Array) => { 17 | const meta = actions.reduce((obj, action) => { 18 | if (typeof action.meta === 'object') { 19 | Object.assign(obj, action.meta); 20 | } 21 | return obj; 22 | }, {}); 23 | return { 24 | type: BATCH_ACTION, 25 | payload: actions, 26 | meta 27 | }; 28 | }; 29 | 30 | export const batchMiddleware = ({ dispatch }: { dispatch: Dispatch }) => (next: Next) => ( 31 | actions: Array | Action 32 | ) => { 33 | if (Array.isArray(actions)) { 34 | const plainActions = []; 35 | actions.forEach((action) => { 36 | if (typeof action === 'function') { 37 | dispatch(action); 38 | } else if (isPlainAction(action)) { 39 | plainActions.push(action); 40 | } 41 | }); 42 | return plainActions.length ? next(actionBatch(plainActions)) : undefined; 43 | } else { 44 | return next(actions); 45 | } 46 | }; 47 | 48 | export const batchReducer = (reducer: Reducer) => (state: StoreState, action: Action) => { 49 | return action && action.type === BATCH_ACTION ? action.payload.reduce(reducer, state) : reducer(state, action); 50 | }; 51 | -------------------------------------------------------------------------------- /src/modules/__tests__/actionTypes.test.js: -------------------------------------------------------------------------------- 1 | import { createActionType, createRequestActionTypes } from '../actionTypes'; 2 | 3 | describe('actions', () => { 4 | describe('createActionType', () => { 5 | test('returns a namespaced string', () => { 6 | expect(createActionType('foobar')).toEqual('firestore/foobar'); 7 | }); 8 | }); 9 | 10 | describe('createRequestActionTypes', () => { 11 | test('returns request action types', () => { 12 | expect(createRequestActionTypes('foobar')).toEqual({ 13 | REQUEST: 'firestore/foobar/REQUEST', 14 | SUCCESS: 'firestore/foobar/SUCCESS', 15 | FAILURE: 'firestore/foobar/FAILURE' 16 | }); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/modules/__tests__/fetchStatus.test.js: -------------------------------------------------------------------------------- 1 | import { FetchStatus, resolveInitialFetchStatus, resolveFetchStatus } from '../fetchStatus'; 2 | 3 | const itemNone = { fetchStatus: FetchStatus.NONE }; 4 | const itemLoading = { fetchStatus: FetchStatus.LOADING }; 5 | const itemLoaded = { fetchStatus: FetchStatus.LOADED }; 6 | const itemFailed = { fetchStatus: FetchStatus.FAILED }; 7 | 8 | describe('FetchStatus', () => { 9 | describe('resolveInitialFetchStatus', () => { 10 | test('returns LOADED if any value is LOADED', () => { 11 | expect(resolveInitialFetchStatus(itemLoading, itemFailed, itemLoaded)).toBe(FetchStatus.LOADED); 12 | }); 13 | 14 | test('returns LOADING if any value is LOADING', () => { 15 | expect(resolveInitialFetchStatus(itemNone, itemFailed, itemLoading)).toBe(FetchStatus.LOADING); 16 | }); 17 | 18 | test('returns FAILED if any value is FAILED', () => { 19 | expect(resolveInitialFetchStatus(itemNone, itemFailed, itemNone)).toBe(FetchStatus.FAILED); 20 | }); 21 | 22 | test('returns NONE if all are NONE', () => { 23 | expect(resolveInitialFetchStatus(itemNone)).toBe(FetchStatus.NONE); 24 | }); 25 | }); 26 | 27 | describe('resolveFetchStatus', () => { 28 | test('returns FAILED if any value is FAILED', () => { 29 | expect(resolveFetchStatus(itemLoaded, itemFailed, itemLoading)).toBe(FetchStatus.FAILED); 30 | }); 31 | 32 | test('returns LOADING if any value is LOADING but not FAILED', () => { 33 | expect(resolveFetchStatus(itemLoaded, itemNone, itemLoading)).toBe(FetchStatus.LOADING); 34 | }); 35 | 36 | test('returns LOADED if all values are LOADED', () => { 37 | expect(resolveFetchStatus(itemLoaded, itemLoaded, itemLoaded)).toBe(FetchStatus.LOADED); 38 | }); 39 | 40 | test('returns NONE if all values are NONE', () => { 41 | expect(resolveFetchStatus(itemNone, { fetchStatus: FetchStatus.NONE })).toBe(FetchStatus.NONE); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/modules/__tests__/query.test.js: -------------------------------------------------------------------------------- 1 | import { getCollectionQueryPath, getDocumentIdsForQuery, getQueryId, getQueryPath } from '../query'; 2 | 3 | describe('query', () => { 4 | describe('getQueryId', () => { 5 | describe('for firebase.firestore.Document', () => { 6 | test('returns the id if available and equal to the path', () => { 7 | expect(getQueryId({ id: 'foobar', path: 'foobar' })).toEqual('foobar'); 8 | }); 9 | 10 | test('returns the path if it does not match the id', () => { 11 | expect(getQueryId({ id: 'foobar', path: 'foobar/123' })).toEqual('foobar/123'); 12 | }); 13 | }); 14 | 15 | describe('for Collection', () => { 16 | test('includes filters', () => { 17 | expect( 18 | getQueryId({ 19 | _query: { 20 | filters: [{ field: { segments: ['count'] }, op: { name: '<' }, value: 4 }], 21 | path: { segments: ['foo', 'bar'] } 22 | } 23 | }) 24 | ).toEqual('foo/bar:count<4'); 25 | }); 26 | }); 27 | }); 28 | 29 | describe('getCollectionQueryPath', () => { 30 | test('returns the collection path for a single Document', () => { 31 | expect(getCollectionQueryPath({ id: 'foo', path: 'foo' })).toEqual('foo'); 32 | expect(getCollectionQueryPath({ id: 'foo', path: 'foo/123' })).toEqual('foo'); 33 | expect(getCollectionQueryPath({ id: 'foo/123/bar', path: 'foo/123/bar' })).toEqual('foo/123/bar'); 34 | expect(getCollectionQueryPath({ id: 'foo/123/bar', path: 'foo/123/bar/456' })).toEqual('foo/123/bar'); 35 | }); 36 | }); 37 | 38 | describe('getQueryPath', () => { 39 | test('returns the path for simple queries', () => { 40 | expect(getQueryPath({ path: 'foo' })).toEqual('foo'); 41 | }); 42 | 43 | test('pieces together segments for complex queries', () => { 44 | expect(getQueryPath({ _query: { path: { segments: ['foo', '123', 'bar'] } } })).toEqual('foo/123/bar'); 45 | }); 46 | }); 47 | 48 | describe('getDocumentIdsForQuery', () => { 49 | test('finds the document ids from state for simple queries', () => { 50 | expect( 51 | getDocumentIdsForQuery( 52 | { path: 'foo/123' }, 53 | { collections: { foo: { '123': { id: '123' }, '456': { id: '456' } } } } 54 | ) 55 | ).toEqual(['123']); 56 | }); 57 | 58 | test('applies filters to documents for complex queries', () => { 59 | expect( 60 | getDocumentIdsForQuery( 61 | { 62 | _query: { 63 | path: { segments: ['foo'] }, 64 | filters: [{ field: { segments: ['count'] }, op: { name: '<' }, value: { internalValue: 4 } }], 65 | limit: null 66 | } 67 | }, 68 | { 69 | collections: { 70 | foo: { '123': { id: '123', count: 5 }, '456': { id: '456', count: 3 }, '789': { id: '789', count: 0 } } 71 | } 72 | } 73 | ) 74 | ).toEqual(['456', '789']); 75 | 76 | expect( 77 | getDocumentIdsForQuery( 78 | { 79 | _query: { 80 | path: { segments: ['foo'] }, 81 | filters: [ 82 | { 83 | field: { segments: ['date'] }, 84 | op: { name: '<=' }, 85 | value: { 86 | internalValue: { seconds: 1600000000000 }, 87 | toString() { 88 | return new Date(1600000000000).toString(); 89 | } 90 | } 91 | }, 92 | { field: { segments: ['b'] }, op: { name: '==' }, value: { internalValue: 4 } } 93 | ], 94 | limit: 2 95 | } 96 | }, 97 | { 98 | collections: { 99 | foo: { 100 | '123': { id: '123', b: 3, date: new Date(1500000000000) }, 101 | '456': { id: '456', b: 4, date: new Date(1600000000000) }, 102 | '789': { id: '789', b: 2, date: new Date(1700000000000) } 103 | } 104 | } 105 | } 106 | ) 107 | ).toEqual(['456']); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/modules/actionTypes.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { RequestAction } from '../modules/requestAction'; 3 | 4 | const NAMESPACE = 'firestore'; 5 | 6 | export const createActionType = (action: string) => `${NAMESPACE}/${action}`; 7 | 8 | type RequestActionTypes = { 9 | REQUEST: string, 10 | SUCCESS: string, 11 | FAILURE: string 12 | }; 13 | 14 | export const createRequestActionTypes = (request: string): RequestActionTypes => { 15 | return Object.keys(RequestAction).reduce( 16 | (memo: RequestActionTypes, key: $Keys) => { 17 | memo[key] = createActionType(`${request}/${key}`); 18 | return memo; 19 | }, 20 | { REQUEST: '', SUCCESS: '', FAILURE: '' } 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/modules/fetchStatus.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export const FetchStatus = { 3 | LOADING: 'loading', 4 | LOADED: 'loaded', 5 | FAILED: 'failed', 6 | NONE: 'none' 7 | }; 8 | 9 | type Item = { fetchStatus: $Values }; 10 | 11 | export const resolveInitialFetchStatus = (...items: Array): $Values => { 12 | const statuses = items.map((item) => item.fetchStatus); 13 | if (statuses.some((status) => status === FetchStatus.LOADED)) { 14 | return FetchStatus.LOADED; 15 | } 16 | if (statuses.some((status) => status === FetchStatus.LOADING)) { 17 | return FetchStatus.LOADING; 18 | } 19 | if (statuses.some((status) => status === FetchStatus.FAILED)) { 20 | return FetchStatus.FAILED; 21 | } 22 | return FetchStatus.NONE; 23 | }; 24 | 25 | export const resolveFetchStatus = (...items: Array): $Values => { 26 | const statuses = items.map((item) => item.fetchStatus); 27 | if (statuses.some((status) => status === FetchStatus.FAILED)) { 28 | return FetchStatus.FAILED; 29 | } 30 | if (statuses.some((status) => status === FetchStatus.LOADING)) { 31 | return FetchStatus.LOADING; 32 | } 33 | if (statuses.every((status) => status === FetchStatus.LOADED)) { 34 | return FetchStatus.LOADED; 35 | } 36 | return FetchStatus.NONE; 37 | }; 38 | -------------------------------------------------------------------------------- /src/modules/query.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { FieldPath, Query } from '@firebase/firestore'; 3 | import type { StoreState } from '../reducers'; 4 | 5 | type Operation = { name: '<' | '<=' | '==' | '>=' | '>' }; 6 | type Value = { internalValue: any }; 7 | type RelationalFilter = { 8 | field: FieldPath, 9 | op: Operation, 10 | value: Value 11 | }; 12 | 13 | const emptyArray = []; 14 | 15 | // $FlowFixMe Undocumented key/value 16 | const pathToString = (path: FieldPath) => path.segments.join('/'); 17 | 18 | const filterToString = (filter: RelationalFilter) => { 19 | const { field, op, value } = filter; 20 | return `${pathToString(field)}${op.name}${value.toString()}`; 21 | }; 22 | 23 | export const getQueryId = (query: Query): string => { 24 | // $FlowFixMe Undocumented key/value 25 | if (query.id && query.id === query.path) { 26 | // $FlowFixMe Undocumented key/value 27 | return query.id; 28 | } 29 | 30 | if (query.path && typeof query.path === 'string') { 31 | // $FlowFixMe Undocumented key/value 32 | return query.path; 33 | } 34 | 35 | if (query._query) { 36 | // $FlowFixMe Grabbing private value 37 | const { filters, limit, path } = query._query; 38 | const filterString = filters.map(filterToString).join('|'); 39 | return `${pathToString(path)}${filterString ? `:${filterString}` : ''}${limit ? `:${limit}` : ''}`; 40 | } 41 | 42 | throw new Error('Unknown query type'); 43 | }; 44 | 45 | export const getQueryPath = (query: Query): string => { 46 | if (query.path) { 47 | // $FlowFixMe Undocumented key/value 48 | return query.path; 49 | } 50 | 51 | if (query._query) { 52 | // $FlowFixMe Grabbing private value 53 | const { path } = query._query; 54 | return pathToString(path); 55 | } 56 | 57 | throw new Error('Unknown query type'); 58 | }; 59 | 60 | export const getCollectionQueryPath = (query: Query): string => { 61 | const fullPath = getQueryPath(query); 62 | const pathParts = fullPath.split('/'); 63 | return pathParts.length % 2 ? fullPath : fullPath.replace(/(\/[^/]+)$/, ''); 64 | }; 65 | 66 | const _getValueFromFieldPath = (doc, fieldPath: FieldPath) => { 67 | let value = doc; 68 | // $FlowFixMe Undocumented key/value 69 | for (let i = 0; i < fieldPath.segments.length; i++) { 70 | // $FlowFixMe Undocumented key/value 71 | const segment = fieldPath.segments[i]; 72 | value = value && typeof value === 'object' ? value[segment] : undefined; 73 | if (typeof value === undefined) { 74 | return undefined; 75 | } 76 | } 77 | return value; 78 | }; 79 | 80 | export const getDocumentIdsForQuery = (query: Query, state: StoreState): Array => { 81 | const collectionPath = getCollectionQueryPath(query); 82 | const queryPath = getQueryPath(query); 83 | const pathParts = queryPath.split('/'); 84 | const documents = state.collections[collectionPath]; 85 | if (!(pathParts.length % 2)) { 86 | const documentId = pathParts[pathParts.length - 1]; 87 | return Object.keys(documents).indexOf(documentId) >= 0 ? [documentId] : []; 88 | } 89 | 90 | if (query._query) { 91 | // $FlowFixMe Undocumented key/value 92 | const { filters, limit, orderBy } = query._query; 93 | let docs = Object.values(documents).filter((doc) => { 94 | return filters.every(({ field, op, value }) => { 95 | const docValue = _getValueFromFieldPath(doc, field); 96 | const { internalValue } = value; 97 | const normValue = 98 | typeof internalValue === 'object' 99 | ? internalValue.seconds ? new Date(internalValue.seconds) : internalValue 100 | : internalValue; 101 | switch (op.name) { 102 | case '<': 103 | return docValue < normValue; 104 | case '<=': 105 | return docValue <= normValue; 106 | case '==': 107 | return docValue == normValue; 108 | case '>': 109 | return docValue > normValue; 110 | case '>=': 111 | return docValue >= normValue; 112 | default: 113 | return true; 114 | } 115 | }); 116 | }); 117 | 118 | if (orderBy && orderBy.length > 0) { 119 | orderBy.forEach((order) => { 120 | const { dir: { name: dir }, field } = order; 121 | docs = docs.sort((a, b) => { 122 | const aValue = _getValueFromFieldPath(a, field); 123 | const bValue = _getValueFromFieldPath(b, field); 124 | if (!aValue || !bValue) { 125 | return 0; 126 | } 127 | if (typeof aValue === 'string' && typeof bValue === 'string') { 128 | return dir === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue); 129 | } 130 | if (aValue instanceof Date && bValue instanceof Date) { 131 | return dir === 'asc' ? aValue.valueOf() - bValue.valueOf() : bValue.valueOf() - aValue.valueOf(); 132 | } 133 | if (typeof aValue === 'number' && typeof bValue === 'number') { 134 | return dir === 'asc' ? aValue - bValue : bValue - aValue; 135 | } 136 | return 0; 137 | }); 138 | }); 139 | } 140 | 141 | return docs 142 | .map((doc) => (typeof doc === 'object' && doc && typeof doc.id === 'string' ? doc.id : undefined)) 143 | .filter(Boolean) 144 | .slice(0, limit || Infinity); 145 | } 146 | return emptyArray; 147 | }; 148 | -------------------------------------------------------------------------------- /src/modules/requestAction.js: -------------------------------------------------------------------------------- 1 | export const RequestAction = { 2 | REQUEST: 'request', 3 | SUCCESS: 'success', 4 | FAILURE: 'failure' 5 | }; 6 | -------------------------------------------------------------------------------- /src/reducers/__tests__/auth.test.js: -------------------------------------------------------------------------------- 1 | import { AUTH } from '../../actions'; 2 | import { reducer } from '../auth'; 3 | 4 | describe('auth reducer', () => { 5 | describe(`${AUTH.CHANGE}`, () => { 6 | test('returns an empty state if no payload', () => { 7 | const state = { 8 | uid: '123', 9 | displayName: 'Tacos' 10 | }; 11 | const action = { type: AUTH.CHANGE }; 12 | expect(reducer(state, action)).toEqual({}); 13 | }); 14 | 15 | test('converts the payload to JSON', () => { 16 | const newState = { uid: '123', email: 'tacos@test.com' }; 17 | const action = { type: AUTH.CHANGE, payload: { toJSON: () => newState } }; 18 | expect(reducer({}, action)).toEqual(newState); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/reducers/__tests__/collections.test.js: -------------------------------------------------------------------------------- 1 | import { COLLECTIONS } from '../../actions'; 2 | import { reducer } from '../collections'; 3 | 4 | const query = { id: 'tacos', path: 'tacos' }; 5 | 6 | const state = { foo: {}, tacos: { '123': { id: '123', tacos: 2 }, '456': { id: '456' } } }; 7 | describe('collections reducer', () => { 8 | describe(`${COLLECTIONS.MODIFY}`, () => { 9 | test('returns current state if no docChanges', () => { 10 | const action = { type: COLLECTIONS.MODIFY, payload: {}, meta: { query } }; 11 | expect(reducer(state, action)).toBe(state); 12 | }); 13 | 14 | test('applies all docChanges', () => { 15 | const docChanges = [ 16 | { doc: { id: '789', data: () => ({ id: '789', tacos: 3 }) }, type: 'added' }, 17 | { doc: { id: '123', data: () => ({ id: '123', tacos: 4 }) }, type: 'modified' }, 18 | { doc: { id: '456' }, type: 'removed' } 19 | ]; 20 | const action = { type: COLLECTIONS.MODIFY, payload: { docChanges }, meta: { query } }; 21 | expect(reducer(state, action)).toEqual({ 22 | foo: {}, 23 | tacos: { 24 | '123': { id: '123', tacos: 4 }, 25 | '789': { id: '789', tacos: 3 } 26 | } 27 | }); 28 | }); 29 | }); 30 | 31 | describe(`${COLLECTIONS.MODIFY_ONE}`, () => { 32 | test('replaces the document with the new payload', () => { 33 | const action = { 34 | type: COLLECTIONS.MODIFY_ONE, 35 | payload: { id: '123', data: () => ({ id: '123', tacos: 4 }) }, 36 | meta: { query } 37 | }; 38 | expect(reducer(state, action)).toEqual({ ...state, tacos: { ...state.tacos, '123': { id: '123', tacos: 4 } } }); 39 | }); 40 | }); 41 | 42 | describe(`${COLLECTIONS.REMOVE}`, () => { 43 | test('removes a document from the collection', () => { 44 | const action = { 45 | type: COLLECTIONS.REMOVE, 46 | payload: { id: '123' }, 47 | meta: { query } 48 | }; 49 | expect(reducer(state, action).tacos['123']).toBeUndefined(); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/reducers/__tests__/listeners.test.js: -------------------------------------------------------------------------------- 1 | import { LISTENERS } from '../../actions'; 2 | import { reducer } from '../listeners'; 3 | 4 | describe('listeners reducer', () => { 5 | describe(`${LISTENERS.ADD}`, () => { 6 | test('adds a listener', () => { 7 | const fn = jest.fn(); 8 | expect(reducer({}, { type: LISTENERS.ADD, payload: fn, meta: { queryId: 'foobar' } })).toEqual({ 9 | foobar: { unsubscribe: fn } 10 | }); 11 | }); 12 | }); 13 | 14 | describe(`${LISTENERS.REMOVE}`, () => { 15 | test('removes a listener', () => { 16 | const fn = jest.fn(); 17 | const state = { 18 | foobar: { unsubscribe: fn }, 19 | tacos: { unsubscribe: fn } 20 | }; 21 | const newState = { tacos: { unsubscribe: fn } }; 22 | expect(reducer(state, { type: LISTENERS.REMOVE, meta: { queryId: 'foobar' } })).toEqual(newState); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/reducers/__tests__/queries.test.js: -------------------------------------------------------------------------------- 1 | import { FetchStatus } from '../../modules/fetchStatus'; 2 | import { QUERIES } from '../../actions'; 3 | import { reducer } from '../queries'; 4 | 5 | const state = { 6 | barfoo: { documentIds: [], fetchStatus: FetchStatus.NONE }, 7 | 'foobar/123': { 8 | documentIds: ['123', '456'], 9 | fetchStatus: FetchStatus.LOADED 10 | } 11 | }; 12 | 13 | describe('queries reducer', () => { 14 | describe(`${QUERIES.REQUEST}`, () => { 15 | test('adds the query and sets fetchStatus', () => { 16 | const action = { type: QUERIES.REQUEST, meta: { queryId: 'tacos' } }; 17 | expect(reducer(state, action)).toEqual({ 18 | ...state, 19 | tacos: { documentIds: [], fetchStatus: FetchStatus.LOADING } 20 | }); 21 | }); 22 | }); 23 | 24 | describe(`${QUERIES.SUCCESS}`, () => { 25 | test('modifies a single document', () => { 26 | const action = { type: QUERIES.SUCCESS, payload: { id: '456' }, meta: { queryId: 'barfoo' } }; 27 | expect(reducer(state, action)).toEqual({ 28 | ...state, 29 | barfoo: { documentIds: ['456'], fetchStatus: FetchStatus.LOADED } 30 | }); 31 | }); 32 | 33 | test('modifies multiple documents', () => { 34 | const docChanges = [ 35 | { doc: { id: '456', newIndex: 1 }, type: 'modified' }, 36 | { doc: { id: '123', newIndex: -1 }, type: 'removed' }, 37 | { doc: { id: '789', newIndex: 0 }, type: 'added' } 38 | ]; 39 | const action = { type: QUERIES.SUCCESS, payload: { docChanges }, meta: { queryId: 'foobar/123' } }; 40 | expect(reducer(state, action)).toEqual({ 41 | ...state, 42 | 'foobar/123': { 43 | documentIds: ['789', '456'], 44 | fetchStatus: FetchStatus.LOADED 45 | } 46 | }); 47 | }); 48 | }); 49 | 50 | describe(`${QUERIES.FAILURE}`, () => { 51 | test('sets the error and fetchStatus', () => { 52 | const error = new Error(); 53 | const action = { type: QUERIES.FAILURE, payload: error, meta: { queryId: 'foobar/123' } }; 54 | expect(reducer(state, action)).toEqual({ 55 | ...state, 56 | 'foobar/123': { documentIds: ['123', '456'], error, fetchStatus: FetchStatus.FAILED } 57 | }); 58 | }); 59 | }); 60 | 61 | describe(`${QUERIES.REMOVE}`, () => { 62 | test('removes a query', () => { 63 | const action = { type: QUERIES.REMOVE, meta: { queryId: 'foobar/123' } }; 64 | expect(reducer(state, action)).toEqual({ barfoo: state.barfoo }); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/reducers/auth.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { AUTH } from '../actions'; 3 | 4 | import type { FirebaseUser } from 'firebase'; 5 | import type { FluxStandardAction } from './flux-standard-action'; 6 | 7 | export type State = { 8 | uid?: string, 9 | ...FirebaseUser 10 | }; 11 | 12 | type Action = FluxStandardAction; 13 | 14 | const defaultState = {}; 15 | 16 | export function reducer(state: State = defaultState, action: Action) { 17 | switch (action.type) { 18 | case AUTH.CHANGE: 19 | return action.payload 20 | ? { 21 | ...state, 22 | ...action.payload.toJSON() 23 | } 24 | : defaultState; 25 | 26 | default: 27 | return state; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/reducers/collections.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { getCollectionQueryPath } from '../modules/query'; 3 | import { COLLECTIONS } from '../actions'; 4 | 5 | import type { DocumentSnapshot, QuerySnapshot, Query } from '@firebase/firestore'; 6 | import type { FluxStandardAction } from './flux-standard-action'; 7 | 8 | export type State = { 9 | [collectionPath: string]: { [id: string]: { id: string } } 10 | }; 11 | 12 | type Meta = { 13 | query: Query 14 | }; 15 | type Payload = DocumentSnapshot | QuerySnapshot; 16 | type Action = FluxStandardAction; 17 | 18 | const defaultState = {}; 19 | const emptyObject = {}; 20 | 21 | export function reducer(state: State = defaultState, action: Action) { 22 | switch (action.type) { 23 | case COLLECTIONS.MODIFY_ONE: { 24 | const { meta, payload } = action; 25 | if (!(payload && payload.id)) { 26 | return state; 27 | } 28 | 29 | const path = getCollectionQueryPath(meta.query); 30 | return { 31 | ...state, 32 | [path]: { 33 | ...state[path], 34 | // $FlowFixMe 35 | [payload.id]: { id: payload.id, ...payload.data() } 36 | } 37 | }; 38 | } 39 | 40 | case COLLECTIONS.MODIFY: { 41 | const { meta, payload } = action; 42 | if (!meta || !(payload && payload.docChanges)) { 43 | return state; 44 | } 45 | 46 | const path = getCollectionQueryPath(meta.query); 47 | 48 | // $FlowFixMe 49 | const newPathState = payload.docChanges.reduce((newState, change) => { 50 | const { doc, type: changeType } = change; 51 | const oldData = newState[doc.id] || emptyObject; 52 | switch (changeType) { 53 | case 'added': { 54 | if (!(doc.id in newState)) { 55 | newState[doc.id] = { 56 | id: doc.id, 57 | ...change.doc.data() 58 | }; 59 | } 60 | break; 61 | } 62 | 63 | case 'modified': 64 | newState[doc.id] = { 65 | ...oldData, 66 | id: doc.id, 67 | ...change.doc.data() 68 | }; 69 | break; 70 | 71 | case 'removed': 72 | delete newState[doc.id]; 73 | break; 74 | // no default 75 | } 76 | return newState; 77 | }, state[path] || {}); 78 | return { 79 | ...state, 80 | [path]: newPathState 81 | }; 82 | } 83 | 84 | case COLLECTIONS.REMOVE: { 85 | const { meta, payload: { id } } = action; 86 | const path = getCollectionQueryPath(meta.query); 87 | const pathDocs = { ...state[path] }; 88 | delete pathDocs[id]; 89 | return { 90 | ...state, 91 | [path]: pathDocs 92 | }; 93 | } 94 | 95 | default: 96 | return state; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/reducers/flux-standard-action.js: -------------------------------------------------------------------------------- 1 | export type FluxStandardAction = { 2 | error?: boolean, 3 | meta: Meta, 4 | payload: Payload, 5 | type: ActionType 6 | }; 7 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { batchReducer } from '../middleware/batch'; 3 | import { combineReducers } from 'redux'; 4 | import { reducer as auth } from './auth'; 5 | import { reducer as collections } from './collections'; 6 | import { reducer as listeners } from './listeners'; 7 | import { reducer as queries } from './queries'; 8 | import { reducer as storage } from './storage'; 9 | 10 | import type { State as AuthState } from './auth'; 11 | import type { State as CollectionsState } from './collections'; 12 | import type { State as ListenersState } from './listeners'; 13 | import type { State as QueriesState } from './queries'; 14 | import type { State as StorageState } from './storage'; 15 | 16 | export type StoreState = { 17 | auth: AuthState, 18 | collections: CollectionsState, 19 | listeners: ListenersState, 20 | queries: QueriesState, 21 | storage: StorageState 22 | }; 23 | 24 | const reducer = batchReducer( 25 | combineReducers({ 26 | auth, 27 | collections, 28 | listeners, 29 | queries, 30 | storage 31 | }) 32 | ); 33 | 34 | export default reducer; 35 | -------------------------------------------------------------------------------- /src/reducers/listeners.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { LISTENERS } from '../actions'; 3 | 4 | import type { FluxStandardAction } from './flux-standard-action'; 5 | 6 | export type State = { 7 | [queryId: string]: { 8 | unsubscribe: () => void 9 | } 10 | }; 11 | 12 | type Meta = { queryId: string }; 13 | type Action = FluxStandardAction void, Meta>; 14 | 15 | const defaultState = {}; 16 | 17 | export function reducer(state: State = defaultState, action: Action) { 18 | switch (action.type) { 19 | case LISTENERS.ADD: { 20 | const { meta: { queryId }, payload } = action; 21 | return { 22 | ...state, 23 | [queryId]: { 24 | unsubscribe: payload 25 | } 26 | }; 27 | } 28 | 29 | case LISTENERS.REMOVE: { 30 | const { meta: { queryId } } = action; 31 | const newState = { ...state }; 32 | delete newState[queryId]; 33 | return newState; 34 | } 35 | 36 | default: 37 | return state; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/reducers/queries.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { QUERIES } from '../actions'; 3 | import { FetchStatus } from '../modules/fetchStatus'; 4 | 5 | import type { DocumentSnapshot, QuerySnapshot } from '@firebase/firestore'; 6 | import type { FluxStandardAction } from './flux-standard-action'; 7 | 8 | export type QueryState = { 9 | documentIds: Array, 10 | error?: Error, 11 | fetchStatus: $Values 12 | }; 13 | export type State = { 14 | [queryId: string]: QueryState 15 | }; 16 | 17 | type QueryMeta = { queryId: string }; 18 | type Action = FluxStandardAction; 19 | 20 | const defaultState = {}; 21 | const defaultQueryState = { 22 | documentIds: [], 23 | error: undefined, 24 | fetchStatus: FetchStatus.NONE 25 | }; 26 | 27 | const sortChanges = (a: { doc: { newIndex: number } }, b: { doc: { newIndex: number } }) => 28 | a.doc.newIndex - b.doc.newIndex; 29 | 30 | const updateMultiple = (state: State, payload: QuerySnapshot, queryId: string): State => { 31 | const documentIds = payload.docChanges.sort(sortChanges).reduce( 32 | (memo, change) => { 33 | const { doc, type: changeType, newIndex } = change; 34 | switch (changeType) { 35 | case 'added': 36 | case 'modified': 37 | if (memo.indexOf(doc.id) !== -1) { 38 | return memo; 39 | } 40 | if (memo.length === 0) { 41 | return [doc.id]; 42 | } 43 | memo.splice(newIndex, 0, doc.id); 44 | return memo; 45 | case 'removed': 46 | memo.splice(newIndex, 1); 47 | return memo; 48 | default: 49 | return memo; 50 | } 51 | }, 52 | [...state[queryId].documentIds] 53 | ); 54 | return { 55 | ...state, 56 | [queryId]: { 57 | ...state[queryId], 58 | documentIds, 59 | fetchStatus: FetchStatus.LOADED 60 | } 61 | }; 62 | }; 63 | 64 | const updateOne = (state: State, payload: DocumentSnapshot, queryId: string): State => { 65 | return { 66 | ...state, 67 | [queryId]: { 68 | ...state[queryId], 69 | documentIds: [payload.id], 70 | fetchStatus: FetchStatus.LOADED 71 | } 72 | }; 73 | }; 74 | 75 | export function reducer(state: State = defaultState, action: Action): State { 76 | switch (action.type) { 77 | case QUERIES.REQUEST: { 78 | const { meta: { queryId } } = action; 79 | return { 80 | ...state, 81 | [queryId]: { 82 | ...defaultQueryState, 83 | ...state[queryId], 84 | fetchStatus: FetchStatus.LOADING 85 | } 86 | }; 87 | } 88 | 89 | case QUERIES.SUCCESS: { 90 | const { meta: { queryId }, payload } = action; 91 | if (payload.docChanges) { 92 | return updateMultiple(state, payload, queryId); 93 | } else { 94 | return updateOne(state, payload, queryId); 95 | } 96 | } 97 | 98 | case QUERIES.FAILURE: { 99 | const { meta: { queryId }, payload } = action; 100 | return { 101 | ...state, 102 | [queryId]: { 103 | ...state[queryId], 104 | error: payload, 105 | fetchStatus: FetchStatus.FAILED 106 | } 107 | }; 108 | } 109 | 110 | case QUERIES.ADD: { 111 | const { meta: { queryId }, payload: documentIds } = action; 112 | return { 113 | ...state, 114 | [queryId]: { 115 | ...state[queryId], 116 | documentIds, 117 | fetchStatus: FetchStatus.LOADED 118 | } 119 | }; 120 | } 121 | 122 | case QUERIES.REMOVE: { 123 | const { meta: { queryId } } = action; 124 | const newState = { ...state }; 125 | delete newState[queryId]; 126 | return newState; 127 | } 128 | 129 | default: 130 | return state; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/reducers/storage.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { FetchStatus } from '../modules/fetchStatus'; 3 | import { STORAGE } from '../actions'; 4 | 5 | import type { FullMetadata, Reference } from '@firebase/storage'; 6 | import type { FluxStandardAction } from './flux-standard-action'; 7 | 8 | export type State = { 9 | [referencePath: string]: { 10 | downloadUrl?: string, 11 | error?: Error, 12 | fetchStatus: $Values, 13 | metadata?: FullMetadata 14 | } 15 | }; 16 | 17 | type Meta = { 18 | reference: Reference 19 | }; 20 | type Payload = FullMetadata | string; 21 | type Action = FluxStandardAction; 22 | 23 | const defaultState = {}; 24 | 25 | export function reducer(state: State = defaultState, action: Action): State { 26 | switch (action.type) { 27 | case STORAGE.URL.REQUEST: { 28 | const { meta: { reference } } = action; 29 | return { 30 | ...state, 31 | [reference.fullPath]: { ...state[reference.fullPath], fetchStatus: FetchStatus.LOADING } 32 | }; 33 | } 34 | 35 | case STORAGE.URL.SUCCESS: { 36 | const { meta: { reference }, payload } = action; 37 | return { 38 | ...state, 39 | [reference.fullPath]: { fetchStatus: FetchStatus.LOADED, downloadUrl: payload } 40 | }; 41 | } 42 | 43 | case STORAGE.URL.FAILURE: { 44 | const { meta: { reference } } = action; 45 | return { 46 | ...state, 47 | [reference.fullPath]: { ...state[reference.fullPath], fetchStatus: FetchStatus.FAILED } 48 | }; 49 | } 50 | 51 | case STORAGE.METADATA.REQUEST: { 52 | const { meta: { reference } } = action; 53 | return { 54 | ...state, 55 | [reference.fullPath]: { ...state[reference.fullPath], fetchStatus: FetchStatus.LOADING } 56 | }; 57 | } 58 | 59 | case STORAGE.METADATA.SUCCESS: { 60 | const { meta: { reference }, payload } = action; 61 | return { 62 | ...state, 63 | [reference.fullPath]: { 64 | ...state[reference.fullPath], 65 | downloadUrl: state[reference.fullPath].downloadUrl || payload.downloadUrls[0], 66 | fetchStatus: FetchStatus.LOADED, 67 | metadata: payload 68 | } 69 | }; 70 | } 71 | 72 | case STORAGE.METADATA.FAILURE: { 73 | const { meta: { reference } } = action; 74 | return { 75 | ...state, 76 | [reference.fullPath]: { ...state[reference.fullPath], fetchStatus: FetchStatus.FAILED } 77 | }; 78 | } 79 | 80 | default: 81 | return state; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/selectors.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { createSelector } from 'reselect'; 3 | import { FetchStatus } from './modules/fetchStatus'; 4 | import { getCollectionQueryPath, getQueryId, getQueryPath } from './modules/query'; 5 | import { addListener, addQuery, getStorageDownloadUrl, getStorageMetadata } from './actions'; 6 | 7 | import type { Auth } from '@firebase/auth'; 8 | import type { Query } from '@firebase/firestore'; 9 | import type { Reference } from '@firebase/storage'; 10 | import type { Store } from 'redux'; 11 | import type { StoreState } from './reducers'; 12 | import type { QueryState } from './reducers/queries'; 13 | 14 | type SelectOptions = { 15 | subscribe?: boolean 16 | }; 17 | 18 | type StorageOptions = { 19 | metadata?: boolean 20 | }; 21 | 22 | const selectOptionDefaults = { 23 | subscribe: true 24 | }; 25 | 26 | const stroageOptionDefaults = { 27 | metadata: false 28 | }; 29 | 30 | const emptyArray = []; 31 | 32 | const singleUndefined = { fetchStatus: FetchStatus.NONE, doc: undefined }; 33 | const collectionUndefiend = { fetchStatus: FetchStatus.NONE, docs: emptyArray }; 34 | 35 | const selectQueries = (state: StoreState) => state.queries; 36 | 37 | export const initSelect = (store: Store<*, *, *>) => (query: Query, selectOptions?: SelectOptions) => { 38 | const options = { ...selectOptionDefaults, ...selectOptions }; 39 | const queryId = getQueryId(query); 40 | const queryPath = getQueryPath(query); 41 | const collectionQueryPath = getCollectionQueryPath(query); 42 | const isDocument = collectionQueryPath !== queryPath; 43 | 44 | const selectStoreQuery = (state: StoreState) => state.queries[queryId]; 45 | const selectCollection = (state: StoreState) => state.collections[collectionQueryPath]; 46 | 47 | const selector = createSelector([selectStoreQuery, selectCollection], (storeQuery, collection) => { 48 | if (storeQuery && collection) { 49 | const { documentIds, fetchStatus } = storeQuery; 50 | if (documentIds) { 51 | const docs = documentIds.map((id: string) => collection[id]).filter(Boolean); 52 | return isDocument ? { fetchStatus, doc: docs[0] } : { fetchStatus, docs }; 53 | } 54 | } 55 | return isDocument ? singleUndefined : collectionUndefiend; 56 | }); 57 | 58 | return () => { 59 | const actions = [addQuery(query)]; 60 | if (options.subscribe) { 61 | actions.push(addListener(query)); 62 | } 63 | store.dispatch(actions); 64 | return selector; 65 | }; 66 | }; 67 | 68 | export const initSelectAuth = (auth: Auth, userCollection?: string) => { 69 | const loggedOut = { fetchStatus: FetchStatus.LOADED, doc: undefined }; 70 | const selectUid = (state: StoreState) => state.auth.uid; 71 | const selectStoreQuery = createSelector( 72 | [selectUid, selectQueries], 73 | (uid, queries) => (userCollection && queries ? queries[`auth|${userCollection}/${uid}`] : undefined) 74 | ); 75 | const selectUsersCollection = (state: StoreState) => (userCollection ? state.collections[userCollection] : undefined); 76 | const selectUserData = createSelector( 77 | [selectUid, selectUsersCollection], 78 | (uid, users) => (users ? users[uid] : undefined) 79 | ); 80 | const selectCurrentUser = () => (auth.currentUser ? JSON.stringify(auth.currentUser.toJSON()) : undefined); 81 | 82 | const selector = createSelector([selectUid, selectCurrentUser, selectStoreQuery, selectUserData], ( 83 | uid: string, 84 | currentUser?: string, // used for selector cache busting. Use auth.currentUser with connectAuth. 85 | storeQuery?: QueryState, 86 | doc: {} 87 | ) => { 88 | if (!uid) { 89 | return loggedOut; 90 | } 91 | const fetchStatus = storeQuery ? storeQuery.fetchStatus : !userCollection ? FetchStatus.LOADED : FetchStatus.NONE; 92 | return { 93 | fetchStatus, 94 | doc: userCollection ? doc : {} // use a new object if no userCollection to cache bust 95 | }; 96 | }); 97 | 98 | return selector; 99 | }; 100 | 101 | export const initSelectStorage = (store: Store<*, *, *>) => (ref: Reference, storageOptions?: StorageOptions) => { 102 | const options = { 103 | ...stroageOptionDefaults, 104 | ...storageOptions 105 | }; 106 | const actionCreator = options.metadata ? getStorageMetadata : getStorageDownloadUrl; 107 | const selector = (state: StoreState) => state.storage[ref.fullPath]; 108 | return () => { 109 | store.dispatch(actionCreator(ref)); 110 | return selector; 111 | }; 112 | }; 113 | --------------------------------------------------------------------------------