├── .github └── workflows │ └── push_deploy.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── firebase.json ├── gulpfile.js ├── jest.config.js ├── package.json ├── src-demo ├── .gitignore ├── FIREBASE_CONFIG.json.EXAMPLE ├── README.md ├── firebase.json ├── package.json ├── public │ ├── .nojekyll │ ├── favicon.ico │ ├── global.css │ ├── index.html │ └── manifest.json └── src │ ├── App.js │ ├── CustomDeleteButtons.js │ ├── CustomForgotPassword.js │ ├── CustomLoginPage.js │ ├── EventMonitor.js │ ├── FirebaseReferenceFields.js │ ├── comments.js │ ├── index.js │ ├── posts.js │ ├── registerServiceWorker.js │ └── users.js ├── src ├── index.ts ├── misc │ ├── arrayHelpers.ts │ ├── dispatcher.ts │ ├── document-parser.ts │ ├── firebase-models.ts │ ├── index.ts │ ├── internal.models.ts │ ├── logger │ │ ├── firestore-logger.ts │ │ ├── index.ts │ │ ├── logger-base.ts │ │ └── logger.ts │ ├── messageTypes.ts │ ├── metadata-parser.ts │ ├── objectFlatten.ts │ ├── pathHelper.ts │ ├── react-admin-models.ts │ ├── status-code-translator.ts │ ├── storage-parser.ts │ ├── translate-from-firestore.ts │ └── translate-to-firestore.ts ├── providers │ ├── AuthProvider.ts │ ├── DataProvider.ts │ ├── commands │ │ ├── Create.ts │ │ ├── Delete.Soft.ts │ │ ├── Delete.ts │ │ ├── DeleteMany.Soft.ts │ │ ├── DeleteMany.ts │ │ ├── Update.ts │ │ ├── UpdateMany.ts │ │ └── index.ts │ ├── database │ │ ├── FireClient.ts │ │ ├── ResourceManager.ts │ │ ├── firebase │ │ │ ├── FirebaseWrapper.ts │ │ │ └── IFirebaseWrapper.ts │ │ └── index.ts │ ├── lazy-loading │ │ ├── FirebaseLazyLoadingClient.ts │ │ ├── paramsToQuery.ts │ │ └── queryCursors.ts │ ├── options.ts │ └── queries │ │ ├── GetList.ts │ │ ├── GetMany.ts │ │ ├── GetManyReference.ts │ │ ├── GetOne.ts │ │ └── index.ts └── types.d.ts ├── storage.rules ├── tests ├── Path.spec.ts ├── arrayHelpers.filtering.spec.ts ├── arrayHelpers.sorting.spec.ts ├── file-parser.spec.ts ├── firestore-parser.spec.ts ├── integration-tests │ ├── ApiCreate.spec.ts │ ├── ApiDelete.spec.ts │ ├── ApiDeleteMany.spec.ts │ ├── ApiGetList.spec.ts │ ├── ApiGetMany.spec.ts │ ├── ApiGetOne.spec.ts │ ├── ApiRootRef.spec.ts │ ├── ApiSoftDelete.spec.ts │ ├── ApiSoftDeleteMany.spec.ts │ ├── ApiUpdate.spec.ts │ ├── ApiUpdateMany.spec.ts │ └── utils │ │ ├── FirebaseWrapperStub.ts │ │ └── test-helpers.ts ├── objectFlatten.spec.ts ├── providers │ └── lazy-loading │ │ └── paramsToQuery.spec.ts ├── reference-document-parser.spec.ts └── storagePath.spec.ts ├── tsconfig.json ├── tsconfig.spec.json ├── tslint.json └── yarn.lock /.github/workflows/push_deploy.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: "Build, Test & Deploy 🚀" 3 | jobs: 4 | build: 5 | if: "!contains(github.event.head_commit.message, 'no-ci')" 6 | name: Build Lib 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: 20.18.1 13 | - name: Cache node_modules 14 | id: cache-modules 15 | uses: actions/cache@v3 16 | with: 17 | path: node_modules 18 | key: ${{ runner.OS }}-build-${{ hashFiles('package.json') }} 19 | - name: Install Dependencies 20 | if: steps.cache-modules.outputs.cache-hit != 'true' 21 | run: yarn install --pure-lockfile 22 | - name: Check library builds 23 | run: yarn build 24 | - name: Check tests build 25 | run: yarn tsc-test 26 | - name: Check tests pass 27 | run: yarn test 28 | - name: Upload lib build artifact 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: build-lib 32 | path: dist 33 | 34 | deploy: 35 | if: "!contains(github.event.head_commit.message, 'no-deploy-lib')" 36 | needs: build 37 | name: Deploy Library 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@master 41 | - uses: actions/setup-node@v1 42 | with: 43 | node-version: 20.18.1 44 | - uses: actions/cache@v3 45 | id: cache-modules 46 | with: 47 | path: node_modules 48 | key: ${{ runner.OS }}-deploy-${{ hashFiles('package.json') }} 49 | - name: Download build-lib 50 | uses: actions/download-artifact@v4 51 | with: 52 | name: build-lib 53 | path: dist 54 | - name: Deploy to npm! 🚀 (if master branch) 55 | if: github.ref == 'refs/heads/master' 56 | uses: benwinding/merge-release@e0e3e53924f252ebe0fc52dced2c253de984452a 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 60 | 61 | demo: 62 | needs: build 63 | name: Deploy Demo 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@master 67 | - uses: actions/setup-node@v1 68 | with: 69 | node-version: 20.18.1 70 | - uses: actions/cache@v3 71 | id: cache-modules 72 | with: 73 | path: src-demo/node_modules 74 | key: ${{ runner.OS }}-demo-${{ hashFiles('src-demo/package.json') }} 75 | - name: Install Dependencies 76 | if: steps.cache-modules.outputs.cache-hit != 'true' 77 | run: yarn install 78 | working-directory: ./src-demo 79 | - run: REACT_APP_FIREBASE_CONFIG="${{ secrets.FIREBASE_JSON }}" yarn build-ci 80 | working-directory: ./src-demo 81 | - name: Deploy to GH Pages 🚀 82 | if: github.ref == 'refs/heads/master' 83 | uses: JamesIves/github-pages-deploy-action@releases/v3 84 | with: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | BRANCH: gh-pages # The branch the action should deploy to. 87 | FOLDER: src-demo/build # The folder the action should deploy. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | node_modules 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /build 12 | 13 | # ignore dist now 14 | /dist 15 | dist 16 | 17 | #ide 18 | .idea 19 | 20 | # misc 21 | .DS_Store 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | firestore-debug.log* 31 | firebase-debug.log* 32 | 33 | src/**/*.js 34 | .rts* 35 | TEST.config.ts 36 | FIREBASE_CONFIG.js 37 | 38 | database-debug.log 39 | firestore-debug.log 40 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "organizeImportsSkipDestructiveCodeActions": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest Current File", 11 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 12 | "args": [ 13 | "${relativeFile}", 14 | "--detectOpenHandles", 15 | "--forceExit" 16 | ], 17 | "console": "integratedTerminal", 18 | "internalConsoleOptions": "neverOpen", 19 | "skipFiles": [ 20 | "/**/*.js" 21 | ] 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-admin-firebase 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/react-admin-firebase.svg)](https://www.npmjs.com/package/react-admin-firebase) 4 | [![License](https://img.shields.io/npm/l/react-admin-firebase.svg)](https://github.com/benwinding/react-admin-firebase/blob/master/LICENSE) 5 | [![Downloads/week](https://img.shields.io/npm/dm/react-admin-firebase.svg)](https://www.npmjs.com/package/react-admin-firebase) 6 | [![Github Issues](https://img.shields.io/github/issues/benwinding/react-admin-firebase.svg)](https://github.com/benwinding/react-admin-firebase) 7 | 8 | 9 | A firebase data provider for the [React-Admin](https://github.com/marmelab/react-admin) framework. It maps collections from the Firebase database (Firestore) to your react-admin application. It's an [npm package](https://www.npmjs.com/package/react-admin-firebase)! 10 | 11 | --- 12 | 13 | ## Features 14 | - [x] Firestore Dataprovider _(details below)_ 15 | - [x] Firebase AuthProvider (email, password) 16 | - [x] Login with: Google, Facebook, Github etc... [(Example Here)](https://github.com/benwinding/react-admin-firebase/blob/master/src-demo/src/CustomLoginPage.js) 17 | - [x] Forgot password button... [(Example Here)](https://github.com/benwinding/react-admin-firebase/blob/master/src-demo/src/CustomForgotPassword.js) 18 | - [x] Firebase storage upload functionality, with upload monitoring... [(Example Here)](https://github.com/benwinding/react-admin-firebase/blob/master/src-demo/src/EventMonitor.js) 19 | 20 | _Pull requests welcome!!_ 21 | 22 | ## Firestore Dataprovider Features 23 | - [x] Dynamic caching of resources 24 | - [x] All methods implemented; `(GET, POST, GET_LIST ect...)` 25 | - [x] Filtering, sorting etc... 26 | - [x] Ability to manage sub collections through app configuration 27 | - [x] Ability to use externally initialized firebaseApp instance 28 | - [x] Override firestore random id by using "id" as a field in the Create part of the resource 29 | - [x] Upload to the firebase storage bucket using the standard `` field 30 | - [ ] Realtime updates, using ra-realtime 31 | - Optional watch collection array or dontwatch collection array 32 | 33 | ## Get Started 34 | `yarn add react-admin-firebase firebase` 35 | 36 | or 37 | 38 | `npm install --save react-admin-firebase firebase` 39 | 40 | ## Demos Basic 41 | A simple example based on the [React Admin Tutorial](https://marmelab.com/react-admin/Tutorial.html). 42 | 43 | - [Demo Project (javascript)](https://github.com/benwinding/demo-react-admin-firebase) 44 | - [Demo Project (typescript)](https://github.com/benwinding/react-admin-firebase-demo-typescript) 45 | 46 | ### Prerequisits 47 | - Create a `posts` collection in the firebase firestore database 48 | - Get config credentials using the dashboard 49 | 50 | ## Options 51 | 52 | ``` javascript 53 | import { 54 | FirebaseAuthProvider, 55 | FirebaseDataProvider, 56 | FirebaseRealTimeSaga 57 | } from 'react-admin-firebase'; 58 | 59 | const config = { 60 | apiKey: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 61 | authDomain: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 62 | databaseURL: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 63 | projectId: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 64 | storageBucket: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 65 | messagingSenderId: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 66 | }; 67 | 68 | // All options are optional 69 | const options = { 70 | // Use a different root document to set your resource collections, by default it uses the root collections of firestore 71 | rootRef: 'root-collection/some-doc' | () => 'root-collection/some-doc', 72 | // Your own, previously initialized firebase app instance 73 | app: firebaseAppInstance, 74 | // Enable logging of react-admin-firebase 75 | logging: true, 76 | // Resources to watch for realtime updates, will implicitly watch all resources by default, if not set. 77 | watch: ['posts'], 78 | // Resources you explicitly dont want realtime updates for 79 | dontwatch: ['comments'], 80 | // Authentication persistence, defaults to 'session', options are 'session' | 'local' | 'none' 81 | persistence: 'session', 82 | // Disable the metadata; 'createdate', 'lastupdate', 'createdby', 'updatedby' 83 | disableMeta: false, 84 | // Have custom metadata field names instead of: 'createdate', 'lastupdate', 'createdby', 'updatedby' 85 | renameMetaFields: { 86 | created_at: 'my_created_at', // default: 'createdate' 87 | created_by: 'my_created_by', // default: 'createdby' 88 | updated_at: 'my_updated_at', // default: 'lastupdate' 89 | updated_by: 'my_updated_by', // default: 'updatedby' 90 | }, 91 | // Prevents document from getting the ID field added as a property 92 | dontAddIdFieldToDoc: false, 93 | // Adds 'deleted' meta field for non-destructive deleting functionality 94 | // NOTE: Hides 'deleted' records from list views unless overridden by filtering for {deleted: true} 95 | softDelete: false, 96 | // Changes meta fields like 'createdby' and 'updatedby' to store user IDs instead of email addresses 97 | associateUsersById: false, 98 | // Casing for meta fields like 'createdby' and 'updatedby', defaults to 'lower', options are 'lower' | 'camel' | 'snake' | 'pascal' | 'kebab' 99 | metaFieldCasing: 'lower', 100 | // Instead of saving full download url for file, save just relative path and then get download url 101 | // when getting docs - main use case is handling multiple firebase projects (environments) 102 | // and moving/copying documents/storage files between them - with relativeFilePaths, download url 103 | // always point to project own storage 104 | relativeFilePaths: false, 105 | // Add file name to storage path, when set to true the file name is included in the path 106 | useFileNamesInStorage: false, 107 | // Use firebase sdk queries for pagination, filtering and sorting 108 | lazyLoading: { 109 | enabled: false 110 | }, 111 | // Logging of all reads performed by app (additional feature, for lazy-loading testing) 112 | firestoreCostsLogger: { 113 | enabled: false, 114 | localStoragePrefix // optional 115 | }, 116 | // Function to transform documentData before they are written to Firestore 117 | transformToDb: (resourceName, documentData, documentId) => documentDataTransformed 118 | } 119 | 120 | const dataProvider = FirebaseDataProvider(config, options); 121 | const authProvider = FirebaseAuthProvider(config, options); 122 | const firebaseRealtime = FirebaseRealTimeSaga(dataProvider, options); 123 | ``` 124 | 125 | ## Data Provider 126 | 127 | ``` javascript 128 | import * as React from 'react'; 129 | import { Admin, Resource } from 'react-admin'; 130 | 131 | import { PostList, PostShow, PostCreate, PostEdit } from "./posts"; 132 | import { 133 | FirebaseAuthProvider, 134 | FirebaseDataProvider, 135 | FirebaseRealTimeSaga 136 | } from 'react-admin-firebase'; 137 | 138 | const config = { 139 | apiKey: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 140 | authDomain: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 141 | databaseURL: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 142 | projectId: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 143 | storageBucket: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 144 | messagingSenderId: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 145 | }; 146 | 147 | const options = {}; 148 | 149 | const dataProvider = FirebaseDataProvider(config, options); 150 | ... 151 | 154 | 155 | 156 | ... 157 | ``` 158 | ## Auth Provider 159 | Using the `FirebaseAuthProvider` you can allow authentication in the application. 160 | 161 | ``` javascript 162 | const dataProvider = FirebaseDataProvider(config); 163 | const authProvider = FirebaseAuthProvider(config); 164 | ... 165 | 169 | ... 170 | ``` 171 | 172 | Also checkout how to login with: Google, Facebook, Github etc... [(Example Here)](https://github.com/benwinding/react-admin-firebase/blob/master/src-demo/src/CustomLoginPage.js) 173 | 174 | And you might want a "Forgot password" button... [(Example Here)](https://github.com/benwinding/react-admin-firebase/blob/master/src-demo/src/CustomForgotPassword.js) 175 | 176 | #### Note 177 | To get the currently logged in user run `const user = await authProvider.checkAuth()`, this will return the firebase user object, or null if there is no currently logged in user. 178 | 179 | ## Realtime Updates! 180 | 181 | NOTE: Realtime updates were removed in `react-admin` v3.x (see https://github.com/marmelab/react-admin/pull/3908). As such, `react-admin-firebase` v3.x also does not support Realtime Updates. However, much of the original code used for this functionality in `react-admin` v2.x remains - including the documentation below. For updates on the implementation of realtime please follow these issues: 182 | - https://github.com/benwinding/react-admin-firebase/issues/49 183 | - https://github.com/benwinding/react-admin-firebase/issues/57 184 | 185 | Get realtime updates from the firebase server instantly on your tables, with minimal overheads, using rxjs observables! 186 | 187 | ``` javascript 188 | ... 189 | import { 190 | FirebaseRealTimeSaga, 191 | FirebaseDataProvider 192 | } from 'react-admin-firebase'; 193 | ... 194 | const dataProvider = FirebaseDataProvider(config); 195 | const firebaseRealtime = FirebaseRealTimeSaga(dataProvider); 196 | ... 197 | 201 | ... 202 | ``` 203 | 204 | ### Realtime Options 205 | Trigger realtime on only some routes using the options object. 206 | 207 | ``` javascript 208 | ... 209 | const dataProvider = FirebaseDataProvider(config); 210 | const options = { 211 | watch: ['posts', 'comments'], 212 | dontwatch: ['users'] 213 | } 214 | const firebaseRealtime = FirebaseRealTimeSaga(dataProvider, options); 215 | ... 216 | ``` 217 | 218 | ## Upload Progress 219 | 220 | Monitor file upload data using custom React component which listen for following events (`CustomEvent`): 221 | 222 | - `FILE_UPLOAD_WILL_START` 223 | - `FILE_UPLOAD_START` 224 | - `FILE_UPLOAD_PROGRESS` 225 | - `FILE_UPLOAD_PAUSED` 226 | - `FILE_UPLOAD_CANCELD` 227 | - `FILE_UPLOAD_COMPLETE` 228 | - `FILE_SAVED` 229 | 230 | All events have data passed in `details` key: 231 | 232 | - `fileName`: the file anme 233 | - `data`: percentage for `FILE_UPLOAD_PROGRESS` 234 | 235 | Events are sent to HTML DOM element with id "eventMonitor". See demo implementation for example at [src-demo/src/App.js](src-demo/src/App.js); 236 | 237 | # Help Develop `react-admin-firebase`? 238 | 239 | 1. `git clone https://github.com/benwinding/react-admin-firebase` 240 | 2. `yarn` 241 | 3. `yarn start-demo` 242 | 243 | Now all local changes in the library source code can be tested immediately in the demo app. 244 | 245 | ### Run tests 246 | To run the tests, either watch for changes or just run all tests. 247 | 248 | - `yarn test-watch` 249 | - `yarn test` 250 | 251 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=ben.winding%40gmail.com&item_name=Development¤cy_code=AUD&source=url) 252 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage": { 3 | "rules": "storage.rules" 4 | }, 5 | "emulators": { 6 | "firestore": { 7 | "port": 8080 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const { series, watch } = require('gulp'); 2 | const gulp = require("gulp"); 3 | const exec = require("child_process").exec; 4 | 5 | const execCmd = (cmd, directory) => { 6 | console.log(`running $ "${cmd}" in dir: [${directory}]`); 7 | const child = exec(cmd, { cwd: directory }); 8 | 9 | child.stdout.on("data", function(data) { 10 | console.log(data); 11 | }); 12 | child.stderr.on("data", function(data) { 13 | console.error(data); 14 | }); 15 | return new Promise((resolve, reject) => { 16 | child.on("close", resolve); 17 | }); 18 | }; 19 | 20 | const conf = { 21 | watchSrc: "./src/**/*", 22 | copyFrom: ["./src/**/*", "./package.json", "./dist/**/*"], 23 | copyTo: "./src-demo/node_modules/react-admin-firebase", 24 | output: { 25 | dir: `./dist/**/*` 26 | }, 27 | demo: { 28 | root: "./src-demo" 29 | } 30 | }; 31 | 32 | async function prepareDemo(cb) { 33 | await execCmd("yarn", './src-demo') 34 | cb(); 35 | } 36 | 37 | function copyToDemo() { 38 | return gulp.src(conf.copyFrom, { base: "." }).pipe(gulp.dest(conf.copyTo)) 39 | } 40 | 41 | function watchAndCopy(cb) { 42 | // body omitted 43 | execCmd("yarn watch", '.'); 44 | watch(conf.output.dir, copyToDemo); 45 | execCmd("sleep 10 && yarn start", 'src-demo'); 46 | } 47 | 48 | exports['start-demo'] = series(prepareDemo, watchAndCopy); -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: '.', 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest", 5 | }, 6 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 7 | testPathIgnorePatterns: ["/dist/", "/node_modules/", "/src-demo/"], 8 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 9 | collectCoverage: false, 10 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-admin-firebase", 3 | "description": "A firebase data provider for the React Admin framework", 4 | "version": "5.0.0", 5 | "peerDependencies": { 6 | "firebase": "^9.6.4", 7 | "react": "17.x || 18.x", 8 | "react-admin": "4.x || 5.x", 9 | "react-dom": "17.x || 18.x", 10 | "react-router-dom": "5.x || 6.x" 11 | }, 12 | "dependencies": { 13 | "lodash": "4.x", 14 | "path-browserify": "^1.0.0", 15 | "rxjs": "^6.5.x" 16 | }, 17 | "devDependencies": { 18 | "@firebase/rules-unit-testing": "^2.0.2", 19 | "@types/jest": "^24.0.13", 20 | "@types/lodash": "^4.14.150", 21 | "@types/node": "^10.9.4", 22 | "@types/react": "^18.0.9", 23 | "@types/react-router-dom": "^5.3.3", 24 | "@types/rx": "^4.1.1", 25 | "firebase": "^9.15.0", 26 | "firebase-tools": "11.x", 27 | "gulp": "^4.0.2", 28 | "jest": "^23.6.0", 29 | "microbundle": "^0.15.0", 30 | "prettier": "^2.8.3", 31 | "prettier-plugin-organize-imports": "^3.2.2", 32 | "ra-core": "3.10.0", 33 | "ts-jest": "^25", 34 | "tslint": "^5.16.0", 35 | "tslint-config-prettier": "^1.18.0", 36 | "typescript": "4.5.5" 37 | }, 38 | "homepage": "https://github.com/benwinding/react-admin-firebase", 39 | "email": "hello@benwinding.com", 40 | "license": "MIT", 41 | "scripts": { 42 | "build": "rm -rf dist && microbundle", 43 | "tsc-test": "tsc -p ./tsconfig.spec.json", 44 | "watch": "microbundle watch", 45 | "start-demo": "gulp start-demo", 46 | "start-emulator": "yarn firebase emulators:start --only firestore", 47 | "test": "yarn firebase emulators:exec \"yarn jest --forceExit --detectOpenHandles\"", 48 | "test-watch": "yarn firebase emulators:exec \"yarn jest --watchAll --detectOpenHandles\"", 49 | "tslint": "tslint -c tslint.json 'src/**/*.ts' 'tests/**/*.ts'", 50 | "prettify": "prettier --write src tests", 51 | "lint": "yarn prettify && yarn tslint" 52 | }, 53 | "files": [ 54 | "dist", 55 | "src", 56 | "LICENSE", 57 | "README.md" 58 | ], 59 | "main": "dist/index.js", 60 | "types": "dist/index.d.ts", 61 | "umd:main": "dist/index.umd.js", 62 | "source": "src/index.ts", 63 | "engines": { 64 | "node": ">=10" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src-demo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | yarn* 4 | package-* 5 | FIREBASE_CONFIG.json 6 | -------------------------------------------------------------------------------- /src-demo/FIREBASE_CONFIG.json.EXAMPLE: -------------------------------------------------------------------------------- 1 | { 2 | "apiKey": "00000000000000000", 3 | "authDomain": "00000000000000000", 4 | "databaseURL": "00000000000000000", 5 | "projectId": "00000000000000000", 6 | "storageBucket": "00000000000000000", 7 | "messagingSenderId": "00000000000000000", 8 | "appId": "00000000000000000" 9 | } -------------------------------------------------------------------------------- /src-demo/README.md: -------------------------------------------------------------------------------- 1 | An example project for the [react-admin-firebase](https://github.com/benwinding/react-admin-firebase) package. 2 | 3 | # Demo 4 | Try the [demo here!](https://benwinding.github.io/react-admin-firebase) 5 | 6 | ``` 7 | username: test@example.com 8 | password: test@example.com 9 | ``` 10 | 11 | # Get started 12 | You need to add the private Firebase connection file: `src/FIREBASE_CONFIG.js` with the following format from firebase: 13 | 14 | ``` js 15 | export const firebaseConfig = { 16 | apiKey: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 17 | authDomain: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 18 | databaseURL: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 19 | projectId: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 20 | storageBucket: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 21 | messagingSenderId: "aaaaaaaaaaaaaaaaaaaaaaaaaaa", 22 | }; 23 | ``` 24 | 25 | Don't forget to add the `export` infront of the configuration that Firebase gives you! 26 | 27 | Then just run `npm run start` 28 | 29 | ### Additional Dev Notes 30 | The following 2 files are to patch the `react-scripts` package. These are copied after install (postinstall), and allow better sourcemapping to the src files in the parent folder. 31 | 32 | - `src-demo/webpack-devserver-override.js` 33 | - `src-demo/webpack-override.js` -------------------------------------------------------------------------------- /src-demo/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-simple-react-admin", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "4.12.4", 7 | "@material-ui/icons": "4.11.3", 8 | "firebase": "^9.6.4", 9 | "react": "^18.x", 10 | "react-admin": "5", 11 | "react-admin-firebase": "5.0.0", 12 | "react-dom": "^18.x", 13 | "react-firebaseui": "^6.0.0", 14 | "react-router-dom": "^6.x", 15 | "react-scripts": "^5.0.0", 16 | "source-map-loader": "^3" 17 | }, 18 | "homepage": "https://benwinding.github.io/react-admin-firebase/", 19 | "devDependencies": { 20 | "concurrently": "4.1.1", 21 | "cpx": "1.5.0", 22 | "gh-pages": "3.2.3" 23 | }, 24 | "scripts": { 25 | "start": "REACT_APP_FIREBASE_CONFIG=`cat ./FIREBASE_CONFIG.json` NODE_ENV=development BROWSER=none PORT=3333 react-scripts start", 26 | "build": "REACT_APP_FIREBASE_CONFIG=`cat ./FIREBASE_CONFIG.json` react-scripts build", 27 | "build-ci": "react-scripts build", 28 | "cp-dist": "rm -rf ./node_modules/react-admin-firebase/dist && cp -r ../dist ./node_modules/react-admin-firebase/dist", 29 | "test": "react-scripts test --env=jsdom", 30 | "eject": "react-scripts eject", 31 | "deploy": "gh-pages -d build" 32 | }, 33 | "browserslist": [ 34 | ">0.2%", 35 | "not dead", 36 | "not ie <= 11", 37 | "not op_mini all" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src-demo/public/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benwinding/react-admin-firebase/526b13299d1a714e477db83f58e1f42b8935b399/src-demo/public/.nojekyll -------------------------------------------------------------------------------- /src-demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benwinding/react-admin-firebase/526b13299d1a714e477db83f58e1f42b8935b399/src-demo/public/favicon.ico -------------------------------------------------------------------------------- /src-demo/public/global.css: -------------------------------------------------------------------------------- 1 | button.firebaseui-idp-button, button.firebaseui-tenant-button { 2 | max-width: unset; 3 | padding-left: 53px; 4 | } 5 | 6 | div.firebaseui-card-content, div.firebaseui-card-footer { 7 | padding: 0 10px; 8 | } 9 | -------------------------------------------------------------------------------- /src-demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /src-demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src-demo/src/App.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { PostList, PostShow, PostCreate, PostEdit } from './posts'; 3 | import { UserList, UserShow, UserCreate, UserEdit } from './users'; 4 | import { Admin, Resource } from 'react-admin'; 5 | import { 6 | FirebaseDataProvider, 7 | FirebaseAuthProvider, 8 | } from 'react-admin-firebase'; 9 | 10 | import firebase from "firebase/compat/app"; 11 | 12 | import UserIcon from '@material-ui/icons/People'; 13 | import CommentIcon from '@material-ui/icons/Comment'; 14 | 15 | import * as Posts from "./posts"; 16 | import * as Users from "./users"; 17 | import * as Comments from "./comments"; 18 | 19 | import CustomLoginPage from './CustomLoginPage'; 20 | import EventMonitor from './EventMonitor'; 21 | 22 | let firebaseConfig; 23 | try { 24 | firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG); 25 | } catch (error) { 26 | console.error('Error parsing (maybe quotes aren\'t escaped?): ', {REACT_APP_FIREBASE_CONFIG: process.env.REACT_APP_FIREBASE_CONFIG}, error); 27 | } 28 | 29 | const firebaseApp = firebase.initializeApp(firebaseConfig); 30 | export const storage = firebase.storage(firebaseApp) 31 | console.log({firebaseConfig, firebaseApp}); 32 | 33 | const authProvider = FirebaseAuthProvider(firebaseConfig); 34 | const dataProvider = FirebaseDataProvider(firebaseConfig, { 35 | logging: true, 36 | // rootRef: 'rootrefcollection/QQG2McwjR2Bohi9OwQzP', 37 | app: firebaseApp, 38 | // watch: ['posts']; 39 | // dontwatch: ['comments']; 40 | persistence: 'local', 41 | // disableMeta: true 42 | dontAddIdFieldToDoc: true, 43 | lazyLoading: { 44 | enabled: true, 45 | }, 46 | firestoreCostsLogger: { 47 | enabled: true, 48 | }, 49 | }); 50 | 51 | class App extends React.Component { 52 | render() { 53 | return ( 54 | <> 55 | 60 | 67 | 75 | 76 | 77 | 78 | ); 79 | } 80 | } 81 | 82 | export default App; 83 | -------------------------------------------------------------------------------- /src-demo/src/CustomDeleteButtons.js: -------------------------------------------------------------------------------- 1 | import {storage} from './App' 2 | 3 | import { 4 | useRecordContext, 5 | DeleteButton, 6 | BulkDeleteButton, 7 | useListContext 8 | } from 'react-admin' 9 | 10 | 11 | // IMPORTANT COMMENT 12 | // You can't delete the folder because is deletes itself 13 | // when you remove all the elements inside of it 14 | function deleteFileFirestore(fileURL) { 15 | let fileRef = storage.refFromURL(fileURL) 16 | 17 | // Delete the file using the delete() method 18 | fileRef.delete().then(function () { 19 | // File deleted successfully 20 | console.log("File Deleted") 21 | }).catch(function (error) { 22 | // Some Error occurred 23 | console.log("An error occured when deleting a file.") 24 | }) 25 | } 26 | 27 | const getValueOfAnyWantedKey = (obj, wantedKey, array) => { 28 | 29 | if (Array.isArray(obj)) { 30 | for (let i = 0; i < obj.length; i++) { 31 | getValueOfAnyWantedKey(obj[i], wantedKey, array) 32 | } 33 | } 34 | else if (typeof obj === "object") { 35 | for (const key in obj) { 36 | if (key === wantedKey) { 37 | array.push(obj[key]) 38 | delete obj[key] 39 | } 40 | getValueOfAnyWantedKey(obj[key], wantedKey, array) 41 | } 42 | } 43 | 44 | return array 45 | } 46 | 47 | export const CustomBulkDeleteButton = (props) => { 48 | const listContext = useListContext(); 49 | 50 | const filterSelectedIds = (arr1, arr2) => { 51 | let res = []; 52 | res = arr1.filter(el => { 53 | return arr2.find(element => { 54 | return element === el.id; 55 | }); 56 | }); 57 | return res; 58 | } 59 | 60 | const handleDelete = () => { 61 | const select = listContext.selectedIds 62 | 63 | const result = filterSelectedIds(listContext.data, select) 64 | 65 | result.forEach(function (el) { 66 | let sources = [] 67 | getValueOfAnyWantedKey(el, 'src', sources) 68 | // delete files in storage 69 | for (const src of sources){ 70 | deleteFileFirestore(src) 71 | } 72 | }) 73 | } 74 | return ( 75 | 76 | ) 77 | } 78 | 79 | 80 | export const CustomDeleteButton = (props) => { 81 | const record = useRecordContext(); 82 | 83 | const handleClick = () => { 84 | const sources = [] 85 | getValueOfAnyWantedKey(record, 'src', sources) 86 | // delete files in storage 87 | for (const src of sources){ 88 | deleteFileFirestore(src) 89 | } 90 | // then delete in db but if you use DeleteButton there is no use for useDelete 91 | } 92 | 93 | return ; 94 | } 95 | 96 | export default CustomDeleteButton; -------------------------------------------------------------------------------- /src-demo/src/CustomForgotPassword.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogContentText, 8 | DialogTitle, 9 | Snackbar, 10 | TextField, 11 | } from "@material-ui/core"; 12 | 13 | import firebase from "firebase/compat/app"; 14 | import "firebase/compat/auth"; 15 | 16 | export default function AlertDialog() { 17 | const [open, setOpen] = React.useState(false); 18 | const [email, setEmail] = React.useState(""); 19 | 20 | const [toastOpen, setToastOpen] = React.useState(false); 21 | const [toastMessage, setToastMessage] = React.useState(""); 22 | 23 | const handleClickOpen = () => { 24 | setOpen(true); 25 | }; 26 | 27 | const handleClose = () => { 28 | setOpen(false); 29 | }; 30 | const handleSubmit = async () => { 31 | console.log("sending email to: ", email); 32 | try { 33 | await firebase.auth().sendPasswordResetEmail(email); 34 | setOpen(false); 35 | setToastOpen(true); 36 | setToastMessage("Password reset email sent!"); 37 | } catch (error) { 38 | setToastOpen(true); 39 | setToastMessage(error.message); 40 | } 41 | }; 42 | 43 | const handleOnChange = (event) => { 44 | const email = event.target.value; 45 | setEmail(email); 46 | }; 47 | 48 | const handleToastClose = () => { 49 | setToastOpen(false); 50 | setToastOpen(false); 51 | }; 52 | 53 | return ( 54 |
55 | 58 | 64 | Send Password Reset? 65 | 66 | 67 | A password reset will be sent to the following email: 68 | 69 | 77 | 78 | 79 | 80 | 83 | 84 | 85 | 95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src-demo/src/CustomLoginPage.js: -------------------------------------------------------------------------------- 1 | // LoginPage.js 2 | import React from "react"; 3 | import { Login, LoginForm } from "react-admin"; 4 | import StyledFirebaseAuth from 'react-firebaseui/StyledFirebaseAuth'; 5 | import firebase from "firebase/compat/app"; 6 | import ForgotPasswordButton from './CustomForgotPassword' 7 | 8 | // Configure FirebaseUI. 9 | const uiConfig = { 10 | // Popup signin flow rather than redirect flow. 11 | signInFlow: 'popup', 12 | // Redirect to /signedIn after sign in is successful. Alternatively you can provide a callbacks.signInSuccess function. 13 | signInSuccessUrl: '#/', 14 | // We will display Google and Facebook as auth providers. 15 | signInOptions: [ 16 | firebase.auth.GoogleAuthProvider.PROVIDER_ID, 17 | firebase.auth.FacebookAuthProvider.PROVIDER_ID 18 | ], 19 | // Optional callbacks in order to get Access Token from Google,Facebook,... etc 20 | callbacks: { 21 | signInSuccessWithAuthResult: (result) => { 22 | const credential = result.credential; 23 | // The signed-in user info. 24 | const user = result.user; 25 | // This gives you a Facebook Access Token. You can use it to access the Facebook API. 26 | const accessToken = credential.accessToken; 27 | console.log({result, user, accessToken}); 28 | } 29 | } 30 | }; 31 | 32 | const SignInScreen = () => ; 36 | 37 | const CustomLoginForm = props => ( 38 |
39 |
40 |

Username: test@example.com

41 |

Password: password

42 |
43 | 44 | 45 | 46 |
47 | ); 48 | 49 | const CustomLoginPage = props => ( 50 | 51 | 52 | 53 | ); 54 | 55 | export default CustomLoginPage; 56 | -------------------------------------------------------------------------------- /src-demo/src/EventMonitor.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | 3 | const EventMonitor = () => { 4 | 5 | const eventMonitorRef = useRef(); 6 | 7 | // subscribe for file upload events on mount 8 | useEffect(() => { 9 | 10 | const uploadWillStartEventHandler = ({ detail: { fileName } }) => { 11 | console.log(`upload '${fileName}' will start`); 12 | // use a react "toast" module (such as react-toastify) to display notifications 13 | }; 14 | 15 | const uploadRunningEventHandler = ({ detail: { fileName } }) => { 16 | console.log(`upload '${fileName}' running`); 17 | // use a react "toast" module (such as react-toastify) to display notifications 18 | }; 19 | 20 | const uploadProgressEventHandler = ({ detail: { fileName, data } }) => { 21 | console.log(`upload '${fileName}' progress ${data}%`); 22 | // use a react "toast" module (such as react-toastify) to display notifications 23 | }; 24 | 25 | const uploadCompleteEventHandler = ({ detail: { fileName } }) => { 26 | console.log(`upload '${fileName}' complete`); 27 | // use a react "toast" module (such as react-toastify) to display notifications 28 | }; 29 | 30 | const fileReadyEventHandler = ({ detail: { fileName } }) => { 31 | console.log(`file '${fileName}' ready`); 32 | // use a react "toast" module (such as react-toastify) to display notifications 33 | }; 34 | 35 | const eventMonitor = eventMonitorRef.current; 36 | if (!eventMonitor) return; // never too cautious 37 | 38 | // @ts-ignore 39 | eventMonitor.addEventListener('FILE_UPLOAD_WILL_START', uploadWillStartEventHandler); 40 | // @ts-ignore 41 | eventMonitor.addEventListener('FILE_UPLOAD_RUNNING', uploadRunningEventHandler); 42 | // @ts-ignore 43 | eventMonitor.addEventListener('FILE_UPLOAD_PROGRESS', uploadProgressEventHandler); 44 | // @ts-ignore 45 | // eventMonitor.addEventListener('FILE_UPLOAD_PAUSED', ___); 46 | // @ts-ignore 47 | // eventMonitor.addEventListener('FILE_UPLOAD_CANCELD', ___); 48 | // @ts-ignore 49 | eventMonitor.addEventListener('FILE_UPLOAD_COMPLETE', uploadCompleteEventHandler); 50 | // @ts-ignore 51 | eventMonitor.addEventListener('FILE_SAVED', fileReadyEventHandler); 52 | 53 | // unsubscribe on unmount 54 | return () => { 55 | if (!eventMonitor) return; // never too cautious 56 | 57 | // @ts-ignore 58 | eventMonitor.removeEventListener('FILE_UPLOAD_WILL_START', uploadWillStartEventHandler); 59 | // @ts-ignore 60 | eventMonitor.removeEventListener('FILE_UPLOAD_RUNNING', uploadRunningEventHandler); 61 | // @ts-ignore 62 | eventMonitor.removeEventListener('FILE_UPLOAD_PROGRESS', uploadProgressEventHandler); 63 | // @ts-ignore 64 | // eventMonitor.removeEventListener('FILE_UPLOAD_PAUSED', ___); 65 | // @ts-ignore 66 | // eventMonitor.removeEventListener('FILE_UPLOAD_CANCELD', ___); 67 | // @ts-ignore 68 | eventMonitor.removeEventListener('FILE_UPLOAD_COMPLETE', uploadCompleteEventHandler); 69 | // @ts-ignore 70 | eventMonitor.removeEventListener('FILE_SAVED', fileReadyEventHandler); 71 | }; 72 | }, []); 73 | 74 | return
; 75 | }; 76 | 77 | export default EventMonitor; 78 | -------------------------------------------------------------------------------- /src-demo/src/FirebaseReferenceFields.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ReferenceField, ReferenceInput } from "react-admin"; 3 | 4 | export const FirebaseReferenceField = (props) => { 5 | const { 6 | source, 7 | children, 8 | ...rest 9 | } = props; 10 | return ( 11 | 15 | {children} 16 | 17 | ); 18 | }; 19 | FirebaseReferenceField.defaultProps = { addLabel: true }; 20 | 21 | export const FirebaseReferenceInput = (props) => { 22 | const { 23 | source, 24 | children, 25 | ...rest 26 | } = props; 27 | return ( 28 | 32 | {children} 33 | 34 | ); 35 | }; -------------------------------------------------------------------------------- /src-demo/src/comments.js: -------------------------------------------------------------------------------- 1 | // in src/posts.js 2 | import * as React from "react"; 3 | // tslint:disable-next-line:no-var-requires 4 | import { 5 | Datagrid, 6 | List, 7 | Show, 8 | Create, 9 | Edit, 10 | SimpleShowLayout, 11 | SimpleForm, 12 | TextField, 13 | TextInput, 14 | ShowButton, 15 | EditButton, 16 | RichTextField, 17 | ReferenceField, 18 | SelectInput, 19 | ReferenceInput, 20 | } from "react-admin"; 21 | import {CustomDeleteButton} from './CustomDeleteButtons' 22 | 23 | export const CommentsList = (props) => ( 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | 42 | export const CommentShow = (props) => ( 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | 56 | export const CommentCreate = (props) => ( 57 | 58 | 59 | 60 | 61 | 67 | 68 | 69 | 70 | 71 | ); 72 | 73 | export const CommentEdit = (props) => ( 74 | 75 | 76 | 77 | 78 | 84 | 85 | 86 | 87 | 88 | ); 89 | -------------------------------------------------------------------------------- /src-demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import registerServiceWorker from './registerServiceWorker'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | registerServiceWorker(); 8 | -------------------------------------------------------------------------------- /src-demo/src/posts.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | Datagrid, 4 | List, 5 | Show, 6 | Create, 7 | Edit, 8 | DateField, 9 | ImageField, 10 | ImageInput, 11 | SimpleShowLayout, 12 | SimpleForm, 13 | TextField, 14 | TextInput, 15 | ShowButton, 16 | EditButton, 17 | RichTextField, 18 | ReferenceField, 19 | SelectInput, 20 | ReferenceInput, 21 | FileInput, 22 | FileField, 23 | ArrayInput, 24 | SimpleFormIterator, 25 | DateInput, 26 | Toolbar, 27 | SaveButton, 28 | } from "react-admin"; 29 | import { 30 | CustomDeleteButton, 31 | CustomBulkDeleteButton, 32 | } from './CustomDeleteButtons' 33 | import { FirebaseReferenceField, FirebaseReferenceInput } from './FirebaseReferenceFields'; 34 | 35 | // const PostFilter = (props) => ( 36 | // 37 | // 38 | // 39 | // ); 40 | 41 | // const ReferenceFilter = (props) => ( 42 | // 43 | // 49 | // 50 | // 51 | // 52 | // ); 53 | 54 | export const PostList = (props) => ( 55 | } 58 | // filters={} 59 | // filter={{ updatedby: "test@example.com" }} 60 | > 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | {/* */} 75 | 76 | 77 | ); 78 | 79 | // const ConditionalEmailField = ({}) => 80 | // record && record.hasEmail ? ( 81 | // 82 | // ) : null; 83 | export const PostShow = (props) => ( 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | {/* Or use the easier */} 100 | 105 | 106 | 107 | 108 | 113 | 114 | 115 | ); 116 | 117 | export const PostCreate = (props) => ( 118 | 119 | 120 | 121 | 122 | 123 | new Date(val)} /> 124 | 130 | 131 | 132 | 137 | 138 | 139 | {/* Or use the easier */} 140 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | ); 168 | 169 | const ToolbarForEdit = (props) => { 170 | return( 171 | 172 | 173 | 174 | 175 | ) 176 | } 177 | 178 | export const PostEdit = (props) => ( 179 | 180 | }> 181 | 182 | 183 | 184 | 185 | 186 | 192 | 193 | 194 | 199 | 200 | 201 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | ); 229 | -------------------------------------------------------------------------------- /src-demo/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src-demo/src/users.js: -------------------------------------------------------------------------------- 1 | // in src/User.js 2 | import * as React from "react"; 3 | // tslint:disable-next-line:no-var-requires 4 | import { 5 | Datagrid, 6 | List, 7 | Show, 8 | Create, 9 | Edit, 10 | Filter, 11 | SimpleShowLayout, 12 | SimpleForm, 13 | TextField, 14 | TextInput, 15 | ShowButton, 16 | EditButton, 17 | } from "react-admin"; 18 | import {CustomDeleteButton, } from './CustomDeleteButtons' 19 | 20 | const UserFilter = (props) => ( 21 | 22 | 23 | 24 | ); 25 | 26 | export const UserList = (props) => ( 27 | }> 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | 40 | export const UserShow = (props) => ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | 50 | export const UserCreate = (props) => ( 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | 60 | export const UserEdit = (props) => ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthProvider } from './providers/AuthProvider'; 2 | import { DataProvider } from './providers/DataProvider'; 3 | import { RAFirebaseOptions } from './providers/options'; 4 | 5 | export { 6 | DataProvider as FirebaseDataProvider, 7 | AuthProvider as FirebaseAuthProvider, 8 | RAFirebaseOptions as RAFirebaseOptions, 9 | }; 10 | -------------------------------------------------------------------------------- /src/misc/arrayHelpers.ts: -------------------------------------------------------------------------------- 1 | import { get, isEmpty } from 'lodash'; 2 | import { getFieldReferences, SearchObj } from './objectFlatten'; 3 | 4 | export function sortArray( 5 | data: Array<{}>, 6 | field: string, 7 | dir: 'asc' | 'desc' 8 | ): void { 9 | data.sort((a: {}, b: {}) => { 10 | const rawA = get(a, field); 11 | const rawB = get(b, field); 12 | const isAsc = dir === 'asc'; 13 | 14 | const isNumberField = Number.isFinite(rawA) && Number.isFinite(rawB); 15 | if (isNumberField) { 16 | return basicSort(rawA, rawB, isAsc); 17 | } 18 | const isStringField = typeof rawA === 'string' && typeof rawB === 'string'; 19 | if (isStringField) { 20 | const aParsed = rawA.toLowerCase(); 21 | const bParsed = rawB.toLowerCase(); 22 | return basicSort(aParsed, bParsed, isAsc); 23 | } 24 | const isDateField = rawA instanceof Date && rawB instanceof Date; 25 | if (isDateField) { 26 | return basicSort(rawA, rawB, isAsc); 27 | } 28 | return basicSort(!!rawA, !!rawB, isAsc); 29 | }); 30 | } 31 | 32 | function basicSort(aValue: any, bValue: any, isAsc: boolean) { 33 | if (aValue > bValue) { 34 | return isAsc ? 1 : -1; 35 | } 36 | if (aValue < bValue) { 37 | return isAsc ? -1 : 1; 38 | } 39 | return 0; 40 | } 41 | 42 | export function filterArray( 43 | data: Array<{}>, 44 | searchFields?: { [field: string]: string | number | boolean | null } 45 | ): Array<{}> { 46 | if (!searchFields || isEmpty(searchFields)) { 47 | return data; 48 | } 49 | const searchObjs: SearchObj[] = []; 50 | Object.keys(searchFields).map((fieldName) => { 51 | const fieldValue = searchFields[fieldName]; 52 | const getSubObjects = getFieldReferences(fieldName, fieldValue); 53 | searchObjs.push(...getSubObjects); 54 | }); 55 | const filtered = data.filter((row) => 56 | searchObjs.reduce((acc, cur) => { 57 | const res = doesRowMatch(row, cur.searchField, cur.searchValue); 58 | return res && acc; 59 | }, true as boolean) 60 | ); 61 | return filtered; 62 | } 63 | 64 | export function doesRowMatch( 65 | row: {}, 66 | searchField: string, 67 | searchValue: any 68 | ): boolean { 69 | const searchThis = get(row, searchField); 70 | const bothAreFalsey = !searchThis && !searchValue; 71 | if (bothAreFalsey) { 72 | return true; 73 | } 74 | const nothingToSearch = !searchThis; 75 | if (nothingToSearch) { 76 | return false; 77 | } 78 | const isStringSearch = typeof searchValue === 'string'; 79 | if (isStringSearch) { 80 | return searchThis 81 | .toString() 82 | .toLowerCase() 83 | .includes(searchValue.toLowerCase()); 84 | } 85 | const isBooleanOrNumber = 86 | typeof searchValue === 'boolean' || typeof searchValue === 'number'; 87 | if (isBooleanOrNumber) { 88 | return searchThis === searchValue; 89 | } 90 | const isArraySearch = Array.isArray(searchValue); 91 | if (isArraySearch) { 92 | return searchValue.includes(searchThis); 93 | } 94 | return false; 95 | } 96 | -------------------------------------------------------------------------------- /src/misc/dispatcher.ts: -------------------------------------------------------------------------------- 1 | import { log } from './logger'; 2 | 3 | export type DispatchEvent = 4 | | 'FILE_UPLOAD_WILL_START' 5 | | 'FILE_UPLOAD_PROGRESS' 6 | | 'FILE_UPLOAD_PAUSED' 7 | | 'FILE_UPLOAD_RUNNING' 8 | | 'FILE_UPLOAD_CANCELED' 9 | | 'FILE_UPLOAD_COMPLETE' 10 | | 'FILE_SAVED'; 11 | 12 | export function dispatch( 13 | eventName: DispatchEvent, 14 | fileName: string, 15 | data?: any 16 | ): void { 17 | const eventMonitor = document.getElementById('eventMonitor'); 18 | if (!eventMonitor) { 19 | log( 20 | `eventMonitor not found to dispatch event ${eventName} for ${fileName}` 21 | ); 22 | return; 23 | } 24 | const eventData = { fileName, data }; 25 | let event = new CustomEvent(eventName, { detail: eventData }); 26 | eventMonitor.dispatchEvent(event); 27 | } 28 | -------------------------------------------------------------------------------- /src/misc/document-parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FireStoreDocumentSnapshot, 3 | FireStoreQueryDocumentSnapshot, 4 | } from './firebase-models'; 5 | import { logWarn } from './logger'; 6 | import * as ra from './react-admin-models'; 7 | import { 8 | applyRefDocs, 9 | translateDocFromFirestore, 10 | } from './translate-from-firestore'; 11 | 12 | export function parseFireStoreDocument( 13 | doc: FireStoreQueryDocumentSnapshot | FireStoreDocumentSnapshot | undefined 14 | ): T { 15 | if (!doc) { 16 | logWarn('parseFireStoreDocument: no doc', { doc }); 17 | return {} as T; 18 | } 19 | const data = doc.data(); 20 | const result = translateDocFromFirestore(data); 21 | const dataWithRefs = applyRefDocs(result.parsedDoc, result.refdocs); 22 | // React Admin requires an id field on every document, 23 | // So we can just use the firestore document id 24 | return { id: doc.id, ...dataWithRefs } as T; 25 | } 26 | -------------------------------------------------------------------------------- /src/misc/firebase-models.ts: -------------------------------------------------------------------------------- 1 | import { FirebaseApp } from 'firebase/app'; 2 | import { Auth, User, UserCredential } from 'firebase/auth'; 3 | import { 4 | CollectionReference, 5 | DocumentData, 6 | DocumentReference, 7 | DocumentSnapshot, 8 | FieldValue, 9 | Firestore, 10 | OrderByDirection, 11 | Query, 12 | QueryDocumentSnapshot, 13 | WriteBatch, 14 | } from 'firebase/firestore'; 15 | import { 16 | FirebaseStorage, 17 | StorageReference, 18 | TaskState, 19 | UploadTask, 20 | UploadTaskSnapshot, 21 | } from 'firebase/storage'; 22 | 23 | export type FireUser = User; 24 | export type FireApp = FirebaseApp; 25 | 26 | export type FireStorage = FirebaseStorage; 27 | export type FireStorageReference = StorageReference; 28 | export type FireUploadTaskSnapshot = UploadTaskSnapshot; 29 | export type FireUploadTask = UploadTask; 30 | export type FireStoragePutFileResult = { 31 | task: FireUploadTask; 32 | taskResult: Promise; 33 | downloadUrl: Promise; 34 | }; 35 | 36 | export type FireAuth = Auth; 37 | export type FireAuthUserCredentials = UserCredential; 38 | 39 | export type FireStore = Firestore; 40 | export type FireStoreBatch = WriteBatch; 41 | export type FireStoreTimeStamp = FieldValue; 42 | export type FireStoreDocumentRef = DocumentReference; 43 | export type FireStoreDocumentSnapshot = DocumentSnapshot; 44 | export type FireStoreCollectionRef = CollectionReference; 45 | export type FireStoreQueryDocumentSnapshot = QueryDocumentSnapshot; 46 | export type FireStoreQuery = Query; 47 | export type FireStoreQueryOrder = OrderByDirection; 48 | 49 | export const TASK_PAUSED = 'paused' as TaskState; 50 | export const TASK_RUNNING = 'running' as TaskState; 51 | export const TASK_CANCELED = 'cancelled' as TaskState; 52 | -------------------------------------------------------------------------------- /src/misc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './arrayHelpers'; 2 | export * from './dispatcher'; 3 | export * from './document-parser'; 4 | export * from './internal.models'; 5 | export * from './logger'; 6 | export * from './messageTypes'; 7 | export * from './metadata-parser'; 8 | export * from './objectFlatten'; 9 | export * from './pathHelper'; 10 | export * from './react-admin-models'; 11 | export * from './status-code-translator'; 12 | export * from './storage-parser'; 13 | export * from './translate-from-firestore'; 14 | export * from './translate-to-firestore'; 15 | -------------------------------------------------------------------------------- /src/misc/internal.models.ts: -------------------------------------------------------------------------------- 1 | export const REF_INDENTIFIER = '___REF_FULLPATH_'; 2 | 3 | export interface ParsedRefDoc { 4 | ___refpath: string; 5 | ___refid: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/misc/logger/firestore-logger.ts: -------------------------------------------------------------------------------- 1 | import { RAFirebaseOptions } from 'providers/options'; 2 | import { LoggerBase, LogNoOp } from './logger-base'; 3 | 4 | const LOGGER_ENABLEDKEY = 'LOGGING_FIRESTORE_COSTS_ENABLED'; 5 | const logger = new LoggerBase('💸firestore-costs:', LOGGER_ENABLEDKEY); 6 | 7 | const KEY_SINGLE = 'firecosts-single-reads'; 8 | 9 | export interface IFirestoreLogger { 10 | logDocument: (count: number) => Function; 11 | SetEnabled: (isEnabled: boolean) => void; 12 | ResetCount: (shouldReset: boolean) => void; 13 | } 14 | 15 | export function MakeFirestoreLogger( 16 | options: RAFirebaseOptions 17 | ): IFirestoreLogger { 18 | function notEnabled() { 19 | return !options?.lazyLoading?.enabled; 20 | } 21 | 22 | function incrementRead(incrementBy = 1) { 23 | const currentCountRaw = localStorage.getItem(KEY_SINGLE) || ''; 24 | const currentCount = parseInt(currentCountRaw) || 0; 25 | const incremented = currentCount + incrementBy; 26 | localStorage.setItem(KEY_SINGLE, incremented + ''); 27 | return incremented; 28 | } 29 | function clearCache() { 30 | localStorage.removeItem(KEY_SINGLE); 31 | } 32 | return { 33 | SetEnabled(isEnabled: boolean) { 34 | logger.SetEnabled(isEnabled); 35 | }, 36 | ResetCount(shouldReset: boolean) { 37 | shouldReset && clearCache(); 38 | }, 39 | logDocument(docCount: number) { 40 | if (notEnabled()) { 41 | return LogNoOp; 42 | } 43 | const count = incrementRead(docCount); 44 | const suffix = `+${docCount} (session total=${count} documents read)`; 45 | const boundLogFn: (...args: any) => void = logger.log.bind( 46 | console, 47 | suffix 48 | ); 49 | return boundLogFn; 50 | }, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/misc/logger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './firestore-logger'; 2 | export * from './logger'; 3 | export * from './logger-base'; 4 | -------------------------------------------------------------------------------- /src/misc/logger/logger-base.ts: -------------------------------------------------------------------------------- 1 | type LogFn = (...args: any) => void; 2 | 3 | export const LogNoOp: LogFn = (...args: any) => null; 4 | 5 | export class LoggerBase { 6 | constructor(private title: string, private cacheEnabledKey: string) {} 7 | 8 | private isEnabled() { 9 | return !!localStorage.getItem(this.cacheEnabledKey); 10 | } 11 | 12 | SetEnabled(isEnabled: boolean) { 13 | if (isEnabled) { 14 | localStorage.setItem(this.cacheEnabledKey, 'true'); 15 | } else { 16 | localStorage.removeItem(this.cacheEnabledKey); 17 | } 18 | } 19 | 20 | public get log() { 21 | if (!this.isEnabled()) { 22 | return LogNoOp; 23 | } 24 | const boundLogFn: (...args: any) => void = console.log.bind( 25 | console, 26 | this.title 27 | ); 28 | return boundLogFn; 29 | } 30 | 31 | public get warn() { 32 | if (!this.isEnabled()) { 33 | return LogNoOp; 34 | } 35 | const boundLogFn: (...args: any) => void = console.warn.bind( 36 | console, 37 | this.title 38 | ); 39 | return boundLogFn; 40 | } 41 | 42 | public get error() { 43 | if (!this.isEnabled()) { 44 | return LogNoOp; 45 | } 46 | const boundLogFn: (...args: any) => void = console.error.bind( 47 | console, 48 | this.title 49 | ); 50 | return boundLogFn; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/misc/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import { LoggerBase } from './logger-base'; 2 | 3 | const LOGGER_ENABLEDKEY = 'LOGGING_ENABLED'; 4 | export const logger = new LoggerBase('🔥raf:', LOGGER_ENABLEDKEY); 5 | 6 | export const log = logger.log; 7 | export const logError = logger.error; 8 | export const logWarn = logger.warn; 9 | -------------------------------------------------------------------------------- /src/misc/messageTypes.ts: -------------------------------------------------------------------------------- 1 | import { FireStoreCollectionRef, FireStoreQuery } from './firebase-models'; 2 | import { ParsedRefDoc } from './internal.models'; 3 | // Firebase types 4 | import { GetListParams } from './react-admin-models'; 5 | 6 | // PARAMETERS 7 | export namespace messageTypes { 8 | export type IParamsGetList = GetListParams; 9 | 10 | export type CollectionQueryType = ( 11 | arg0: FireStoreCollectionRef 12 | ) => FireStoreQuery; 13 | 14 | export interface IParamsGetOne { 15 | id: string; 16 | } 17 | 18 | export interface IParamsCreate { 19 | data: { 20 | id?: string; 21 | [key: string]: any; 22 | }; 23 | } 24 | 25 | export interface IParamsUpdate { 26 | id: string; 27 | data: { id: string }; 28 | previousData: {}; 29 | } 30 | 31 | export interface IParamsUpdateMany { 32 | ids: string[]; 33 | data: { 34 | id: string; 35 | }; 36 | } 37 | 38 | export interface IParamsDelete { 39 | id: string; 40 | previousData: {}; 41 | } 42 | 43 | export interface IParamsDeleteMany { 44 | ids: string[]; 45 | } 46 | 47 | export type IdMaybeRef = string | any; 48 | export interface IParamsGetMany { 49 | ids: (string | ParsedRefDoc)[]; 50 | } 51 | 52 | export interface IParamsGetManyReference { 53 | target: string; 54 | id: string; 55 | pagination: { 56 | page: number; 57 | perPage: number; 58 | }; 59 | sort: { 60 | field: string; 61 | order: string; 62 | }; 63 | filter?: { 64 | collectionQuery?: CollectionQueryType; 65 | [fieldName: string]: any; 66 | }; 67 | } 68 | 69 | // RESPONSES 70 | 71 | export interface IResponseGetList { 72 | data: Array<{}>; 73 | total: number; 74 | } 75 | 76 | export interface IResponseGetOne { 77 | data: {}; 78 | } 79 | 80 | export interface IResponseCreate { 81 | data: {}; 82 | } 83 | 84 | export interface IResponseUpdate { 85 | data: { id: string }; 86 | } 87 | 88 | export interface IResponseUpdateMany { 89 | data: Array<{}>; 90 | } 91 | 92 | export interface IResponseDelete { 93 | data: {}; 94 | } 95 | 96 | export interface IResponseDeleteMany { 97 | data: Array<{}>; 98 | } 99 | 100 | export interface IResponseGetMany { 101 | data: Array<{}>; 102 | } 103 | 104 | export interface IResponseGetManyReference { 105 | data: Array<{}>; 106 | total: number; 107 | } 108 | 109 | export interface HttpErrorType { 110 | status: number; 111 | message: string; 112 | json?: any; 113 | } 114 | 115 | export type IResponseAny = 116 | | IResponseGetList 117 | | IResponseGetOne 118 | | IResponseCreate 119 | | IResponseUpdate 120 | | IResponseUpdateMany 121 | | IResponseDelete 122 | | IResponseDeleteMany 123 | | HttpErrorType; 124 | } 125 | -------------------------------------------------------------------------------- /src/misc/metadata-parser.ts: -------------------------------------------------------------------------------- 1 | import { RAFirebaseOptions } from 'index'; 2 | import { IFirebaseWrapper, ResourceManager } from 'providers/database'; 3 | 4 | export async function AddCreatedByFields( 5 | obj: any, 6 | fireWrapper: IFirebaseWrapper, 7 | rm: Pick, 8 | options: Pick< 9 | RAFirebaseOptions, 10 | | 'associateUsersById' 11 | | 'disableMeta' 12 | | 'renameMetaFields' 13 | | 'metaFieldCasing' 14 | > 15 | ) { 16 | if (options.disableMeta) { 17 | return; 18 | } 19 | const currentUserIdentifier = await rm.getUserIdentifier(); 20 | const createAtSelector = GetSelectorsCreateAt(options); 21 | const createBySelector = GetSelectorsCreateBy(options); 22 | obj[createAtSelector] = fireWrapper.serverTimestamp(); 23 | obj[createBySelector] = currentUserIdentifier; 24 | } 25 | 26 | export async function AddUpdatedByFields( 27 | obj: any, 28 | fireWrapper: IFirebaseWrapper, 29 | rm: Pick, 30 | options: Pick< 31 | RAFirebaseOptions, 32 | | 'associateUsersById' 33 | | 'disableMeta' 34 | | 'renameMetaFields' 35 | | 'metaFieldCasing' 36 | > 37 | ) { 38 | if (options.disableMeta) { 39 | return; 40 | } 41 | const currentUserIdentifier = await rm.getUserIdentifier(); 42 | const updateAtSelector = GetSelectorsUpdateAt(options); 43 | const updateBySelector = GetSelectorsUpdateBy(options); 44 | obj[updateAtSelector] = fireWrapper.serverTimestamp(); 45 | obj[updateBySelector] = currentUserIdentifier; 46 | } 47 | 48 | export function GetSelectorsUpdateAt( 49 | options: Pick 50 | ): string { 51 | if (options.renameMetaFields && options.renameMetaFields.updated_at) { 52 | return options.renameMetaFields.updated_at; 53 | } 54 | const casing = options.metaFieldCasing; 55 | const defautCase = 'lastupdate'; 56 | if (!casing) { 57 | return defautCase; 58 | } 59 | if (casing === 'camel') { 60 | return 'lastUpdate'; 61 | } 62 | if (casing === 'snake') { 63 | return 'last_update'; 64 | } 65 | if (casing === 'pascal') { 66 | return 'LastUpdate'; 67 | } 68 | if (casing === 'kebab') { 69 | return 'last-update'; 70 | } 71 | return defautCase; 72 | } 73 | 74 | export function GetSelectorsUpdateBy( 75 | options: Pick 76 | ): string { 77 | if (options.renameMetaFields && options.renameMetaFields.updated_by) { 78 | return options.renameMetaFields.updated_by; 79 | } 80 | const casing = options.metaFieldCasing; 81 | const defautCase = 'updatedby'; 82 | if (!casing) { 83 | return defautCase; 84 | } 85 | if (casing === 'camel') { 86 | return 'updatedBy'; 87 | } 88 | if (casing === 'snake') { 89 | return 'updated_by'; 90 | } 91 | if (casing === 'pascal') { 92 | return 'UpdatedBy'; 93 | } 94 | if (casing === 'kebab') { 95 | return 'updated-by'; 96 | } 97 | return defautCase; 98 | } 99 | 100 | export function GetSelectorsCreateAt( 101 | options: Pick 102 | ): string { 103 | if (options.renameMetaFields && options.renameMetaFields.created_at) { 104 | return options.renameMetaFields.created_at; 105 | } 106 | const casing = options.metaFieldCasing; 107 | const defautCase = 'createdate'; 108 | if (!casing) { 109 | return defautCase; 110 | } 111 | if (casing === 'camel') { 112 | return 'createDate'; 113 | } 114 | if (casing === 'snake') { 115 | return 'create_date'; 116 | } 117 | if (casing === 'pascal') { 118 | return 'CreateDate'; 119 | } 120 | if (casing === 'kebab') { 121 | return 'create-date'; 122 | } 123 | return defautCase; 124 | } 125 | 126 | export function GetSelectorsCreateBy( 127 | options: Pick 128 | ): string { 129 | if (options.renameMetaFields && options.renameMetaFields.created_by) { 130 | return options.renameMetaFields.created_by; 131 | } 132 | const casing = options.metaFieldCasing; 133 | const defautCase = 'createdby'; 134 | if (!casing) { 135 | return defautCase; 136 | } 137 | if (casing === 'camel') { 138 | return 'createdBy'; 139 | } 140 | if (casing === 'snake') { 141 | return 'created_by'; 142 | } 143 | if (casing === 'pascal') { 144 | return 'CreatedBy'; 145 | } 146 | if (casing === 'kebab') { 147 | return 'created-by'; 148 | } 149 | return defautCase; 150 | } 151 | -------------------------------------------------------------------------------- /src/misc/objectFlatten.ts: -------------------------------------------------------------------------------- 1 | type SearchValues = {} | number | string | boolean | null; 2 | type SearchValue = SearchValues | SearchValue[]; 3 | 4 | export interface SearchObj { 5 | searchField: string; 6 | searchValue: SearchValue; 7 | } 8 | export function getFieldReferences( 9 | fieldName: string, 10 | value: {} | SearchValue 11 | ): SearchObj[] { 12 | const isFalsy = !value; 13 | const isSimple = 14 | isFalsy || 15 | typeof value === 'string' || 16 | typeof value === 'number' || 17 | typeof value === 'boolean'; 18 | 19 | if (isSimple) { 20 | return [ 21 | { 22 | searchField: fieldName, 23 | searchValue: value as SearchValue, 24 | }, 25 | ]; 26 | } 27 | const tree = {} as Record; 28 | tree[fieldName] = value; 29 | return objectFlatten(tree); 30 | } 31 | 32 | export function objectFlatten(tree: {}): SearchObj[] { 33 | var leaves: SearchObj[] = []; 34 | var recursivelyWalk = (obj: any, path: string | null) => { 35 | path = path || ''; 36 | for (var key in obj) { 37 | if (obj.hasOwnProperty(key)) { 38 | const objVal = obj && obj[key]; 39 | const currentPath = !!path ? path + '.' + key : key; 40 | const isWalkable = 41 | typeof objVal === 'object' || objVal instanceof Array; 42 | if (isWalkable) { 43 | recursivelyWalk(objVal, currentPath); 44 | } else { 45 | leaves.push({ searchField: currentPath, searchValue: objVal }); 46 | } 47 | } 48 | } 49 | }; 50 | recursivelyWalk(tree, null); 51 | return leaves; 52 | } 53 | -------------------------------------------------------------------------------- /src/misc/pathHelper.ts: -------------------------------------------------------------------------------- 1 | import path from 'path-browserify'; 2 | 3 | export function getAbsolutePath( 4 | rootRef: undefined | string | (() => string), 5 | relativePath: string | null 6 | ): string { 7 | if (!rootRef) { 8 | return relativePath + ''; 9 | } 10 | if (!relativePath) { 11 | throw new Error( 12 | 'Resource name must be a string of length greater than 0 characters' 13 | ); 14 | } 15 | const rootRefValue = typeof rootRef === 'string' ? rootRef : rootRef(); 16 | const withSlashes = path.join('/', rootRefValue, '/', relativePath, '/'); 17 | const slashCount = withSlashes.split('/').length - 1; 18 | if (slashCount % 2) { 19 | throw new Error(`The rootRef path must point to a "document" 20 | not a "collection"e.g. /collection/document/ or 21 | /collection/document/collection/document/`); 22 | } 23 | return withSlashes.slice(1, -1); 24 | } 25 | 26 | export function joinPaths(...args: string[]) { 27 | return path.join(...args); 28 | } 29 | -------------------------------------------------------------------------------- /src/misc/react-admin-models.ts: -------------------------------------------------------------------------------- 1 | // React-Admin Core 2 | export { 3 | AuthProvider, 4 | CreateParams, 5 | CreateResult, 6 | DataProvider, 7 | DeleteManyParams, 8 | DeleteManyResult, 9 | DeleteParams, 10 | DeleteResult, 11 | GetListParams, 12 | GetListResult, 13 | GetManyParams, 14 | GetManyReferenceParams, 15 | GetManyReferenceResult, 16 | GetManyResult, 17 | GetOneParams, 18 | GetOneResult, 19 | Identifier, 20 | Record, 21 | UpdateManyParams, 22 | UpdateManyResult, 23 | UpdateParams, 24 | UpdateResult, 25 | UserIdentity, 26 | } from 'ra-core'; 27 | -------------------------------------------------------------------------------- /src/misc/status-code-translator.ts: -------------------------------------------------------------------------------- 1 | // From firebase SDK 2 | 3 | import { logError } from './logger'; 4 | 5 | // tslint:disable-next-line:max-line-length 6 | // - https://github.com/firebase/firebase-js-sdk/blob/9f109f85ad0d99f6c13e68dcb549a0b852e35a2a/packages/functions/src/api/error.ts 7 | export function retrieveStatusTxt(status: number): 'ok' | 'unauthenticated' { 8 | // Make sure any successful status is OK. 9 | if (status >= 200 && status < 300) { 10 | return 'ok'; 11 | } 12 | switch (status) { 13 | case 401: // 'unauthenticated' 14 | case 403: // 'permission-denied' 15 | return 'unauthenticated'; 16 | 17 | case 0: // 'internal' 18 | case 400: // 'invalid-argument' 19 | case 404: // 'not-found' 20 | case 409: // 'aborted' 21 | case 429: // 'resource-exhausted' 22 | case 499: // 'cancelled' 23 | case 500: // 'internal' 24 | case 501: // 'unimplemented' 25 | case 503: // 'unavailable' 26 | case 504: // 'deadline-exceeded' 27 | default: 28 | // ignore 29 | return 'ok'; 30 | } 31 | } 32 | 33 | // From firebase SDK 34 | // tslint:disable-next-line:max-line-length 35 | // - https://github.com/firebase/firebase-js-sdk/blob/9f109f85ad0d99f6c13e68dcb549a0b852e35a2a/packages/functions/src/api/error.ts 36 | export function retrieveStatusCode(statusTxt: string): number { 37 | // Make sure any successful status is OK. 38 | const regexResult = /\[code\=([\w-]*)/g.exec(statusTxt); 39 | const status = Array.isArray(regexResult) && regexResult[1]; 40 | if (!status) { 41 | logError('unknown StatusCode ', { statusTxt }); 42 | } 43 | switch (status) { 44 | case 'unauthenticated': 45 | return 401; 46 | case 'permission-denied': 47 | return 403; 48 | case 'internal': 49 | return 0; 50 | case 'invalid-argument': 51 | return 400; 52 | case 'not-found': 53 | return 404; 54 | case 'aborted': 55 | return 409; 56 | case 'resource-exhausted': 57 | return 429; 58 | case 'cancelled': 59 | return 499; 60 | case 'internal': 61 | return 500; 62 | case 'unimplemented': 63 | return 501; 64 | case 'unavailable': 65 | return 503; 66 | case 'deadline-exceeded': 67 | return 504; 68 | default: 69 | return 200; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/misc/storage-parser.ts: -------------------------------------------------------------------------------- 1 | import { joinPaths } from './pathHelper'; 2 | 3 | export function parseStoragePath( 4 | rawFile: File, 5 | docPath: string, 6 | fieldPath: string, 7 | useFileName: boolean 8 | ): string { 9 | const fileNameBits = rawFile instanceof File ? rawFile.name.split('.') : []; 10 | 11 | const fileExtension = !fileNameBits?.length ? '' : '.' + fileNameBits.pop(); 12 | 13 | return useFileName 14 | ? joinPaths(docPath, fieldPath, rawFile.name) 15 | : joinPaths(docPath, fieldPath + fileExtension); 16 | } 17 | -------------------------------------------------------------------------------- /src/misc/translate-from-firestore.ts: -------------------------------------------------------------------------------- 1 | import { getDownloadURL, ref } from 'firebase/storage'; 2 | import { has, set } from 'lodash'; 3 | import { IFirebaseWrapper } from 'providers/database'; 4 | import { FireStoreDocumentRef } from './firebase-models'; 5 | import { REF_INDENTIFIER } from './internal.models'; 6 | import { logError } from './logger'; 7 | 8 | export interface RefDocFound { 9 | fieldPath: string; 10 | refDocPath: string; 11 | } 12 | 13 | export interface FromFirestoreResult { 14 | parsedDoc: any; 15 | refdocs: RefDocFound[]; 16 | } 17 | 18 | export function translateDocFromFirestore(obj: any) { 19 | const isObject = !!obj && typeof obj === 'object'; 20 | const result: FromFirestoreResult = { 21 | parsedDoc: {}, 22 | refdocs: [], 23 | }; 24 | if (!isObject) { 25 | return result; 26 | } 27 | Object.keys(obj).map((key) => { 28 | const value = obj[key]; 29 | obj[key] = recusivelyCheckObjectValue(value, key, result); 30 | }); 31 | result.parsedDoc = obj; 32 | return result; 33 | } 34 | 35 | export function recusivelyCheckObjectValue( 36 | input: any, 37 | fieldPath: string, 38 | result: FromFirestoreResult 39 | ): any { 40 | const isFalsey = !input; 41 | if (isFalsey) { 42 | return input; 43 | } 44 | const isPrimitive = typeof input !== 'object'; 45 | if (isPrimitive) { 46 | return input; 47 | } 48 | const isTimestamp = !!input.toDate && typeof input.toDate === 'function'; 49 | if (isTimestamp) { 50 | return input.toDate(); 51 | } 52 | const isArray = Array.isArray(input); 53 | if (isArray) { 54 | return (input as any[]).map((value, index) => 55 | recusivelyCheckObjectValue(value, `${fieldPath}.${index}`, result) 56 | ); 57 | } 58 | const isDocumentReference = isInputADocReference(input); 59 | if (isDocumentReference) { 60 | const documentReference = input as FireStoreDocumentRef; 61 | result.refdocs.push({ 62 | fieldPath: fieldPath, 63 | refDocPath: documentReference.path, 64 | }); 65 | return documentReference.id; 66 | } 67 | const isObject = typeof input === 'object'; 68 | if (isObject) { 69 | Object.keys(input).map((key) => { 70 | const value = input[key]; 71 | input[key] = recusivelyCheckObjectValue(value, key, result); 72 | }); 73 | return input; 74 | } 75 | return input; 76 | } 77 | 78 | function isInputADocReference(input: any): boolean { 79 | const isDocumentReference = 80 | typeof input.id === 'string' && 81 | typeof input.firestore === 'object' && 82 | typeof input.parent === 'object' && 83 | typeof input.path === 'string'; 84 | return isDocumentReference; 85 | } 86 | 87 | export function applyRefDocs(doc: any, refDocs: RefDocFound[]) { 88 | refDocs.map((d) => { 89 | set(doc, REF_INDENTIFIER + d.fieldPath, d.refDocPath); 90 | }); 91 | return doc; 92 | } 93 | 94 | export const recursivelyMapStorageUrls = async ( 95 | fireWrapper: IFirebaseWrapper, 96 | fieldValue: any 97 | ): Promise => { 98 | const isPrimitive = !fieldValue || typeof fieldValue !== 'object'; 99 | if (isPrimitive) { 100 | return fieldValue; 101 | } 102 | const isFileField = has(fieldValue, 'src'); 103 | if (isFileField) { 104 | try { 105 | const src = await getDownloadURL( 106 | ref(fireWrapper.storage(), fieldValue.src) 107 | ); 108 | return { 109 | ...fieldValue, 110 | src, 111 | }; 112 | } catch (error) { 113 | logError(`Error when getting download URL`, { 114 | error, 115 | }); 116 | return fieldValue; 117 | } 118 | } 119 | const isArray = Array.isArray(fieldValue); 120 | if (isArray) { 121 | return Promise.all( 122 | (fieldValue as any[]).map(async (value, index) => { 123 | fieldValue[index] = await recursivelyMapStorageUrls(fireWrapper, value); 124 | }) 125 | ); 126 | } 127 | const isDocumentReference = isInputADocReference(fieldValue); 128 | if (isDocumentReference) { 129 | return fieldValue; 130 | } 131 | const isObject = !isArray && typeof fieldValue === 'object'; 132 | if (isObject) { 133 | return Promise.all( 134 | Object.keys(fieldValue).map(async (key) => { 135 | const value = fieldValue[key]; 136 | fieldValue[key] = await recursivelyMapStorageUrls(fireWrapper, value); 137 | }) 138 | ); 139 | } 140 | }; 141 | -------------------------------------------------------------------------------- /src/misc/translate-to-firestore.ts: -------------------------------------------------------------------------------- 1 | import { REF_INDENTIFIER } from './internal.models'; 2 | 3 | interface ParsedUpload { 4 | fieldDotsPath: string; 5 | fieldSlashesPath: string; 6 | rawFile: File | any; 7 | } 8 | 9 | interface ParsedDocRef { 10 | fieldDotsPath: string; 11 | refPath: string; 12 | } 13 | 14 | interface ParseResult { 15 | parsedDoc: any; 16 | uploads: ParsedUpload[]; 17 | refdocs: ParsedDocRef[]; 18 | } 19 | 20 | export function translateDocToFirestore(obj: any): ParseResult { 21 | const isObject = !!obj && typeof obj === 'object'; 22 | const result: ParseResult = { 23 | uploads: [], 24 | refdocs: [], 25 | parsedDoc: {}, 26 | }; 27 | if (!isObject) { 28 | return result; 29 | } 30 | Object.keys(obj).map((key) => { 31 | const value = obj[key]; 32 | recusivelyParseObjectValue(value, key, result); 33 | }); 34 | result.parsedDoc = obj; 35 | return result; 36 | } 37 | 38 | export function recusivelyParseObjectValue( 39 | input: any, 40 | fieldPath: string, 41 | result: ParseResult 42 | ): any { 43 | const isFalsey = !input; 44 | if (isFalsey) { 45 | return input; 46 | } 47 | const isRefField = 48 | typeof fieldPath === 'string' && fieldPath.includes(REF_INDENTIFIER); 49 | if (isRefField) { 50 | const refDocFullPath = input as string; 51 | result.refdocs.push({ 52 | fieldDotsPath: fieldPath, 53 | refPath: refDocFullPath, 54 | }); 55 | return; 56 | } 57 | const isPrimitive = typeof input !== 'object'; 58 | if (isPrimitive) { 59 | return input; 60 | } 61 | const isTimestamp = !!input.toDate && typeof input.toDate === 'function'; 62 | if (isTimestamp) { 63 | return input.toDate(); 64 | } 65 | const isArray = Array.isArray(input); 66 | if (isArray) { 67 | return (input as []).map((value, index) => 68 | recusivelyParseObjectValue(value, `${fieldPath}.${index}`, result) 69 | ); 70 | } 71 | const isFileField = !!input && input.hasOwnProperty('rawFile'); 72 | if (isFileField) { 73 | result.uploads.push({ 74 | fieldDotsPath: fieldPath, 75 | fieldSlashesPath: fieldPath.split('.').join('/'), 76 | rawFile: input.rawFile, 77 | }); 78 | delete input.rawFile; 79 | return; 80 | } 81 | Object.keys(input).map((key) => { 82 | const value = input[key]; 83 | recusivelyParseObjectValue(value, `${fieldPath}.${key}`, result); 84 | }); 85 | return input; 86 | } 87 | -------------------------------------------------------------------------------- /src/providers/AuthProvider.ts: -------------------------------------------------------------------------------- 1 | import { log, logger, logWarn, retrieveStatusTxt } from '../misc'; 2 | import { FireUser } from '../misc/firebase-models'; 3 | import { 4 | AuthProvider as RaAuthProvider, 5 | UserIdentity, 6 | } from '../misc/react-admin-models'; 7 | import { messageTypes } from './../misc/messageTypes'; 8 | import { IFirebaseWrapper } from './database'; 9 | import { FirebaseWrapper } from './database/firebase/FirebaseWrapper'; 10 | import { RAFirebaseOptions } from './options'; 11 | 12 | class AuthClient { 13 | private fireWrapper: IFirebaseWrapper; 14 | 15 | constructor(firebaseConfig: {}, optionsInput?: RAFirebaseOptions) { 16 | const options = optionsInput || {}; 17 | log('Auth Client: initializing...', { firebaseConfig, options }); 18 | this.fireWrapper = new FirebaseWrapper(options, firebaseConfig); 19 | options.persistence && this.setPersistence(options.persistence); 20 | } 21 | 22 | setPersistence(persistenceInput: 'session' | 'local' | 'none') { 23 | return this.fireWrapper.authSetPersistence(persistenceInput); 24 | } 25 | 26 | public async HandleAuthLogin(params: { username: string; password: string }) { 27 | const { username, password } = params; 28 | 29 | if (username && password) { 30 | try { 31 | const user = await this.fireWrapper.authSigninEmailPassword( 32 | username, 33 | password 34 | ); 35 | log('HandleAuthLogin: user sucessfully logged in', { user }); 36 | return user; 37 | } catch (e) { 38 | log('HandleAuthLogin: invalid credentials', { params }); 39 | throw new Error('Login error: invalid credentials'); 40 | } 41 | } else { 42 | return this.getUserLogin(); 43 | } 44 | } 45 | 46 | public HandleAuthLogout() { 47 | return this.fireWrapper.authSignOut(); 48 | } 49 | 50 | public HandleAuthError(errorHttp: messageTypes.HttpErrorType) { 51 | log('HandleAuthLogin: invalid credentials', { errorHttp }); 52 | const status = !!errorHttp && errorHttp.status; 53 | const statusTxt = retrieveStatusTxt(status); 54 | if (statusTxt === 'ok') { 55 | log('API is actually authenticated'); 56 | return Promise.resolve(); 57 | } 58 | logWarn('Received authentication error from API'); 59 | return Promise.reject(); 60 | } 61 | 62 | public async HandleAuthCheck(): Promise { 63 | return this.getUserLogin(); 64 | } 65 | 66 | public getUserLogin(): Promise { 67 | return this.fireWrapper.authGetUserLoggedIn(); 68 | } 69 | 70 | public async HandleGetPermissions() { 71 | try { 72 | const user = await this.getUserLogin(); 73 | // @ts-ignore 74 | const token = await user.getIdTokenResult(); 75 | 76 | return token.claims; 77 | } catch (e) { 78 | log('HandleGetPermission: no user is logged in or tokenResult error', { 79 | e, 80 | }); 81 | return null; 82 | } 83 | } 84 | 85 | public async HandleGetIdentity(): Promise { 86 | try { 87 | const { uid, displayName, photoURL } = await this.getUserLogin(); 88 | const identity: UserIdentity = { 89 | id: uid, 90 | fullName: `${displayName ?? ''}`, 91 | avatar: `${photoURL ?? ''}`, 92 | }; 93 | return identity; 94 | } catch (e) { 95 | log('HandleGetIdentity: no user is logged in', { 96 | e, 97 | }); 98 | return null as any; 99 | } 100 | } 101 | 102 | public async HandleGetJWTAuthTime() { 103 | try { 104 | const user = await this.getUserLogin(); 105 | // @ts-ignore 106 | const token = await user.getIdTokenResult(); 107 | 108 | return token.authTime; 109 | } catch (e) { 110 | log('HandleGetJWTAuthTime: no user is logged in or tokenResult error', { 111 | e, 112 | }); 113 | return null; 114 | } 115 | } 116 | 117 | public async HandleGetJWTExpirationTime() { 118 | try { 119 | const user = await this.getUserLogin(); 120 | // @ts-ignore 121 | const token = await user.getIdTokenResult(); 122 | 123 | return token.expirationTime; 124 | } catch (e) { 125 | log( 126 | 'HandleGetJWTExpirationTime: no user is logged in or tokenResult error', 127 | { 128 | e, 129 | } 130 | ); 131 | return null; 132 | } 133 | } 134 | 135 | public async HandleGetJWTSignInProvider() { 136 | try { 137 | const user = await this.getUserLogin(); 138 | // @ts-ignore 139 | const token = await user.getIdTokenResult(); 140 | 141 | return token.signInProvider; 142 | } catch (e) { 143 | log( 144 | 'HandleGetJWTSignInProvider: no user is logged in or tokenResult error', 145 | { 146 | e, 147 | } 148 | ); 149 | return null; 150 | } 151 | } 152 | 153 | public async HandleGetJWTIssuedAtTime() { 154 | try { 155 | const user = await this.getUserLogin(); 156 | // @ts-ignore 157 | const token = await user.getIdTokenResult(); 158 | 159 | return token.issuedAtTime; 160 | } catch (e) { 161 | log( 162 | 'HandleGetJWTIssuedAtTime: no user is logged in or tokenResult error', 163 | { 164 | e, 165 | } 166 | ); 167 | return null; 168 | } 169 | } 170 | 171 | public async HandleGetJWTToken() { 172 | try { 173 | const user = await this.getUserLogin(); 174 | // @ts-ignore 175 | const token = await user.getIdTokenResult(); 176 | 177 | return token.token; 178 | } catch (e) { 179 | log('HandleGetJWTToken: no user is logged in or tokenResult error', { 180 | e, 181 | }); 182 | return null; 183 | } 184 | } 185 | } 186 | 187 | export function AuthProvider( 188 | firebaseConfig: {}, 189 | options: RAFirebaseOptions 190 | ): ReactAdminFirebaseAuthProvider { 191 | VerifyAuthProviderArgs(firebaseConfig, options); 192 | logger.SetEnabled(!!options?.logging); 193 | const auth = new AuthClient(firebaseConfig, options); 194 | 195 | const provider: ReactAdminFirebaseAuthProvider = { 196 | // React Admin Interface 197 | login: (params) => auth.HandleAuthLogin(params), 198 | logout: () => auth.HandleAuthLogout(), 199 | checkAuth: () => auth.HandleAuthCheck(), 200 | checkError: (error) => auth.HandleAuthError(error), 201 | getPermissions: () => auth.HandleGetPermissions(), 202 | getIdentity: () => auth.HandleGetIdentity(), 203 | // Custom Functions 204 | getAuthUser: () => auth.getUserLogin(), 205 | getJWTAuthTime: () => auth.HandleGetJWTAuthTime(), 206 | getJWTExpirationTime: () => auth.HandleGetJWTExpirationTime(), 207 | getJWTSignInProvider: () => auth.HandleGetJWTSignInProvider(), 208 | getJWTClaims: () => auth.HandleGetPermissions(), 209 | getJWTToken: () => auth.HandleGetJWTToken(), 210 | }; 211 | return provider; 212 | } 213 | 214 | export type ReactAdminFirebaseAuthProvider = RaAuthProvider & { 215 | // Custom Functions 216 | getAuthUser: () => Promise; 217 | getJWTAuthTime: () => Promise; 218 | getJWTExpirationTime: () => Promise; 219 | getJWTSignInProvider: () => Promise; 220 | getJWTClaims: () => Promise<{ [key: string]: any } | null>; 221 | getJWTToken: () => Promise; 222 | }; 223 | 224 | function VerifyAuthProviderArgs( 225 | firebaseConfig: {}, 226 | options: RAFirebaseOptions 227 | ) { 228 | const hasNoApp = !options || !options.app; 229 | const hasNoConfig = !firebaseConfig; 230 | if (hasNoConfig && hasNoApp) { 231 | throw new Error( 232 | 'Please pass the Firebase firebaseConfig object or options.app to the FirebaseAuthProvider' 233 | ); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/providers/DataProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAbsolutePath, 3 | log, 4 | logError, 5 | logger, 6 | MakeFirestoreLogger, 7 | retrieveStatusCode, 8 | } from '../misc'; 9 | import { FireApp } from '../misc/firebase-models'; 10 | import * as ra from '../misc/react-admin-models'; 11 | import { Create, Delete, DeleteMany, Update, UpdateMany } from './commands'; 12 | import { FirebaseWrapper } from './database/firebase/FirebaseWrapper'; 13 | import { FireClient } from './database/FireClient'; 14 | import { RAFirebaseOptions } from './options'; 15 | import { GetList, GetMany, GetManyReference, GetOne } from './queries'; 16 | 17 | export interface IDataProvider extends ra.DataProvider { 18 | app: FireApp; 19 | } 20 | 21 | export function DataProvider( 22 | firebaseConfig: {}, 23 | optionsInput?: RAFirebaseOptions 24 | ): IDataProvider { 25 | const options = optionsInput || {}; 26 | verifyDataProviderArgs(firebaseConfig, options); 27 | 28 | const flogger = MakeFirestoreLogger(options); 29 | logger.SetEnabled(!!options?.logging); 30 | flogger.SetEnabled(!!options?.firestoreCostsLogger?.enabled); 31 | flogger.ResetCount(!options?.firestoreCostsLogger?.persistCount); 32 | log('Creating FirebaseDataProvider', { 33 | firebaseConfig, 34 | options, 35 | }); 36 | 37 | const fireWrapper = new FirebaseWrapper(optionsInput, firebaseConfig); 38 | 39 | async function run(cb: () => Promise) { 40 | let res: any; 41 | try { 42 | res = await cb(); 43 | return res; 44 | } catch (error) { 45 | const errorMsg = ((error as any) || '').toString(); 46 | const code = retrieveStatusCode(errorMsg); 47 | const errorObj = { status: code, message: errorMsg, json: res }; 48 | logError('DataProvider:', error, { errorMsg, code, errorObj }); 49 | throw errorObj; 50 | } 51 | } 52 | const client = new FireClient(fireWrapper, options, flogger); 53 | 54 | const newProviderApi: IDataProvider = { 55 | app: fireWrapper.GetApp(), 56 | getList( 57 | resource: string, 58 | params: ra.GetListParams 59 | ): Promise> { 60 | return run(() => GetList(resource, params, client)); 61 | }, 62 | getOne( 63 | resource: string, 64 | params: ra.GetOneParams 65 | ): Promise> { 66 | return run(() => GetOne(resource, params, client)); 67 | }, 68 | getMany( 69 | resource: string, 70 | params: ra.GetManyParams 71 | ): Promise> { 72 | return run(() => GetMany(resource, params, client)); 73 | }, 74 | getManyReference( 75 | resource: string, 76 | params: ra.GetManyReferenceParams 77 | ): Promise> { 78 | return run(() => GetManyReference(resource, params, client)); 79 | }, 80 | update( 81 | resource: string, 82 | params: ra.UpdateParams 83 | ): Promise> { 84 | return run(() => Update(resource, params, client)); 85 | }, 86 | updateMany( 87 | resource: string, 88 | params: ra.UpdateManyParams 89 | ): Promise { 90 | return run(() => UpdateMany(resource, params, client)); 91 | }, 92 | create( 93 | resource: string, 94 | params: ra.CreateParams 95 | ): Promise> { 96 | return run(() => Create(resource, params, client)); 97 | }, 98 | delete( 99 | resource: string, 100 | params: ra.DeleteParams 101 | ): Promise> { 102 | return run(() => Delete(resource, params, client)); 103 | }, 104 | deleteMany( 105 | resource: string, 106 | params: ra.DeleteManyParams 107 | ): Promise { 108 | return run(() => DeleteMany(resource, params, client)); 109 | }, 110 | }; 111 | 112 | return newProviderApi; 113 | } 114 | 115 | function verifyDataProviderArgs( 116 | firebaseConfig: {}, 117 | options?: RAFirebaseOptions 118 | ) { 119 | const hasNoApp = !options || !options.app; 120 | const hasNoConfig = !firebaseConfig; 121 | if (hasNoConfig && hasNoApp) { 122 | throw new Error( 123 | 'Please pass the Firebase firebaseConfig object or options.app to the FirebaseAuthProvider' 124 | ); 125 | } 126 | if (options && options.rootRef) { 127 | // Will throw error if rootRef doesn't point to a document 128 | getAbsolutePath(options.rootRef, 'test'); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/providers/commands/Create.ts: -------------------------------------------------------------------------------- 1 | import { doc, getDoc, setDoc } from 'firebase/firestore'; 2 | import { log } from '../../misc'; 3 | import * as ra from '../../misc/react-admin-models'; 4 | import { FireClient } from '../database/FireClient'; 5 | 6 | export async function Create( 7 | resourceName: string, 8 | params: ra.CreateParams, 9 | client: FireClient 10 | ): Promise> { 11 | const { rm, fireWrapper } = client; 12 | const r = await rm.TryGetResource(resourceName); 13 | log('Create', { resourceName, resource: r, params }); 14 | const hasOverridenDocId = params.data && params.data.id; 15 | log('Create', { hasOverridenDocId }); 16 | if (hasOverridenDocId) { 17 | const overridenId = params.data.id; 18 | const exists = (await getDoc(doc(r.collection, overridenId))).exists(); 19 | if (exists) { 20 | throw new Error( 21 | `the id:"${overridenId}" already exists, please use a unique string if overriding the 'id' field` 22 | ); 23 | } 24 | 25 | const createData = await client.parseDataAndUpload( 26 | r, 27 | overridenId, 28 | params.data 29 | ); 30 | if (!overridenId) { 31 | throw new Error('id must be a valid string'); 32 | } 33 | const createDocObj = { ...createData }; 34 | client.checkRemoveIdField(createDocObj, overridenId); 35 | await client.addCreatedByFields(createDocObj); 36 | await client.addUpdatedByFields(createDocObj); 37 | const createDocObjTransformed = client.transformToDb( 38 | resourceName, 39 | createDocObj, 40 | overridenId 41 | ); 42 | log('Create', { docObj: createDocObj }); 43 | await setDoc(doc(r.collection, overridenId), createDocObjTransformed, { 44 | merge: false, 45 | }); 46 | return { 47 | data: { 48 | ...createDocObjTransformed, 49 | id: overridenId, 50 | }, 51 | }; 52 | } 53 | const newId = fireWrapper.dbMakeNewId(); 54 | const data = await client.parseDataAndUpload(r, newId, params.data); 55 | const docObj = { ...data }; 56 | client.checkRemoveIdField(docObj, newId); 57 | await client.addCreatedByFields(docObj); 58 | await client.addUpdatedByFields(docObj); 59 | const docObjTransformed = client.transformToDb(resourceName, docObj, newId); 60 | await setDoc(doc(r.collection, newId), docObjTransformed, { merge: false }); 61 | return { 62 | data: { 63 | ...docObjTransformed, 64 | id: newId, 65 | }, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/providers/commands/Delete.Soft.ts: -------------------------------------------------------------------------------- 1 | import { doc, updateDoc } from 'firebase/firestore'; 2 | import { log, logError } from '../../misc'; 3 | import * as ra from '../../misc/react-admin-models'; 4 | import { FireClient } from '../database'; 5 | 6 | export async function DeleteSoft( 7 | resourceName: string, 8 | params: ra.DeleteParams, 9 | client: FireClient 10 | ): Promise> { 11 | const { rm } = client; 12 | const id = params.id + ''; 13 | const r = await rm.TryGetResource(resourceName); 14 | log('DeleteSoft', { resourceName, resource: r, params }); 15 | const docObj = { deleted: true }; 16 | await client.addUpdatedByFields(docObj); 17 | 18 | updateDoc(doc(r.collection, id), docObj).catch((error) => { 19 | logError('DeleteSoft error', { error }); 20 | }); 21 | 22 | return { 23 | data: params.previousData as T, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/providers/commands/Delete.ts: -------------------------------------------------------------------------------- 1 | import { deleteDoc, doc } from 'firebase/firestore'; 2 | import { log } from '../../misc'; 3 | import * as ra from '../../misc/react-admin-models'; 4 | import { FireClient } from '../database/FireClient'; 5 | import { DeleteSoft } from './Delete.Soft'; 6 | 7 | export async function Delete( 8 | resourceName: string, 9 | params: ra.DeleteParams, 10 | client: FireClient 11 | ): Promise> { 12 | const { rm, options } = client; 13 | if (options.softDelete) { 14 | return DeleteSoft(resourceName, params, client); 15 | } 16 | const r = await rm.TryGetResource(resourceName); 17 | log('apiDelete', { resourceName, resource: r, params }); 18 | try { 19 | const id = params.id + ''; 20 | 21 | await deleteDoc(doc(r.collection, id)); 22 | } catch (error) { 23 | throw new Error(error as any); 24 | } 25 | return { 26 | data: params.previousData as T, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/providers/commands/DeleteMany.Soft.ts: -------------------------------------------------------------------------------- 1 | import { doc, updateDoc } from 'firebase/firestore'; 2 | import { log, logError } from '../../misc'; 3 | import * as ra from '../../misc/react-admin-models'; 4 | import { FireClient } from '../database'; 5 | 6 | export async function DeleteManySoft( 7 | resourceName: string, 8 | params: ra.DeleteManyParams, 9 | client: FireClient 10 | ): Promise { 11 | const { rm } = client; 12 | const r = await rm.TryGetResource(resourceName); 13 | log('DeleteManySoft', { resourceName, resource: r, params }); 14 | const ids = params.ids; 15 | const returnData = await Promise.all( 16 | ids.map(async (id) => { 17 | const idStr = id + ''; 18 | const docObj = { deleted: true }; 19 | await client.addUpdatedByFields(docObj); 20 | updateDoc(doc(r.collection, idStr), docObj).catch((error) => { 21 | logError('apiSoftDeleteMany error', { error }); 22 | }); 23 | return idStr; 24 | }) 25 | ); 26 | return { 27 | data: returnData, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/providers/commands/DeleteMany.ts: -------------------------------------------------------------------------------- 1 | import { doc } from 'firebase/firestore'; 2 | import { log } from '../../misc'; 3 | import * as ra from '../../misc/react-admin-models'; 4 | import { FireClient } from '../database'; 5 | import { DeleteManySoft } from './DeleteMany.Soft'; 6 | 7 | export async function DeleteMany( 8 | resourceName: string, 9 | params: ra.DeleteManyParams, 10 | client: FireClient 11 | ): Promise { 12 | const { options, rm, fireWrapper } = client; 13 | if (options.softDelete) { 14 | return DeleteManySoft(resourceName, params, client); 15 | } 16 | const r = await rm.TryGetResource(resourceName); 17 | log('DeleteMany', { resourceName, resource: r, params }); 18 | const returnData: ra.Identifier[] = []; 19 | const batch = fireWrapper.dbCreateBatch(); 20 | for (const id of params.ids) { 21 | const idStr = id + ''; 22 | const docToDelete = doc(r.collection, idStr); 23 | batch.delete(docToDelete); 24 | returnData.push(id); 25 | } 26 | 27 | try { 28 | await batch.commit(); 29 | } catch (error) { 30 | throw new Error(error as any); 31 | } 32 | return { data: returnData }; 33 | } 34 | -------------------------------------------------------------------------------- /src/providers/commands/Update.ts: -------------------------------------------------------------------------------- 1 | import { doc, updateDoc } from 'firebase/firestore'; 2 | import { log } from '../../misc'; 3 | import * as ra from '../../misc/react-admin-models'; 4 | import { FireClient } from '../database'; 5 | 6 | export async function Update( 7 | resourceName: string, 8 | params: ra.UpdateParams, 9 | client: FireClient 10 | ): Promise> { 11 | const { rm } = client; 12 | log('Update', { resourceName, params }); 13 | const id = params.id + ''; 14 | delete params.data.id; 15 | const r = await rm.TryGetResource(resourceName); 16 | log('Update', { resourceName, resource: r, params }); 17 | const data = await client.parseDataAndUpload(r, id, params.data); 18 | const docObj = { ...data }; 19 | client.checkRemoveIdField(docObj, id); 20 | await client.addUpdatedByFields(docObj); 21 | const docObjTransformed = client.transformToDb(resourceName, docObj, id); 22 | await updateDoc(doc(r.collection, id), docObjTransformed); 23 | return { 24 | data: { 25 | ...data, 26 | id: id, 27 | }, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/providers/commands/UpdateMany.ts: -------------------------------------------------------------------------------- 1 | import { doc, updateDoc } from 'firebase/firestore'; 2 | import { log } from '../../misc'; 3 | import * as ra from '../../misc/react-admin-models'; 4 | import { FireClient } from '../database'; 5 | 6 | export async function UpdateMany( 7 | resourceName: string, 8 | params: ra.UpdateManyParams, 9 | client: FireClient 10 | ): Promise { 11 | const { rm } = client; 12 | log('UpdateMany', { resourceName, params }); 13 | delete params.data.id; 14 | const r = await rm.TryGetResource(resourceName); 15 | log('UpdateMany', { resourceName, resource: r, params }); 16 | const ids = params.ids; 17 | const returnData = await Promise.all( 18 | ids.map(async (id) => { 19 | const idStr = id + ''; 20 | const data = await client.parseDataAndUpload(r, idStr, params.data); 21 | const docObj = { ...data }; 22 | client.checkRemoveIdField(docObj, idStr); 23 | await client.addUpdatedByFields(docObj); 24 | const docObjTransformed = client.transformToDb( 25 | resourceName, 26 | docObj, 27 | idStr 28 | ); 29 | await updateDoc(doc(r.collection, idStr), docObjTransformed); 30 | return { 31 | ...data, 32 | id: idStr, 33 | }; 34 | }) 35 | ); 36 | return { 37 | data: returnData, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/providers/commands/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Create'; 2 | export * from './Delete'; 3 | export * from './Delete.Soft'; 4 | export * from './DeleteMany'; 5 | export * from './DeleteMany.Soft'; 6 | export * from './Update'; 7 | export * from './UpdateMany'; 8 | -------------------------------------------------------------------------------- /src/providers/database/FireClient.ts: -------------------------------------------------------------------------------- 1 | import { doc } from 'firebase/firestore'; 2 | import { get, set } from 'lodash'; 3 | import { 4 | AddCreatedByFields, 5 | AddUpdatedByFields, 6 | dispatch, 7 | IFirestoreLogger, 8 | log, 9 | logError, 10 | parseStoragePath, 11 | translateDocToFirestore, 12 | } from '../../misc'; 13 | import { 14 | TASK_CANCELED, 15 | TASK_PAUSED, 16 | TASK_RUNNING, 17 | } from '../../misc/firebase-models'; 18 | import { RAFirebaseOptions } from '../options'; 19 | import { IFirebaseWrapper } from './firebase/IFirebaseWrapper'; 20 | import { IResource, ResourceManager } from './ResourceManager'; 21 | 22 | export class FireClient { 23 | public rm: ResourceManager; 24 | 25 | constructor( 26 | public fireWrapper: IFirebaseWrapper, 27 | public options: RAFirebaseOptions, 28 | public flogger: IFirestoreLogger 29 | ) { 30 | this.rm = new ResourceManager(this.fireWrapper, this.options, this.flogger); 31 | } 32 | 33 | public checkRemoveIdField(obj: any, docId: string) { 34 | if (!this.options.dontAddIdFieldToDoc) { 35 | obj.id = docId; 36 | } 37 | } 38 | 39 | public transformToDb( 40 | resourceName: string, 41 | documentData: any, 42 | docId: string 43 | ): any { 44 | if (typeof this.options.transformToDb === 'function') { 45 | return this.options.transformToDb(resourceName, documentData, docId); 46 | } 47 | return documentData; 48 | } 49 | 50 | public async parseDataAndUpload(r: IResource, id: string, data: any) { 51 | if (!data) { 52 | return data; 53 | } 54 | const docPath = doc(r.collection, id).path; 55 | 56 | const result = translateDocToFirestore(data); 57 | const uploads = result.uploads; 58 | await Promise.all( 59 | uploads.map(async (u) => { 60 | const storagePath = parseStoragePath( 61 | u.rawFile, 62 | docPath, 63 | u.fieldDotsPath, 64 | !!this.options.useFileNamesInStorage 65 | ); 66 | const link = await this.saveFile(storagePath, u.rawFile); 67 | set(data, u.fieldDotsPath + '.src', link); 68 | }) 69 | ); 70 | return data; 71 | } 72 | 73 | public async addCreatedByFields(obj: any) { 74 | return AddCreatedByFields(obj, this.fireWrapper, this.rm, this.options); 75 | } 76 | 77 | public async addUpdatedByFields(obj: any) { 78 | return AddUpdatedByFields(obj, this.fireWrapper, this.rm, this.options); 79 | } 80 | 81 | private async saveFile( 82 | storagePath: string, 83 | rawFile: any 84 | ): Promise { 85 | log('saveFile() saving file...', { storagePath, rawFile }); 86 | try { 87 | const { task, taskResult, downloadUrl } = this.fireWrapper.putFile( 88 | storagePath, 89 | rawFile 90 | ); 91 | const { name } = rawFile; 92 | // monitor upload status & progress 93 | dispatch('FILE_UPLOAD_WILL_START', name); 94 | task.on('state_changed', (snapshot) => { 95 | const progress = 96 | (snapshot.bytesTransferred / snapshot.totalBytes) * 100; 97 | log('Upload is ' + progress + '% done'); 98 | dispatch('FILE_UPLOAD_PROGRESS', name, progress); 99 | switch (snapshot.state) { 100 | case TASK_PAUSED: 101 | log('Upload is paused'); 102 | dispatch('FILE_UPLOAD_PAUSED', name); 103 | break; 104 | case TASK_RUNNING: 105 | log('Upload is running'); 106 | dispatch('FILE_UPLOAD_RUNNING', name); 107 | break; 108 | case TASK_CANCELED: 109 | log('Upload has been canceled'); 110 | dispatch('FILE_UPLOAD_CANCELED', name); 111 | break; 112 | // case storage.TaskState.ERROR: 113 | // already handled by catch 114 | // case storage.TaskState.SUCCESS: 115 | // already handled by then 116 | } 117 | }); 118 | const [getDownloadURL] = await Promise.all([downloadUrl, taskResult]); 119 | dispatch('FILE_UPLOAD_COMPLETE', name); 120 | dispatch('FILE_SAVED', name); 121 | log('saveFile() saved file', { 122 | storagePath, 123 | taskResult, 124 | getDownloadURL, 125 | }); 126 | return this.options.relativeFilePaths ? storagePath : getDownloadURL; 127 | } catch (storageError) { 128 | if (get(storageError, 'code') === 'storage/unknown') { 129 | logError( 130 | 'saveFile() error saving file, No bucket found! Try clicking "Get Started" in firebase -> storage', 131 | { storageError } 132 | ); 133 | } else { 134 | logError('saveFile() error saving file', { 135 | storageError, 136 | }); 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/providers/database/ResourceManager.ts: -------------------------------------------------------------------------------- 1 | import { doc, getDoc, getDocs } from 'firebase/firestore'; 2 | import { FireStoreCollectionRef, FireStoreQuery } from 'misc/firebase-models'; 3 | import { 4 | getAbsolutePath, 5 | IFirestoreLogger, 6 | log, 7 | logWarn, 8 | messageTypes, 9 | parseFireStoreDocument, 10 | } from '../../misc'; 11 | import { RAFirebaseOptions } from '../options'; 12 | import { IFirebaseWrapper } from './firebase/IFirebaseWrapper'; 13 | 14 | type IResourceItem = {} & { id: string; deleted?: boolean }; 15 | export interface IResource { 16 | path: string; 17 | pathAbsolute: string; 18 | collection: FireStoreCollectionRef; 19 | list: Array; 20 | } 21 | 22 | export class ResourceManager { 23 | private resources: Record = {}; 24 | 25 | constructor( 26 | private fireWrapper: IFirebaseWrapper, 27 | private options: RAFirebaseOptions, 28 | private flogger: IFirestoreLogger 29 | ) { 30 | this.fireWrapper.OnUserLogout(() => { 31 | this.resources = {}; 32 | }); 33 | } 34 | 35 | public async TryGetResource( 36 | resourceName: string, 37 | refresh?: 'REFRESH', 38 | collectionQuery?: messageTypes.CollectionQueryType 39 | ): Promise { 40 | if (refresh) { 41 | await this.RefreshResource(resourceName, collectionQuery); 42 | } 43 | return this.TryGetResourcePromise(resourceName, collectionQuery); 44 | } 45 | 46 | public GetResource(relativePath: string): IResource { 47 | const resource: IResource = this.resources[relativePath]; 48 | if (!resource) { 49 | throw new Error( 50 | `react-admin-firebase: Can't find resource: "${relativePath}"` 51 | ); 52 | } 53 | return resource; 54 | } 55 | 56 | public async TryGetResourcePromise( 57 | relativePath: string, 58 | collectionQuery?: messageTypes.CollectionQueryType 59 | ): Promise { 60 | log('resourceManager.TryGetResourcePromise', { 61 | relativePath, 62 | collectionQuery, 63 | }); 64 | await this.initPath(relativePath); 65 | 66 | const resource: IResource = this.resources[relativePath]; 67 | if (!resource) { 68 | throw new Error( 69 | `react-admin-firebase: Cant find resource: "${relativePath}"` 70 | ); 71 | } 72 | return resource; 73 | } 74 | 75 | public async RefreshResource( 76 | relativePath: string, 77 | collectionQuery: messageTypes.CollectionQueryType | undefined 78 | ) { 79 | if (this.options?.lazyLoading?.enabled) { 80 | logWarn('resourceManager.RefreshResource', { 81 | warn: 'RefreshResource is not available in lazy loading mode', 82 | }); 83 | throw new Error( 84 | 'react-admin-firebase: RefreshResource is not available in lazy loading mode' 85 | ); 86 | } 87 | 88 | log('resourceManager.RefreshResource', { relativePath, collectionQuery }); 89 | await this.initPath(relativePath); 90 | const resource = this.resources[relativePath]; 91 | 92 | const collectionRef = resource.collection; 93 | const collectionOrQuery = this.applyQuery(collectionRef, collectionQuery); 94 | const newDocs = await getDocs(collectionOrQuery); 95 | 96 | resource.list = []; 97 | newDocs.forEach((d) => 98 | resource.list.push(parseFireStoreDocument(d)) 99 | ); 100 | 101 | const count = newDocs.docs.length; 102 | this.flogger.logDocument(count)(); 103 | log('resourceManager.RefreshResource', { 104 | newDocs, 105 | resource, 106 | collectionPath: collectionRef.path, 107 | }); 108 | } 109 | 110 | public async GetSingleDoc(relativePath: string, docId: string) { 111 | await this.initPath(relativePath); 112 | const resource = this.GetResource(relativePath); 113 | this.flogger.logDocument(1)(); 114 | const docSnap = await getDoc(doc(resource.collection, docId)); 115 | if (!docSnap.exists) { 116 | throw new Error('react-admin-firebase: No id found matching: ' + docId); 117 | } 118 | const result = parseFireStoreDocument(docSnap); 119 | log('resourceManager.GetSingleDoc', { 120 | relativePath, 121 | resource, 122 | docId, 123 | docSnap, 124 | result, 125 | }); 126 | return result; 127 | } 128 | 129 | private async initPath(relativePath: string): Promise { 130 | const rootRef = this.options && this.options.rootRef; 131 | const absolutePath = getAbsolutePath(rootRef, relativePath); 132 | const hasBeenInited = !!this.resources[relativePath]; 133 | log('resourceManager.initPath()', { 134 | absolutePath, 135 | hasBeenInited, 136 | }); 137 | if (hasBeenInited) { 138 | log('resourceManager.initPath() has been initialized already...'); 139 | return; 140 | } 141 | const collection = this.fireWrapper.dbGetCollection(absolutePath); 142 | const list: Array = []; 143 | const resource: IResource = { 144 | collection, 145 | list, 146 | path: relativePath, 147 | pathAbsolute: absolutePath, 148 | }; 149 | this.resources[relativePath] = resource; 150 | log('resourceManager.initPath() setting resource...', { 151 | resource, 152 | allResources: this.resources, 153 | collection: collection, 154 | collectionPath: collection.path, 155 | }); 156 | } 157 | 158 | public async getUserIdentifier(): Promise { 159 | const identifier = this.options.associateUsersById 160 | ? await this.getCurrentUserId() 161 | : await this.getCurrentUserEmail(); 162 | return identifier; 163 | } 164 | 165 | private async getCurrentUserEmail() { 166 | const user = await this.fireWrapper.authGetUserLoggedIn(); 167 | if (user) { 168 | return user.email as string; 169 | } else { 170 | return 'annonymous user'; 171 | } 172 | } 173 | private async getCurrentUserId() { 174 | const user = await this.fireWrapper.authGetUserLoggedIn(); 175 | if (user) { 176 | return user.uid; 177 | } else { 178 | return 'annonymous user'; 179 | } 180 | } 181 | 182 | private applyQuery( 183 | collection: FireStoreCollectionRef, 184 | collectionQuery?: messageTypes.CollectionQueryType 185 | ): FireStoreCollectionRef | FireStoreQuery { 186 | const collRef = collectionQuery ? collectionQuery(collection) : collection; 187 | 188 | log('resourceManager.applyQuery() ...', { 189 | collection, 190 | collectionQuery: (collectionQuery || '-').toString(), 191 | collRef, 192 | }); 193 | return collRef; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/providers/database/firebase/FirebaseWrapper.ts: -------------------------------------------------------------------------------- 1 | import { FirebaseApp, getApp, getApps, initializeApp } from 'firebase/app'; 2 | import { 3 | browserLocalPersistence, 4 | browserSessionPersistence, 5 | getAuth, 6 | inMemoryPersistence, 7 | onAuthStateChanged, 8 | Persistence, 9 | signInWithEmailAndPassword, 10 | signOut, 11 | } from 'firebase/auth'; 12 | import { 13 | collection, 14 | doc, 15 | getFirestore, 16 | serverTimestamp as firestoreServerTimestamp, 17 | writeBatch, 18 | } from 'firebase/firestore'; 19 | import { 20 | getDownloadURL, 21 | getStorage, 22 | ref, 23 | uploadBytesResumable, 24 | } from 'firebase/storage'; 25 | import { 26 | FireApp, 27 | FireAuth, 28 | FireAuthUserCredentials, 29 | FireStorage, 30 | FireStoragePutFileResult, 31 | FireStore, 32 | FireStoreBatch, 33 | FireStoreCollectionRef, 34 | FireUploadTaskSnapshot, 35 | FireUser, 36 | } from 'misc/firebase-models'; 37 | import { log } from '../../../misc'; 38 | import { RAFirebaseOptions } from '../../options'; 39 | import { IFirebaseWrapper } from './IFirebaseWrapper'; 40 | 41 | export class FirebaseWrapper implements IFirebaseWrapper { 42 | private readonly _app: FireApp; 43 | private readonly _firestore: FireStore; 44 | private readonly _storage: FireStorage; 45 | private readonly _auth: FireAuth; 46 | public options: RAFirebaseOptions; 47 | 48 | constructor(inputOptions: RAFirebaseOptions | undefined, firebaseConfig: {}) { 49 | const optionsSafe = inputOptions || {}; 50 | this.options = optionsSafe; 51 | this._app = (window as any)['_app'] = ObtainFirebaseApp( 52 | firebaseConfig, 53 | optionsSafe 54 | ); 55 | this._firestore = getFirestore(this._app); 56 | this._storage = getStorage(this._app); 57 | this._auth = getAuth(this._app); 58 | } 59 | dbGetCollection(absolutePath: string): FireStoreCollectionRef { 60 | return collection(this._firestore, absolutePath); 61 | } 62 | dbCreateBatch(): FireStoreBatch { 63 | return writeBatch(this._firestore); 64 | } 65 | dbMakeNewId(): string { 66 | return doc(collection(this._firestore, 'collections')).id; 67 | } 68 | 69 | public OnUserLogout(callBack: (u: FireUser | null) => any) { 70 | this._auth.onAuthStateChanged((user) => { 71 | const isLoggedOut = !user; 72 | log('FirebaseWrapper.OnUserLogout', { user, isLoggedOut }); 73 | if (isLoggedOut) { 74 | callBack(user); 75 | } 76 | }); 77 | } 78 | putFile(storagePath: string, rawFile: any): FireStoragePutFileResult { 79 | const task = uploadBytesResumable(ref(this._storage, storagePath), rawFile); 80 | const taskResult = new Promise((res, rej) => 81 | task.then(res).catch(rej) 82 | ); 83 | 84 | const downloadUrl = taskResult 85 | .then((t) => getDownloadURL(t.ref)) 86 | .then((url) => url as string); 87 | 88 | return { 89 | task, 90 | taskResult, 91 | downloadUrl, 92 | }; 93 | } 94 | async getStorageDownloadUrl(fieldSrc: string): Promise { 95 | return getDownloadURL(ref(this._storage, fieldSrc)); 96 | } 97 | public serverTimestamp() { 98 | // This line doesn't work for some reason, might be firebase sdk. 99 | return firestoreServerTimestamp(); 100 | } 101 | 102 | async authSetPersistence(persistenceInput: 'session' | 'local' | 'none') { 103 | let persistenceResolved: Persistence; 104 | switch (persistenceInput) { 105 | case 'local': 106 | persistenceResolved = browserLocalPersistence; 107 | break; 108 | case 'none': 109 | persistenceResolved = inMemoryPersistence; 110 | break; 111 | case 'session': 112 | default: 113 | persistenceResolved = browserSessionPersistence; 114 | break; 115 | } 116 | 117 | log('setPersistence', { persistenceInput, persistenceResolved }); 118 | 119 | return this._auth 120 | .setPersistence(persistenceResolved) 121 | .catch((error) => console.error(error)); 122 | } 123 | async authSigninEmailPassword( 124 | email: string, 125 | password: string 126 | ): Promise { 127 | const user = await signInWithEmailAndPassword(this._auth, email, password); 128 | return user; 129 | } 130 | async authSignOut(): Promise { 131 | return signOut(this._auth); 132 | } 133 | async authGetUserLoggedIn(): Promise { 134 | return new Promise((resolve, reject) => { 135 | const auth = this._auth; 136 | if (auth.currentUser) return resolve(auth.currentUser); 137 | const unsubscribe = onAuthStateChanged(this._auth, (user) => { 138 | unsubscribe(); 139 | if (user) { 140 | resolve(user); 141 | } else { 142 | reject(); 143 | } 144 | }); 145 | }); 146 | } 147 | public async GetUserLogin(): Promise { 148 | return this.authGetUserLoggedIn(); 149 | } 150 | 151 | /** @deprecated */ 152 | public auth(): FireAuth { 153 | return this._auth; 154 | } 155 | /** @deprecated */ 156 | public storage(): FireStorage { 157 | return this._storage; 158 | } 159 | /** @deprecated */ 160 | public GetApp(): FireApp { 161 | return this._app; 162 | } 163 | /** @deprecated */ 164 | public db(): FireStore { 165 | return this._firestore; 166 | } 167 | } 168 | 169 | function ObtainFirebaseApp( 170 | firebaseConfig: {}, 171 | options: RAFirebaseOptions 172 | ): FirebaseApp { 173 | if (options.app) { 174 | return options.app; 175 | } 176 | const apps = getApps(); 177 | 178 | const isInitialized = !!apps?.length; 179 | 180 | if (isInitialized) { 181 | return getApp(); 182 | } else { 183 | return initializeApp(firebaseConfig); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/providers/database/firebase/IFirebaseWrapper.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/compat'; 2 | import { 3 | FireApp, 4 | FireAuth, 5 | FireAuthUserCredentials, 6 | FireStorage, 7 | FireStoragePutFileResult, 8 | FireStore, 9 | FireStoreBatch, 10 | FireStoreCollectionRef, 11 | FireStoreTimeStamp, 12 | FireUser, 13 | } from 'misc/firebase-models'; 14 | import { RAFirebaseOptions } from '../../options'; 15 | 16 | export interface IFirebaseWrapper { 17 | options: RAFirebaseOptions; 18 | putFile(storagePath: string, rawFile: any): FireStoragePutFileResult; 19 | getStorageDownloadUrl(fieldSrc: string): Promise; 20 | 21 | dbGetCollection(absolutePath: string): FireStoreCollectionRef; 22 | dbCreateBatch(): FireStoreBatch; 23 | dbMakeNewId(): string; 24 | 25 | OnUserLogout(cb: (user: FireUser | null) => void): void; 26 | authSetPersistence( 27 | persistenceInput: 'session' | 'local' | 'none' 28 | ): Promise; 29 | authGetUserLoggedIn(): Promise; 30 | authSigninEmailPassword( 31 | email: string, 32 | password: string 33 | ): Promise; 34 | authSignOut(): Promise; 35 | serverTimestamp(): FireStoreTimeStamp | Date; 36 | 37 | // Deprecated methods 38 | /** @deprecated */ 39 | auth(): FireAuth; 40 | /** @deprecated */ 41 | storage(): FireStorage; 42 | /** @deprecated */ 43 | db(): FireStore | firebase.firestore.Firestore; 44 | /** @deprecated */ 45 | GetApp(): FireApp; 46 | /** @deprecated */ 47 | GetUserLogin(): Promise; 48 | } 49 | -------------------------------------------------------------------------------- /src/providers/database/index.ts: -------------------------------------------------------------------------------- 1 | export * from './firebase/FirebaseWrapper'; 2 | export * from './firebase/IFirebaseWrapper'; 3 | export * from './FireClient'; 4 | export * from './ResourceManager'; 5 | -------------------------------------------------------------------------------- /src/providers/lazy-loading/FirebaseLazyLoadingClient.ts: -------------------------------------------------------------------------------- 1 | import { getCountFromServer, getDocs } from 'firebase/firestore'; 2 | import { 3 | log, 4 | messageTypes, 5 | parseFireStoreDocument, 6 | recursivelyMapStorageUrls, 7 | } from '../../misc'; 8 | import * as ra from '../../misc/react-admin-models'; 9 | import { FireClient, IResource, ResourceManager } from '../database'; 10 | import { RAFirebaseOptions } from '../options'; 11 | import { 12 | getFullParamsForQuery, 13 | getNextPageParams, 14 | paramsToQuery, 15 | } from './paramsToQuery'; 16 | import { setQueryCursor } from './queryCursors'; 17 | 18 | export class FirebaseLazyLoadingClient { 19 | constructor( 20 | private readonly options: RAFirebaseOptions, 21 | private readonly rm: ResourceManager, 22 | private client: FireClient 23 | ) {} 24 | 25 | public async apiGetList( 26 | resourceName: string, 27 | reactAdminParams: ra.GetListParams 28 | ): Promise> { 29 | const r = await this.tryGetResource(resourceName); 30 | const params = getFullParamsForQuery( 31 | reactAdminParams, 32 | !!this.options.softDelete 33 | ); 34 | 35 | log('apiGetListLazy', { resourceName, params }); 36 | 37 | const { noPagination, withPagination } = await paramsToQuery( 38 | r.collection, 39 | params, 40 | resourceName, 41 | this.client.flogger 42 | ); 43 | 44 | const snapshots = await getDocs(withPagination); 45 | 46 | const resultsCount = snapshots.docs.length; 47 | if (!resultsCount) { 48 | log('apiGetListLazy', { 49 | message: 'There are not records for given query', 50 | }); 51 | return { data: [], total: 0 }; 52 | } 53 | this.client.flogger.logDocument(resultsCount)(); 54 | 55 | // tslint:disable-next-line 56 | const data = snapshots.docs.map((d) => parseFireStoreDocument(d)); 57 | 58 | const nextPageCursor = snapshots.docs[snapshots.docs.length - 1]; 59 | // After fetching documents save queryCursor for next page 60 | setQueryCursor(nextPageCursor, getNextPageParams(params), resourceName); 61 | // Hardcoded to allow next pages, as we don't have total number of items 62 | 63 | let total = await getCountFromServer(noPagination); 64 | 65 | if (this.options.relativeFilePaths) { 66 | const parsedData = await Promise.all( 67 | data.map(async (doc: any) => { 68 | for (let fieldName in doc) { 69 | doc[fieldName] = await recursivelyMapStorageUrls( 70 | this.client.fireWrapper, 71 | doc[fieldName] 72 | ); 73 | } 74 | return doc; 75 | }) 76 | ); 77 | 78 | log('apiGetListLazy result', { 79 | docs: parsedData, 80 | resource: r, 81 | collectionPath: r.collection.path, 82 | }); 83 | 84 | return { 85 | data: parsedData, 86 | total: total.data().count, 87 | }; 88 | } 89 | 90 | log('apiGetListLazy result', { 91 | docs: data, 92 | resource: r, 93 | collectionPath: r.collection.path, 94 | }); 95 | 96 | return { data, total: total.data().count }; 97 | } 98 | 99 | public async apiGetManyReference( 100 | resourceName: string, 101 | reactAdminParams: messageTypes.IParamsGetManyReference 102 | ): Promise { 103 | const r = await this.tryGetResource(resourceName); 104 | log('apiGetManyReferenceLazy', { 105 | resourceName, 106 | resource: r, 107 | reactAdminParams, 108 | }); 109 | const filterWithTarget = { 110 | ...reactAdminParams.filter, 111 | [reactAdminParams.target]: reactAdminParams.id, 112 | }; 113 | const params = getFullParamsForQuery( 114 | { 115 | ...reactAdminParams, 116 | filter: filterWithTarget, 117 | }, 118 | !!this.options.softDelete 119 | ); 120 | 121 | const { withPagination } = await paramsToQuery( 122 | r.collection, 123 | params, 124 | resourceName, 125 | this.client.flogger 126 | ); 127 | 128 | const snapshots = await getDocs(withPagination); 129 | const resultsCount = snapshots.docs.length; 130 | this.client.flogger.logDocument(resultsCount)(); 131 | const data = snapshots.docs.map(parseFireStoreDocument); 132 | if (this.options.relativeFilePaths) { 133 | const parsedData = await Promise.all( 134 | data.map(async (doc: any) => { 135 | for (let fieldName in doc) { 136 | doc[fieldName] = await recursivelyMapStorageUrls( 137 | this.client.fireWrapper, 138 | doc[fieldName] 139 | ); 140 | } 141 | return doc; 142 | }) 143 | ); 144 | 145 | log('apiGetManyReferenceLazy result', { 146 | docs: parsedData, 147 | resource: r, 148 | collectionPath: r.collection.path, 149 | }); 150 | 151 | return { 152 | data: parsedData, 153 | total: data.length, 154 | }; 155 | } 156 | 157 | log('apiGetManyReferenceLazy result', { 158 | docs: data, 159 | resource: r, 160 | collectionPath: r.collection.path, 161 | }); 162 | return { data, total: data.length }; 163 | } 164 | 165 | private async tryGetResource( 166 | resourceName: string, 167 | collectionQuery?: messageTypes.CollectionQueryType 168 | ): Promise { 169 | return this.rm.TryGetResourcePromise(resourceName, collectionQuery); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/providers/lazy-loading/paramsToQuery.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getDocs, 3 | limit, 4 | orderBy, 5 | query, 6 | QueryConstraint, 7 | startAfter, 8 | where, 9 | } from 'firebase/firestore'; 10 | import { 11 | FireStoreCollectionRef, 12 | FireStoreQuery, 13 | FireStoreQueryOrder, 14 | } from 'misc/firebase-models'; 15 | import { IFirestoreLogger, messageTypes } from '../../misc'; 16 | import { findLastQueryCursor, getQueryCursor } from './queryCursors'; 17 | 18 | interface ParamsToQueryOptions { 19 | filters?: boolean; 20 | sort?: boolean; 21 | pagination?: boolean; 22 | } 23 | 24 | interface QueryPair { 25 | noPagination: FireStoreQuery; 26 | withPagination: FireStoreQuery; 27 | } 28 | 29 | const defaultParamsToQueryOptions = { 30 | filters: true, 31 | sort: true, 32 | pagination: true, 33 | }; 34 | 35 | export async function paramsToQuery< 36 | TParams extends messageTypes.IParamsGetList 37 | >( 38 | collection: FireStoreCollectionRef, 39 | params: TParams, 40 | resourceName: string, 41 | flogger: IFirestoreLogger, 42 | options: ParamsToQueryOptions = defaultParamsToQueryOptions 43 | ): Promise { 44 | const filterConstraints = options.filters 45 | ? getFiltersConstraints(params.filter) 46 | : []; 47 | 48 | const sortConstraints = options.sort ? getSortConstraints(params.sort) : []; 49 | 50 | const paginationConstraints = options.pagination 51 | ? await getPaginationConstraints( 52 | collection, 53 | [...filterConstraints, ...sortConstraints], 54 | params, 55 | resourceName, 56 | flogger 57 | ) 58 | : []; 59 | 60 | return { 61 | noPagination: query( 62 | collection, 63 | ...[...filterConstraints, ...sortConstraints] 64 | ), 65 | withPagination: query( 66 | collection, 67 | ...[...filterConstraints, ...sortConstraints, ...paginationConstraints] 68 | ), 69 | }; 70 | } 71 | 72 | export function getFiltersConstraints(filters: { 73 | [fieldName: string]: any; 74 | }): QueryConstraint[] { 75 | return Object.entries(filters).flatMap(([fieldName, fieldValue]) => { 76 | if (Array.isArray(fieldValue)) { 77 | return [where(fieldName, 'array-contains-any', fieldValue)]; 78 | } else if (Object.keys(filters).length === 1 && isNaN(fieldValue)) { 79 | return [ 80 | where(fieldName, '>=', fieldValue), 81 | where(fieldName, '<', fieldValue + 'z'), 82 | ]; 83 | } else { 84 | return [where(fieldName, '==', fieldValue)]; 85 | } 86 | }); 87 | } 88 | 89 | export function getSortConstraints(sort: { 90 | field: string; 91 | order: string; 92 | }): QueryConstraint[] { 93 | if (sort != null && sort.field !== 'id') { 94 | const { field, order } = sort; 95 | const parsedOrder = order.toLocaleLowerCase() as FireStoreQueryOrder; 96 | return [orderBy(field, parsedOrder)]; 97 | } 98 | return []; 99 | } 100 | 101 | async function getPaginationConstraints< 102 | TParams extends messageTypes.IParamsGetList 103 | >( 104 | collectionRef: FireStoreCollectionRef, 105 | queryConstraints: QueryConstraint[], 106 | params: TParams, 107 | resourceName: string, 108 | flogger: IFirestoreLogger 109 | ): Promise { 110 | const { page, perPage } = params.pagination; 111 | 112 | if (page === 1) { 113 | return [limit(perPage)]; 114 | } else { 115 | let queryCursor = await getQueryCursor( 116 | collectionRef, 117 | params, 118 | resourceName, 119 | flogger 120 | ); 121 | if (!queryCursor) { 122 | queryCursor = await findLastQueryCursor( 123 | collectionRef, 124 | queryConstraints, 125 | params, 126 | resourceName, 127 | flogger 128 | ); 129 | } 130 | return [startAfter(queryCursor), limit(perPage)]; 131 | } 132 | } 133 | 134 | export function getFullParamsForQuery< 135 | TParams extends messageTypes.IParamsGetList 136 | >(reactAdminParams: TParams, softdeleteEnabled: boolean): TParams { 137 | return { 138 | ...reactAdminParams, 139 | filter: softdeleteEnabled 140 | ? { 141 | deleted: false, 142 | ...reactAdminParams.filter, 143 | } 144 | : reactAdminParams.filter, 145 | }; 146 | } 147 | 148 | export function getNextPageParams( 149 | params: TParams 150 | ): TParams { 151 | return { 152 | ...params, 153 | pagination: { 154 | ...params.pagination, 155 | page: params.pagination.page + 1, 156 | }, 157 | }; 158 | } 159 | -------------------------------------------------------------------------------- /src/providers/lazy-loading/queryCursors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | doc, 3 | getDoc, 4 | getDocs, 5 | limit, 6 | query, 7 | QueryConstraint, 8 | startAfter, 9 | startAt, 10 | } from 'firebase/firestore'; 11 | import { ref } from 'firebase/storage'; 12 | import { 13 | FireStoreCollectionRef, 14 | FireStoreDocumentSnapshot, 15 | FireStoreQuery, 16 | } from 'misc/firebase-models'; 17 | import { IFirestoreLogger, messageTypes } from '../../misc'; 18 | 19 | export function setQueryCursor( 20 | document: FireStoreDocumentSnapshot, 21 | params: messageTypes.IParamsGetList, 22 | resourceName: string 23 | ) { 24 | const key = btoa(JSON.stringify({ ...params, resourceName })); 25 | localStorage.setItem(key, document.id); 26 | 27 | const allCursorsKey = `ra-firebase-cursor-keys_${resourceName}`; 28 | const localCursorKeys = localStorage.getItem(allCursorsKey); 29 | if (!localCursorKeys) { 30 | localStorage.setItem(allCursorsKey, JSON.stringify([key])); 31 | } else { 32 | const cursors: string[] = JSON.parse(localCursorKeys); 33 | const newCursors = cursors.concat(key); 34 | localStorage.setItem(allCursorsKey, JSON.stringify(newCursors)); 35 | } 36 | } 37 | 38 | export async function getQueryCursor( 39 | collection: FireStoreCollectionRef, 40 | params: messageTypes.IParamsGetList, 41 | resourceName: string, 42 | flogger: IFirestoreLogger 43 | ): Promise { 44 | const key = btoa(JSON.stringify({ ...params, resourceName })); 45 | const docId = localStorage.getItem(key); 46 | if (!docId) { 47 | return false; 48 | } 49 | 50 | const docSnapshot = await getDoc(doc(collection, docId)); 51 | flogger.logDocument(1)(); 52 | if (docSnapshot.exists()) { 53 | return docSnapshot; 54 | } 55 | return false; 56 | } 57 | 58 | export function clearQueryCursors(resourceName: string) { 59 | const allCursorsKey = `ra-firebase-cursor-keys_${resourceName}`; 60 | const localCursorKeys = localStorage.getItem(allCursorsKey); 61 | if (localCursorKeys) { 62 | const cursors: string[] = JSON.parse(localCursorKeys); 63 | cursors.forEach((cursor) => localStorage.removeItem(cursor)); 64 | localStorage.removeItem(allCursorsKey); 65 | } 66 | } 67 | 68 | export async function findLastQueryCursor( 69 | collection: FireStoreCollectionRef, 70 | queryConstraints: QueryConstraint[], 71 | params: messageTypes.IParamsGetList, 72 | resourceName: string, 73 | flogger: IFirestoreLogger 74 | ) { 75 | const { page, perPage } = params.pagination; 76 | 77 | let lastQueryCursor: FireStoreDocumentSnapshot | false = false; 78 | let currentPage = page - 1; 79 | 80 | const currentPageParams = { 81 | ...params, 82 | pagination: { 83 | ...params.pagination, 84 | }, 85 | }; 86 | while (!lastQueryCursor && currentPage > 1) { 87 | currentPage--; 88 | currentPageParams.pagination.page = currentPage; 89 | console.log('getting query cursor currentPage=', currentPage); 90 | lastQueryCursor = await getQueryCursor( 91 | collection, 92 | currentPageParams, 93 | resourceName, 94 | flogger 95 | ); 96 | } 97 | const pageLimit = (page - currentPage) * perPage; 98 | const isFirst = currentPage === 1; 99 | 100 | function getQuery() { 101 | if (isFirst) { 102 | return query(collection, ...[...queryConstraints, limit(pageLimit)]); 103 | } else { 104 | return query( 105 | collection, 106 | ...[...queryConstraints, startAfter(lastQueryCursor), limit(pageLimit)] 107 | ); 108 | } 109 | } 110 | 111 | const newQuery = getQuery(); 112 | const snapshots = await getDocs(newQuery); 113 | const docsLength = snapshots.docs.length; 114 | flogger.logDocument(docsLength)(); 115 | const lastDocIndex = docsLength - 1; 116 | const lastDocRef = snapshots.docs[lastDocIndex]; 117 | return lastDocRef; 118 | } 119 | -------------------------------------------------------------------------------- /src/providers/options.ts: -------------------------------------------------------------------------------- 1 | import { FireApp } from 'misc/firebase-models'; 2 | 3 | export interface RAFirebaseOptions { 4 | // Use a different root document to set your resource collections, 5 | // by default it uses the root collections of firestore 6 | rootRef?: string | (() => string); 7 | // Your own, previously initialized firebase app instance 8 | app?: FireApp; 9 | // Enable logging of react-admin-firebase 10 | logging?: boolean; 11 | // Resources to watch for realtime updates, 12 | // will implicitly watch all resources by default, if not set. 13 | watch?: string[]; 14 | // Resources you explicitly don't want realtime updates for 15 | dontwatch?: string[]; 16 | // Disable the metadata; 'createdate', 'lastupdate', 'createdby', 'updatedby' 17 | disableMeta?: boolean; 18 | // Have custom metadata field names instead of: 19 | // 'createdate', 'lastupdate', 'createdby', 'updatedby' 20 | renameMetaFields?: { 21 | created_at?: string; // default createdate 22 | created_by?: string; // default createdby 23 | updated_at?: string; // default lastupdate 24 | updated_by?: string; // default updatedby 25 | }; 26 | // Prevents document from getting the ID field added as a property 27 | dontAddIdFieldToDoc?: boolean; 28 | // Authentication persistence, defaults to 'session', options are 29 | // 'session' | 'local' | 'none' 30 | persistence?: 'session' | 'local' | 'none'; 31 | // Adds 'deleted' meta field for non-destructive deleting functionality 32 | // NOTE: Hides 'deleted' records from list views unless overridden by 33 | // filtering for {deleted: true} 34 | softDelete?: boolean; 35 | // Changes meta fields like 'createdby' and 'updatedby' to store user IDs instead of email addresses 36 | associateUsersById?: boolean; 37 | // Casing for meta fields like 'createdby' and 'updatedby', defaults to 'lower', options are 'lower' | 'camel' | 'snake' | 'pascal' | 'kebab' 38 | metaFieldCasing?: 'lower' | 'camel' | 'snake' | 'pascal' | 'kebab'; 39 | // Instead of saving full download url for file, save just relative path and then get download url 40 | // when getting docs - main use case is handling multiple firebase projects (environments) 41 | // and moving/copying documents/storage files between them - with relativeFilePaths, download url 42 | // always point to project own storage 43 | relativeFilePaths?: boolean; 44 | // Add file name to storage path, when set to true the file name is included in the path 45 | useFileNamesInStorage?: boolean; 46 | // Use firebase sdk queries for pagination, filtering and sorting 47 | lazyLoading?: { 48 | enabled: boolean; 49 | }; 50 | // Logging of all reads performed by app (additional feature, for lazy-loading testing) 51 | firestoreCostsLogger?: { 52 | enabled: boolean; 53 | persistCount?: boolean; 54 | }; 55 | // Function to transform documentData before they are written to Firestore 56 | transformToDb?: ( 57 | resourceName: string, 58 | documentData: any, 59 | documentId: string 60 | ) => any; 61 | /* OLD FLAGS */ 62 | /** 63 | * @deprecated The method should not be used 64 | */ 65 | overrideDefaultId?: boolean; 66 | } 67 | -------------------------------------------------------------------------------- /src/providers/queries/GetList.ts: -------------------------------------------------------------------------------- 1 | import { 2 | filterArray, 3 | log, 4 | recursivelyMapStorageUrls, 5 | sortArray, 6 | } from '../../misc'; 7 | import * as ra from '../../misc/react-admin-models'; 8 | import { FireClient } from '../database/FireClient'; 9 | import { FirebaseLazyLoadingClient } from '../lazy-loading/FirebaseLazyLoadingClient'; 10 | 11 | export async function GetList( 12 | resourceName: string, 13 | params: ra.GetListParams, 14 | client: FireClient 15 | ): Promise> { 16 | log('GetList', { resourceName, params }); 17 | const { rm, fireWrapper, options } = client; 18 | 19 | if (options?.lazyLoading?.enabled) { 20 | const lazyClient = new FirebaseLazyLoadingClient(options, rm, client); 21 | return lazyClient.apiGetList(resourceName, params); 22 | } 23 | 24 | const filterSafe = params.filter || {}; 25 | 26 | const collectionQuery = filterSafe.collectionQuery; 27 | delete filterSafe.collectionQuery; 28 | 29 | const r = await rm.TryGetResource(resourceName, 'REFRESH', collectionQuery); 30 | const data = r.list; 31 | if (params.sort != null) { 32 | const { field, order } = params.sort; 33 | if (order === 'ASC') { 34 | sortArray(data, field, 'asc'); 35 | } else { 36 | sortArray(data, field, 'desc'); 37 | } 38 | } 39 | let softDeleted = data; 40 | if (options.softDelete && !Object.keys(filterSafe).includes('deleted')) { 41 | softDeleted = data.filter((doc) => !doc.deleted); 42 | } 43 | const filteredData = filterArray(softDeleted, filterSafe); 44 | const pageStart = (params.pagination.page - 1) * params.pagination.perPage; 45 | const pageEnd = pageStart + params.pagination.perPage; 46 | const dataPage = filteredData.slice(pageStart, pageEnd) as T[]; 47 | const total = filteredData.length; 48 | 49 | if (options.relativeFilePaths) { 50 | const fetchedData = await Promise.all( 51 | dataPage.map((doc) => recursivelyMapStorageUrls(fireWrapper, doc)) 52 | ); 53 | return { 54 | data: fetchedData, 55 | total, 56 | }; 57 | } 58 | 59 | return { 60 | data: dataPage, 61 | total, 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/providers/queries/GetMany.ts: -------------------------------------------------------------------------------- 1 | import { doc, getDoc } from 'firebase/firestore'; 2 | import { log, recursivelyMapStorageUrls } from '../../misc'; 3 | import * as ra from '../../misc/react-admin-models'; 4 | import { FireClient } from '../database/FireClient'; 5 | 6 | export async function GetMany( 7 | resourceName: string, 8 | params: ra.GetManyParams, 9 | client: FireClient 10 | ): Promise> { 11 | const { rm, options, fireWrapper } = client; 12 | const r = await rm.TryGetResource(resourceName); 13 | const ids = params.ids; 14 | log('GetMany', { resourceName, resource: r, params, ids }); 15 | const matchDocSnaps = await Promise.all( 16 | ids.map((idObj) => { 17 | if (typeof idObj === 'string') { 18 | return getDoc(doc(r.collection, idObj)); 19 | } 20 | // Will get and resolve reference documents into the current doc 21 | return getDoc(doc(r.collection, (idObj as any)['___refid'])); 22 | }) 23 | ); 24 | client.flogger.logDocument(ids.length)(); 25 | const matches = matchDocSnaps.map( 26 | (snap) => ({ ...snap.data(), id: snap.id } as T) 27 | ); 28 | const permittedData = options.softDelete 29 | ? matches.filter((row) => !row['deleted']) 30 | : matches; 31 | if (options.relativeFilePaths) { 32 | const data = await Promise.all( 33 | permittedData.map((d) => recursivelyMapStorageUrls(fireWrapper, d)) 34 | ); 35 | return { 36 | data, 37 | }; 38 | } 39 | 40 | return { 41 | data: permittedData, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/providers/queries/GetManyReference.ts: -------------------------------------------------------------------------------- 1 | import { 2 | filterArray, 3 | log, 4 | recursivelyMapStorageUrls, 5 | sortArray, 6 | } from '../../misc'; 7 | import * as ra from '../../misc/react-admin-models'; 8 | import { FireClient } from '../database/FireClient'; 9 | 10 | export async function GetManyReference( 11 | resourceName: string, 12 | params: ra.GetManyReferenceParams, 13 | client: FireClient 14 | ): Promise> { 15 | const { rm, options, fireWrapper } = client; 16 | log('GetManyReference', { resourceName, params }); 17 | const filterSafe = params.filter || {}; 18 | const collectionQuery = filterSafe.collectionQuery; 19 | const r = await rm.TryGetResource(resourceName, 'REFRESH', collectionQuery); 20 | delete filterSafe.collectionQuery; 21 | log('apiGetManyReference', { resourceName, resource: r, params }); 22 | const data = r.list; 23 | const targetField = params.target; 24 | const targetValue = params.id; 25 | let softDeleted = data; 26 | if (options.softDelete) { 27 | softDeleted = data.filter((doc) => !doc['deleted']); 28 | } 29 | const filteredData = filterArray(softDeleted, filterSafe); 30 | const targetIdFilter: Record = {}; 31 | targetIdFilter[targetField] = targetValue; 32 | const permittedData = filterArray(filteredData, targetIdFilter); 33 | if (params.sort != null) { 34 | const { field, order } = params.sort; 35 | if (order === 'ASC') { 36 | sortArray(permittedData, field, 'asc'); 37 | } else { 38 | sortArray(permittedData, field, 'desc'); 39 | } 40 | } 41 | const pageStart = (params.pagination.page - 1) * params.pagination.perPage; 42 | const pageEnd = pageStart + params.pagination.perPage; 43 | const dataPage = permittedData.slice(pageStart, pageEnd) as T[]; 44 | const total = permittedData.length; 45 | 46 | if (options.relativeFilePaths) { 47 | const fetchedData = await Promise.all( 48 | permittedData.map((doc) => recursivelyMapStorageUrls(fireWrapper, doc)) 49 | ); 50 | return { data: fetchedData, total }; 51 | } 52 | 53 | return { data: dataPage, total }; 54 | } 55 | -------------------------------------------------------------------------------- /src/providers/queries/GetOne.ts: -------------------------------------------------------------------------------- 1 | import { log, translateDocFromFirestore } from '../../misc'; 2 | import * as ra from '../../misc/react-admin-models'; 3 | import { FireClient } from '../database/FireClient'; 4 | 5 | export async function GetOne( 6 | resourceName: string, 7 | params: ra.GetOneParams, 8 | client: FireClient 9 | ): Promise> { 10 | log('GetOne', { resourceName, params }); 11 | const { rm } = client; 12 | try { 13 | const id = params.id + ''; 14 | const dataSingle = await rm.GetSingleDoc(resourceName, id); 15 | client.flogger.logDocument(1)(); 16 | return { data: dataSingle as T }; 17 | } catch (error) { 18 | throw new Error( 19 | 'Error getting id: ' + params.id + ' from collection: ' + resourceName 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/providers/queries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GetList'; 2 | export * from './GetMany'; 3 | export * from './GetManyReference'; 4 | export * from './GetOne'; 5 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'csstype'; 2 | declare module '@material-ui/core/AppBar'; 3 | declare module 'react-admin-firebase'; 4 | declare module 'react-admin-firebase-lazy-loading'; 5 | 6 | declare module 'react-admin'; 7 | declare module 'ra-realtime'; 8 | 9 | declare module 'path-browserify' { 10 | import path from 'path'; 11 | export default path; 12 | } 13 | -------------------------------------------------------------------------------- /storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service firebase.storage { 3 | match /{path=**} { 4 | allow read; 5 | } 6 | // TODO: Add interesting example rules. 7 | } -------------------------------------------------------------------------------- /tests/Path.spec.ts: -------------------------------------------------------------------------------- 1 | import { getAbsolutePath } from '../src/misc'; 2 | 3 | test('path test 1', () => { 4 | const santized = getAbsolutePath('root/doc', 'coll'); 5 | expect(santized).toBe('root/doc/coll'); 6 | }); 7 | 8 | test('path test 2', () => { 9 | const santized = getAbsolutePath('/root/doc/', 'coll'); 10 | expect(santized).toBe('root/doc/coll'); 11 | }); 12 | 13 | test('path test - minimum paths', () => { 14 | const santized = getAbsolutePath('t/d', 'coll'); 15 | expect(santized).toBe('t/d/coll'); 16 | }); 17 | 18 | test('path test - test null resource', () => { 19 | const run = () => { 20 | const santized = getAbsolutePath('/root/doc/', null); 21 | }; 22 | expect(run).toThrowError(); 23 | }); 24 | 25 | test('path test - test incorrect rootRef', () => { 26 | const run = () => { 27 | const santized = getAbsolutePath('/root/doc/asdasd', 'scaascasc'); 28 | }; 29 | expect(run).toThrowError(); 30 | }); 31 | 32 | test('path test - function returns same results as string', () => { 33 | expect(getAbsolutePath(() => 'root/doc', 'col1')).toBe( 34 | getAbsolutePath('root/doc', 'col1') 35 | ); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/arrayHelpers.filtering.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | doesRowMatch, 3 | filterArray, 4 | getFieldReferences, 5 | SearchObj, 6 | } from '../src/misc'; 7 | 8 | describe('array filter', () => { 9 | test('filter array, filter empty', () => { 10 | const input = [{ name: 'Apple' }, { name: 'Pear' }, { name: 'Banana' }]; 11 | const result = filterArray(input); 12 | const expected = [{ name: 'Apple' }, { name: 'Pear' }, { name: 'Banana' }]; 13 | expect(result).toEqual(expected); 14 | }); 15 | 16 | test('filter array, filter empty obj', () => { 17 | const input = [{ name: 'Apple' }, { name: 'Pear' }, { name: 'Banana' }]; 18 | const result = filterArray(input, {}); 19 | const expected = [{ name: 'Apple' }, { name: 'Pear' }, { name: 'Banana' }]; 20 | expect(result).toEqual(expected); 21 | }); 22 | 23 | test('filters array, simple', () => { 24 | const input = [{ a: 1 }, { a: 2 }]; 25 | const expected = [{ a: 2 }]; 26 | const result = filterArray(input, { a: 2 }); 27 | expect(result).toEqual(expected); 28 | }); 29 | 30 | test('filter array, multiple', () => { 31 | const input = [ 32 | { name: 'Ben', age: 32 }, 33 | { name: 'Dale', age: 23 }, 34 | { name: 'Fred', age: 23 }, 35 | ]; 36 | const result = filterArray(input, { age: 32 }); 37 | const expected = [{ name: 'Ben', age: 32 }]; 38 | expect(result).toEqual(expected); 39 | }); 40 | 41 | test('filter array, multiple search', () => { 42 | const input = [ 43 | { name: 'Ben', age: 32 }, 44 | { name: 'Dale', age: 23 }, 45 | { name: 'Fred', age: 23 }, 46 | ]; 47 | const result = filterArray(input, { name: 'Ben', age: 32 }); 48 | const expected = [{ name: 'Ben', age: 32 }]; 49 | expect(result).toEqual(expected); 50 | }); 51 | 52 | test('filter array, filter boolean true', () => { 53 | const input = [ 54 | { name: 'Apple', enabled: false }, 55 | { name: 'Pear', enabled: false }, 56 | { name: 'Banana', enabled: true }, 57 | ]; 58 | const result = filterArray(input, { enabled: true }); 59 | const expected = [{ name: 'Banana', enabled: true }]; 60 | expect(result).toEqual(expected); 61 | }); 62 | 63 | test('filter array, filter boolean false', () => { 64 | const input = [ 65 | { name: 'Apple', enabled: false }, 66 | { name: 'Pear', enabled: false }, 67 | { name: 'Banana', enabled: true }, 68 | ]; 69 | const result = filterArray(input, { enabled: false }); 70 | const expected = [ 71 | { name: 'Apple', enabled: false }, 72 | { name: 'Pear', enabled: false }, 73 | ]; 74 | expect(result).toEqual(expected); 75 | }); 76 | 77 | test('filter array, filter boolean null', () => { 78 | const input = [ 79 | { name: 'Apple', enabled: false }, 80 | { name: 'Pear', enabled: false }, 81 | { name: 'Banana', enabled: true }, 82 | ]; 83 | const result = filterArray(input, { enabled: null }); 84 | const expected = [ 85 | { name: 'Apple', enabled: false }, 86 | { name: 'Pear', enabled: false }, 87 | ]; 88 | expect(result).toEqual(expected); 89 | }); 90 | 91 | test('doesRowMatch, partial', () => { 92 | const inputRow = { name: 'Banana', enabled: true }; 93 | const result = doesRowMatch(inputRow, 'name', 'ana'); 94 | expect(result).toEqual(true); 95 | }); 96 | 97 | test('doesRowMatch, deep object', () => { 98 | const inputRow = { name: 'Banana', deep: { enabled: 'Apple' } }; 99 | const result = doesRowMatch(inputRow, 'deep.enabled', 'Apple'); 100 | expect(result).toEqual(true); 101 | }); 102 | 103 | test(`doesRowMatch, deep object path doesn't exist`, () => { 104 | const inputRow = { name: 'Banana', deep: { enabled: 'Apple' } }; 105 | const result = doesRowMatch(inputRow, 'deep.enabled.sss', 'Apple'); 106 | expect(result).toEqual(false); 107 | }); 108 | 109 | test('getFieldReferences, simple field', () => { 110 | const res = getFieldReferences('name', 'Dan'); 111 | const expected: SearchObj[] = [{ searchField: 'name', searchValue: 'Dan' }]; 112 | expect(res).toEqual(expected); 113 | }); 114 | 115 | test('getFieldReferences, nested field', () => { 116 | const res = getFieldReferences('name', { value: 'Alex' }); 117 | const expected: SearchObj[] = [ 118 | { searchField: 'name.value', searchValue: 'Alex' }, 119 | ]; 120 | expect(res).toEqual(expected); 121 | }); 122 | 123 | test('getFieldReferences, nested field', () => { 124 | const res = getFieldReferences('customer', { details: { age: 25 } }); 125 | const expected: SearchObj[] = [ 126 | { searchField: 'customer.details.age', searchValue: 25 }, 127 | ]; 128 | expect(res).toEqual(expected); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /tests/arrayHelpers.sorting.spec.ts: -------------------------------------------------------------------------------- 1 | import { sortArray } from '../src/misc'; 2 | 3 | describe('array sort', () => { 4 | test('returns an ascending array', () => { 5 | const input = [{ a: 1 }, { a: 2 }]; 6 | const expected = [{ a: 1 }, { a: 2 }]; 7 | sortArray(input, 'a', 'asc'); 8 | expect(input).toEqual(expected); 9 | }); 10 | 11 | test('returns an ascending array - nested', () => { 12 | const input = [{ obj: { a: 2 } }, { obj: { a: 1 } }]; 13 | const expected = [{ obj: { a: 1 } }, { obj: { a: 2 } }]; 14 | sortArray(input, 'obj.a', 'asc'); 15 | expect(input).toEqual(expected); 16 | }); 17 | 18 | test('returns an descending array', () => { 19 | const input = [{ a: 1 }, { a: 2 }]; 20 | const expected = [{ a: 2 }, { a: 1 }]; 21 | sortArray(input, 'a', 'desc'); 22 | expect(input).toEqual(expected); 23 | }); 24 | 25 | test('sorting dates', () => { 26 | const input = [ 27 | { date: new Date('2019-10-13') }, 28 | { date: new Date('2019-10-12') }, 29 | { date: new Date('2019-10-20') }, 30 | ]; 31 | sortArray(input, 'date', 'asc'); 32 | const firstSortedItem = input[0]; 33 | expect(firstSortedItem).toBeTruthy(); 34 | expect(firstSortedItem.date).toBeInstanceOf(Date); 35 | expect(firstSortedItem.date).toEqual(new Date('2019-10-12')); 36 | }); 37 | 38 | test('sorting dates, with times', () => { 39 | const input = [ 40 | { date: new Date('2019-10-12, 10:20 pm') }, 41 | { date: new Date('2019-10-24, 11:20 pm') }, 42 | { date: new Date('2019-10-12, 9:20 pm') }, 43 | ]; 44 | sortArray(input, 'date', 'desc'); 45 | expect(input[0].date).toEqual(new Date('2019-10-24, 11:20 pm')); 46 | expect(input[1].date).toEqual(new Date('2019-10-12, 10:20 pm')); 47 | expect(input[2].date).toEqual(new Date('2019-10-12, 9:20 pm')); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/file-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { translateDocToFirestore } from '../src/misc'; 2 | 3 | describe('file-parser tests', () => { 4 | test('simple single file', () => { 5 | const doc = { 6 | name: 'Some guy', 7 | file: makeFile(), 8 | }; 9 | const result = translateDocToFirestore(doc); 10 | expect(result.uploads.length).toBe(1); 11 | expect(result.uploads[0].fieldDotsPath).toBe('file'); 12 | expect(doc.file.rawFile).toBeFalsy(); 13 | }); 14 | 15 | test('simple files in array', () => { 16 | const doc = { 17 | name: 'Some guy', 18 | files: [makeFile(), makeFile()], 19 | }; 20 | const result = translateDocToFirestore(doc); 21 | expect(result.uploads.length).toBe(2); 22 | expect(result.uploads[0].fieldDotsPath).toBe('files.0'); 23 | expect(doc.files[0].rawFile).toBeFalsy(); 24 | }); 25 | 26 | test('simple files in array objects', () => { 27 | const doc = { 28 | name: 'Some guy', 29 | items: [ 30 | { 31 | name: 'albert', 32 | image: makeFile(), 33 | }, 34 | { 35 | name: 'franklin', 36 | image: makeFile(), 37 | }, 38 | ], 39 | }; 40 | const result = translateDocToFirestore(doc); 41 | expect(result.uploads.length).toBe(2); 42 | expect(result.uploads[0].fieldDotsPath).toBe('items.0.image'); 43 | expect(result.uploads[0].fieldSlashesPath).toBe('items/0/image'); 44 | expect(doc.items[0].image.rawFile).toBeFalsy(); 45 | }); 46 | }); 47 | 48 | function makeFile() { 49 | return new MockFile(); 50 | } 51 | 52 | class MockFile { 53 | rawFile = 'File binary goes here'; 54 | src = 'somethign'; 55 | } 56 | -------------------------------------------------------------------------------- /tests/firestore-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/compat'; 2 | import { doc } from 'firebase/firestore'; 3 | import { 4 | FromFirestoreResult, 5 | recusivelyCheckObjectValue, 6 | translateDocFromFirestore, 7 | } from '../src/misc'; 8 | import { FireStoreDocumentRef } from '../src/misc/firebase-models'; 9 | import { FireClient } from '../src/providers/database'; 10 | import { MakeMockClient } from './integration-tests/utils/test-helpers'; 11 | 12 | function blankResultObj(): FromFirestoreResult { 13 | return { 14 | parsedDoc: {}, 15 | refdocs: [], 16 | }; 17 | } 18 | 19 | describe('timestamp-parser tests', () => { 20 | test(`null doesn't break it`, () => { 21 | const testDoc = null; 22 | translateDocFromFirestore(testDoc); 23 | expect(testDoc).toBe(null); 24 | }); 25 | 26 | test('retains falsey', () => { 27 | const testDoc = { a: null }; 28 | translateDocFromFirestore(testDoc); 29 | expect(testDoc.a).toBe(null); 30 | }); 31 | 32 | test('retains number', () => { 33 | const testDoc = { a: 1 }; 34 | translateDocFromFirestore(testDoc); 35 | expect(testDoc.a).toBe(1); 36 | }); 37 | 38 | test('retains string', () => { 39 | const testDoc = { a: '1' }; 40 | translateDocFromFirestore(testDoc); 41 | expect(testDoc.a).toBe('1'); 42 | }); 43 | 44 | test('retains object', () => { 45 | const testDoc = { a: { f: '1' } }; 46 | translateDocFromFirestore(testDoc); 47 | expect(testDoc.a.f).toBe('1'); 48 | }); 49 | 50 | test('converts timestamp simple', () => { 51 | const testDoc = { a: makeTimestamp() }; 52 | translateDocFromFirestore(testDoc); 53 | expect(testDoc.a).toBeInstanceOf(Date); 54 | }); 55 | 56 | test('converts timestamp deep nested', () => { 57 | const testDoc = { a: { b: makeTimestamp(), c: { d: makeTimestamp() } } }; 58 | translateDocFromFirestore(testDoc); 59 | expect(testDoc.a.b).toBeInstanceOf(Date); 60 | expect(testDoc.a.c.d).toBeInstanceOf(Date); 61 | }); 62 | 63 | test('converts timestamp array', () => { 64 | const testDoc = { a: { c: [makeTimestamp(), makeTimestamp()] } }; 65 | translateDocFromFirestore(testDoc); 66 | expect(testDoc.a.c[0]).toBeInstanceOf(Date); 67 | expect(testDoc.a.c[1]).toBeInstanceOf(Date); 68 | }); 69 | 70 | test('converts timestamp array', () => { 71 | const testDoc = { a: { c: [{ d: makeTimestamp() }] } }; 72 | translateDocFromFirestore(testDoc); 73 | expect(testDoc.a.c[0].d).toBeInstanceOf(Date); 74 | }); 75 | 76 | test('retains falsey', () => { 77 | const document = ['okay']; 78 | recusivelyCheckObjectValue(document, '', blankResultObj()); 79 | expect(document[0]).toBe('okay'); 80 | }); 81 | 82 | test('check converts document references', async () => { 83 | const client = await MakeMockClient(); 84 | const document = { ref: makeDocumentRef('something/here', client) } as any; 85 | const result = blankResultObj(); 86 | recusivelyCheckObjectValue(document, '', result); 87 | expect(result.refdocs.length).toBe(1); 88 | expect(document.ref).toBe('here'); 89 | }); 90 | }); 91 | 92 | function makeTimestamp() { 93 | return new MockTimeStamp(); 94 | } 95 | 96 | function makeDocumentRef( 97 | path: string, 98 | client: FireClient 99 | ): FireStoreDocumentRef | any { 100 | return doc(client.fireWrapper.db() as firebase.firestore.Firestore, path); 101 | } 102 | 103 | class MockTimeStamp { 104 | toDate() { 105 | return new Date(); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiCreate.spec.ts: -------------------------------------------------------------------------------- 1 | import { getDocs } from 'firebase/firestore'; 2 | import { Create } from '../../src/providers/commands'; 3 | import { getDocsFromCollection, MakeMockClient } from './utils/test-helpers'; 4 | 5 | describe('ApiCreate', () => { 6 | test('FireClient create doc', async () => { 7 | const client = await MakeMockClient({ 8 | logging: true, 9 | disableMeta: true, 10 | }); 11 | await Create('t1', { data: { name: 'John' } }, client); 12 | const users = await getDocsFromCollection(client.fireWrapper.db(), 't1'); 13 | expect(users.length).toBe(1); 14 | const first = users[0]; 15 | expect(first).toBeTruthy(); 16 | expect(first.name).toBe('John'); 17 | }, 100000); 18 | test('FireClient create doc with custom meta', async () => { 19 | const client = await MakeMockClient({ 20 | logging: true, 21 | renameMetaFields: { 22 | updated_by: 'MY_CREATED_BY', 23 | }, 24 | }); 25 | await Create('t1', { data: { name: 'John' } }, client); 26 | const users = await getDocsFromCollection(client.fireWrapper.db(), 't1'); 27 | expect(users.length).toBe(1); 28 | const first = users[0] as {}; 29 | 30 | expect(first.hasOwnProperty('MY_CREATED_BY')).toBeTruthy(); 31 | }, 100000); 32 | // tslint:disable-next-line:max-line-length 33 | test('FireClient create doc with transformToDb function provided', async () => { 34 | const client = await MakeMockClient({ 35 | logging: true, 36 | transformToDb: (resourceName, document, id) => { 37 | if (resourceName === 'users') { 38 | return { 39 | ...document, 40 | firstName: document.firstName.toUpperCase(), 41 | picture: document.picture.src || document.picture, 42 | }; 43 | } 44 | return document; 45 | }, 46 | }); 47 | 48 | const newUser = { 49 | firstName: 'John', 50 | lastName: 'Last', 51 | age: 20, 52 | picture: { 53 | src: 'http://example.com/pic.png', 54 | }, 55 | }; 56 | 57 | await Create('users', { data: newUser }, client); 58 | const users = await getDocs(client.fireWrapper.dbGetCollection('users')); 59 | 60 | expect(users.docs.length).toBe(1); 61 | expect(users.docs[0].data()).toMatchObject({ 62 | firstName: 'JOHN', 63 | lastName: 'Last', 64 | age: 20, 65 | picture: 'http://example.com/pic.png', 66 | }); 67 | }, 100000); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiDelete.spec.ts: -------------------------------------------------------------------------------- 1 | import { doc, setDoc } from 'firebase/firestore'; 2 | import { Delete } from '../../src/providers/commands'; 3 | import { getDocsFromCollection, MakeMockClient } from './utils/test-helpers'; 4 | 5 | describe('api methods', () => { 6 | test('FireClient delete doc', async () => { 7 | const client = await MakeMockClient(); 8 | 9 | const docId = 'test123'; 10 | const docObj = { id: docId, name: 'Jim' }; 11 | await setDoc(doc(client.fireWrapper.dbGetCollection('t2'), docId), docObj); 12 | 13 | await Delete( 14 | 't2', 15 | { 16 | id: docId, 17 | previousData: docObj, 18 | }, 19 | client 20 | ); 21 | 22 | const users = await getDocsFromCollection(client.fireWrapper.db(), 't2'); 23 | expect(users.length).toBe(0); 24 | }, 100000); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiDeleteMany.spec.ts: -------------------------------------------------------------------------------- 1 | import { doc, getDocs, setDoc } from 'firebase/firestore'; 2 | import { DeleteMany } from '../../src/providers/commands'; 3 | import { MakeMockClient } from './utils/test-helpers'; 4 | 5 | describe('api methods', () => { 6 | test('FireClient delete doc', async () => { 7 | const client = await MakeMockClient(); 8 | const docIds = ['test123', 'test22222', 'asdads']; 9 | const collName = 'deleteables'; 10 | const collection = client.fireWrapper.dbGetCollection(collName); 11 | await Promise.all( 12 | docIds.map((id) => setDoc(doc(collection, id), { title: 'ee' })) 13 | ); 14 | 15 | await DeleteMany(collName, { ids: docIds.slice(1) }, client); 16 | const res = await getDocs(collection); 17 | expect(res.docs.length).toBe(1); 18 | }, 100000); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiGetList.spec.ts: -------------------------------------------------------------------------------- 1 | import { addDoc, doc, query, setDoc, where } from 'firebase/firestore'; 2 | import { FireStoreCollectionRef } from '../../src/misc/firebase-models'; 3 | import { GetList } from '../../src/providers/queries'; 4 | import { MakeMockClient } from './utils/test-helpers'; 5 | 6 | describe('api methods', () => { 7 | test('FireClient list docs', async () => { 8 | const client = await MakeMockClient(); 9 | const docIds = ['test123', 'test22222', 'asdads']; 10 | const collName = 'list-mes'; 11 | const collection = client.fireWrapper.dbGetCollection(collName); 12 | await Promise.all( 13 | docIds.map((id) => setDoc(doc(collection, id), { title: 'ee' })) 14 | ); 15 | 16 | const result = await GetList( 17 | collName, 18 | { 19 | sort: { 20 | field: 'title', 21 | order: 'asc', 22 | }, 23 | filter: {}, 24 | pagination: { 25 | page: 1, 26 | perPage: 10, 27 | }, 28 | }, 29 | client 30 | ); 31 | expect(result.data.length).toBe(3); 32 | }, 100000); 33 | 34 | test('FireClient list docs with boolean filter', async () => { 35 | const client = await MakeMockClient(); 36 | const testDocs = [ 37 | { 38 | title: 'A', 39 | isEnabled: false, 40 | }, 41 | { 42 | title: 'B', 43 | isEnabled: true, 44 | }, 45 | { 46 | title: 'C', 47 | isEnabled: false, 48 | }, 49 | ]; 50 | const collName = 'list-filtered'; 51 | const collection = client.fireWrapper.dbGetCollection(collName); 52 | await Promise.all(testDocs.map((document) => addDoc(collection, document))); 53 | 54 | const result = await GetList( 55 | collName, 56 | { 57 | sort: { 58 | field: 'title', 59 | order: 'asc', 60 | }, 61 | pagination: { 62 | page: 1, 63 | perPage: 10, 64 | }, 65 | filter: { 66 | isEnabled: false, 67 | }, 68 | }, 69 | client 70 | ); 71 | expect(result.data.length).toBe(2); 72 | }, 100000); 73 | 74 | test('FireClient list docs with dotpath sort', async () => { 75 | const client = await MakeMockClient(); 76 | const testDocs = [ 77 | { 78 | obj: { 79 | title: 'A', 80 | }, 81 | isEnabled: false, 82 | }, 83 | { 84 | obj: { 85 | title: 'C', 86 | }, 87 | isEnabled: false, 88 | }, 89 | { 90 | obj: { 91 | title: 'B', 92 | }, 93 | isEnabled: true, 94 | }, 95 | ]; 96 | const collName = 'list-filtered'; 97 | const collection = client.fireWrapper.dbGetCollection(collName); 98 | await Promise.all(testDocs.map((document) => addDoc(collection, document))); 99 | 100 | const result = await GetList( 101 | collName, 102 | { 103 | filter: {}, 104 | pagination: { 105 | page: 1, 106 | perPage: 10, 107 | }, 108 | sort: { 109 | field: 'obj.title', 110 | order: 'ASC', 111 | }, 112 | }, 113 | client 114 | ); 115 | const second = result.data[1]; 116 | expect(second).toBeTruthy(); 117 | expect(second.obj.title).toBe('B'); 118 | }, 100000); 119 | 120 | test('FireClient with filter gte', async () => { 121 | const client = await MakeMockClient(); 122 | const testDocs = [ 123 | { 124 | title: 'A', 125 | obj: { volume: 100 }, 126 | }, 127 | { 128 | title: 'B', 129 | obj: { volume: 101 }, 130 | }, 131 | { 132 | title: 'C', 133 | obj: { volume: 99 }, 134 | }, 135 | ]; 136 | const collName = 'list-filtered'; 137 | const collection = client.fireWrapper.dbGetCollection(collName); 138 | await Promise.all(testDocs.map((document) => addDoc(collection, document))); 139 | 140 | const result = await GetList( 141 | collName, 142 | { 143 | filter: { 144 | collectionQuery: (c: FireStoreCollectionRef) => 145 | query(c, where('obj.volume', '>=', 100)), 146 | }, 147 | pagination: { 148 | page: 1, 149 | perPage: 10, 150 | }, 151 | sort: { 152 | field: 'obj.volume', 153 | order: 'ASC', 154 | }, 155 | }, 156 | client 157 | ); 158 | const third = result.data.length; 159 | expect(third).toBe(2); 160 | }, 100000); 161 | }); 162 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiGetMany.spec.ts: -------------------------------------------------------------------------------- 1 | import { doc, setDoc } from 'firebase/firestore'; 2 | import { FireStore } from '../../src/misc/firebase-models'; 3 | import { GetMany } from '../../src/providers/queries'; 4 | import { MakeMockClient } from './utils/test-helpers'; 5 | 6 | describe('api methods', () => { 7 | test('FireClient list docs', async () => { 8 | const client = await MakeMockClient(); 9 | const docIds = ['test123', 'test22222', 'asdads']; 10 | const collName = 'list-mes'; 11 | const collection = client.fireWrapper.dbGetCollection(collName); 12 | await Promise.all( 13 | docIds.map((id) => setDoc(doc(collection, id), { title: 'ee' })) 14 | ); 15 | 16 | const result = await GetMany( 17 | collName, 18 | { 19 | ids: docIds.slice(1), 20 | }, 21 | client 22 | ); 23 | expect(result.data.length).toBe(2); 24 | expect(result.data[0]['id']).toBe('test22222'); 25 | expect(result.data[1]['id']).toBe('asdads'); 26 | }, 100000); 27 | 28 | test('FirebaseClient list docs from refs', async () => { 29 | const client = await MakeMockClient(); 30 | const docs = [ 31 | { 32 | id: '11', 33 | name: 'Albert', 34 | }, 35 | { 36 | id: '22', 37 | name: 'Stanburg', 38 | }, 39 | ]; 40 | const collName = 'list-mes2'; 41 | const db = client.fireWrapper.db(); 42 | await Promise.all( 43 | docs.map((user) => 44 | setDoc(doc(db as FireStore, collName + '/' + user.id), { 45 | name: user.name, 46 | }) 47 | ) 48 | ); 49 | 50 | const result = await GetMany( 51 | collName, 52 | { 53 | ids: ['11', '22'], 54 | }, 55 | client 56 | ); 57 | expect(result.data.length).toBe(2); 58 | expect(result.data[0]['id']).toBe('11'); 59 | expect(result.data[1]['id']).toBe('22'); 60 | }, 100000); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiGetOne.spec.ts: -------------------------------------------------------------------------------- 1 | import { collection, doc, setDoc } from 'firebase/firestore'; 2 | import { REF_INDENTIFIER } from '../../src/misc'; 3 | import { FireStore } from '../../src/misc/firebase-models'; 4 | import { GetOne } from '../../src/providers/queries'; 5 | import { MakeMockClient } from './utils/test-helpers'; 6 | 7 | describe('api methods', () => { 8 | test('FireClient apiGetOne', async () => { 9 | const client = await MakeMockClient(); 10 | const docIds = ['test123', 'test22222', 'asdads']; 11 | const collName = 'list-mes'; 12 | const collectionRef = client.fireWrapper.dbGetCollection(collName); 13 | await Promise.all( 14 | docIds.map((id) => setDoc(doc(collectionRef, id), { title: 'ee' })) 15 | ); 16 | type D = { title: string; id: string }; 17 | const result = await GetOne(collName, { id: 'test22222' }, client); 18 | expect(result.data).toBeTruthy(); 19 | expect(result.data.title).toBe('ee'); 20 | expect(result.data.id).toBe('test22222'); 21 | }, 100000); 22 | 23 | test('FireClient apiGetOne, with nested Dates', async () => { 24 | const client = await MakeMockClient(); 25 | const collName = 'list-mes'; 26 | const docId = '1234'; 27 | const collectionRef = client.fireWrapper.dbGetCollection(collName); 28 | const testDocNestedDates = { 29 | a: new Date('1999'), 30 | b: { 31 | b1: new Date('2006'), 32 | c: { 33 | c1: new Date('2006'), 34 | }, 35 | }, 36 | }; 37 | await setDoc(doc(collectionRef, docId), testDocNestedDates); 38 | 39 | const result = await GetOne( 40 | collName, 41 | { 42 | id: docId, 43 | }, 44 | client 45 | ); 46 | const data = result.data; 47 | expect(data).toBeTruthy(); 48 | expect(data.a).toBeInstanceOf(Date); 49 | expect(data.b.b1).toBeInstanceOf(Date); 50 | expect(data.b.c.c1).toBeInstanceOf(Date); 51 | }, 100000); 52 | 53 | test('FireClient apiGetOne, with refdocument', async () => { 54 | const client = await MakeMockClient(); 55 | const collName = 'get-one'; 56 | const docId = '12345'; 57 | const collectionRef = collection( 58 | client.fireWrapper.db() as FireStore, 59 | collName 60 | ); 61 | const refId = '22222'; 62 | const refFullPath = 'ascasc/' + refId; 63 | const testData = { 64 | myrefdoc: doc(client.fireWrapper.db() as FireStore, refFullPath), 65 | }; 66 | await setDoc(doc(collectionRef, docId), testData); 67 | 68 | const result = await GetOne(collName, { id: docId }, client); 69 | const data = result.data as any; 70 | expect(data).toBeTruthy(); 71 | expect(data['myrefdoc']).toBe(refId); 72 | expect(data[`${REF_INDENTIFIER}myrefdoc`]).toBe(refFullPath); 73 | }, 100000); 74 | }); 75 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiRootRef.spec.ts: -------------------------------------------------------------------------------- 1 | import { addDoc, deleteDoc, getDocs } from 'firebase/firestore'; 2 | import { MakeMockClient } from './utils/test-helpers'; 3 | 4 | describe('ApiRootRef', () => { 5 | test('rootref1', async () => { 6 | const client = await MakeMockClient({ rootRef: 'root-ref1/ok' }); 7 | const rm = client.rm; 8 | const docRef = await addDoc( 9 | client.fireWrapper.dbGetCollection('root-ref1/ok/t1'), 10 | { test: '' } 11 | ); 12 | const r = await rm.TryGetResourcePromise('t1'); 13 | const snap = await getDocs(r.collection); 14 | await deleteDoc(docRef); 15 | expect(snap.docs.length).toBe(1); 16 | }, 10000); 17 | 18 | test('rootreffunction1', async () => { 19 | const client = await MakeMockClient({ rootRef: 'root-ref-function1/ok' }); 20 | const rm = client.rm; 21 | const docRef = await addDoc( 22 | client.fireWrapper.dbGetCollection('root-ref-function1/ok/t1'), 23 | { test: '' } 24 | ); 25 | const r = await rm.TryGetResourcePromise('t1'); 26 | const snap = await getDocs(r.collection); 27 | await deleteDoc(docRef); 28 | expect(snap.docs.length).toBe(1); 29 | }, 10000); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiSoftDelete.spec.ts: -------------------------------------------------------------------------------- 1 | import { doc, getDoc, setDoc } from 'firebase/firestore'; 2 | import { DeleteSoft } from '../../src/providers/commands'; 3 | import { MakeMockClient } from './utils/test-helpers'; 4 | 5 | describe('api methods', () => { 6 | test('FireClient delete doc', async () => { 7 | const client = await MakeMockClient({ 8 | softDelete: true, 9 | disableMeta: true, 10 | }); 11 | const id = 'test123'; 12 | const collName = 't2'; 13 | const docRef = doc(client.fireWrapper.dbGetCollection(collName), id); 14 | const docObj = { id, name: 'Jim' }; 15 | await setDoc(docRef, docObj); 16 | 17 | await DeleteSoft(collName, { id: id, previousData: docObj }, client); 18 | 19 | const res = await getDoc(docRef); 20 | expect(res.exists).toBeTruthy(); 21 | expect(res.get('deleted')).toBeTruthy(); 22 | }, 100000); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiSoftDeleteMany.spec.ts: -------------------------------------------------------------------------------- 1 | import { doc, getDocs, setDoc } from 'firebase/firestore'; 2 | import { DeleteManySoft } from '../../src/providers/commands'; 3 | import { MakeMockClient } from './utils/test-helpers'; 4 | 5 | describe('api methods', () => { 6 | test('FireClient delete doc', async () => { 7 | const client = await MakeMockClient({ 8 | softDelete: true, 9 | disableMeta: true, 10 | }); 11 | const docIds = ['test123', 'test22222', 'asdads']; 12 | const collName = 'deleteables'; 13 | const collection = client.fireWrapper.dbGetCollection(collName); 14 | await Promise.all( 15 | docIds.map((id) => setDoc(doc(collection, id), { title: 'ee' })) 16 | ); 17 | 18 | await DeleteManySoft(collName, { ids: docIds.slice(1) }, client); 19 | const res = await getDocs(collection); 20 | expect(res.docs.length).toBe(3); 21 | }, 100000); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiUpdate.spec.ts: -------------------------------------------------------------------------------- 1 | import { doc, getDoc, setDoc } from 'firebase/firestore'; 2 | import { Update } from '../../src/providers/commands'; 3 | import { MakeMockClient } from './utils/test-helpers'; 4 | 5 | describe('api methods', () => { 6 | test('FireClient update doc', async () => { 7 | const client = await MakeMockClient({ disableMeta: true }); 8 | const id = 'testsss123'; 9 | const collName = 't2'; 10 | const docRef = doc(client.fireWrapper.dbGetCollection(collName), id); 11 | await setDoc(docRef, { name: 'Jim' }); 12 | 13 | await Update( 14 | collName, 15 | { 16 | id: id, 17 | data: { id: id, title: 'asd' }, 18 | previousData: { id: id, name: 'Jim' }, 19 | }, 20 | client 21 | ); 22 | 23 | const res = await getDoc(docRef); 24 | expect(res.exists).toBeTruthy(); 25 | expect(res.get('title')).toBe('asd'); 26 | }, 100000); 27 | 28 | // tslint:disable-next-line:max-line-length 29 | test('FireClient update doc with transformToDb function provided', async () => { 30 | const client = await MakeMockClient({ 31 | transformToDb: (resourceName, document) => { 32 | if (resourceName === 'users') { 33 | return { 34 | ...document, 35 | firstName: document.firstName.toUpperCase(), 36 | }; 37 | } 38 | return document; 39 | }, 40 | }); 41 | 42 | const id = 'user123'; 43 | const docRef = doc(client.fireWrapper.dbGetCollection('users'), id); 44 | await setDoc(docRef, { name: 'Jim' }); 45 | 46 | const previousUser = { 47 | id, 48 | firstName: 'Bob', 49 | lastName: 'Last', 50 | }; 51 | const user = { 52 | ...previousUser, 53 | firstName: 'John', 54 | }; 55 | 56 | await Update( 57 | 'users', 58 | { 59 | id: id, 60 | data: user, 61 | previousData: previousUser, 62 | }, 63 | client 64 | ); 65 | 66 | const res = await getDoc(docRef); 67 | expect(res.exists).toBeTruthy(); 68 | expect(res.data()).toMatchObject({ 69 | firstName: 'JOHN', 70 | lastName: 'Last', 71 | }); 72 | }, 100000); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/integration-tests/ApiUpdateMany.spec.ts: -------------------------------------------------------------------------------- 1 | import { doc, getDocs, setDoc } from 'firebase/firestore'; 2 | import { UpdateMany } from '../../src/providers/commands'; 3 | import { MakeMockClient } from './utils/test-helpers'; 4 | 5 | describe('api methods', () => { 6 | test('FireClient updatemany doc', async () => { 7 | const client = await MakeMockClient({ 8 | softDelete: true, 9 | disableMeta: true, 10 | }); 11 | const docIds = ['test123', 'test22222', 'asdads']; 12 | const collName = 'updatec'; 13 | const collection = client.fireWrapper.dbGetCollection(collName); 14 | await Promise.all( 15 | docIds.map((id) => setDoc(doc(collection, id), { title: 'ee' })) 16 | ); 17 | 18 | await UpdateMany( 19 | collName, 20 | { ids: docIds, data: { title: 'blue' } }, 21 | client 22 | ); 23 | const res = await getDocs(collection); 24 | expect(res.docs.length).toBe(3); 25 | expect(res.docs[0].get('title')).toBe('blue'); 26 | expect(res.docs[1].get('title')).toBe('blue'); 27 | expect(res.docs[2].get('title')).toBe('blue'); 28 | }, 100000); 29 | 30 | // tslint:disable-next-line:max-line-length 31 | test('FireClient updatemany doc with transformToDb function provided', async () => { 32 | const collectionName = 'updatec'; 33 | 34 | const client = await MakeMockClient({ 35 | transformToDb: (resourceName, document, id) => { 36 | if (resourceName === collectionName) { 37 | return { 38 | ...document, 39 | title: document.title.toUpperCase(), 40 | }; 41 | } 42 | return document; 43 | }, 44 | }); 45 | 46 | const docIds = ['test123', 'test22222', 'asdads']; 47 | const collection = client.fireWrapper.dbGetCollection(collectionName); 48 | 49 | const originalDoc = { 50 | title: 'red', 51 | body: 'Hello world...', 52 | }; 53 | const updatedDoc = { 54 | title: 'blue', 55 | body: 'OK...', 56 | }; 57 | 58 | await Promise.all( 59 | docIds.map((id) => setDoc(doc(collection, id), originalDoc)) 60 | ); 61 | 62 | await UpdateMany(collectionName, { ids: docIds, data: updatedDoc }, client); 63 | 64 | const res = await getDocs(collection); 65 | expect(res.docs.length).toBe(3); 66 | expect(res.docs[0].get('title')).toBe('BLUE'); 67 | expect(res.docs[1].get('title')).toBe('BLUE'); 68 | expect(res.docs[2].get('title')).toBe('BLUE'); 69 | }, 100000); 70 | }); 71 | -------------------------------------------------------------------------------- /tests/integration-tests/utils/FirebaseWrapperStub.ts: -------------------------------------------------------------------------------- 1 | import { getAuth } from 'firebase/auth'; 2 | import { collection, doc, writeBatch } from 'firebase/firestore'; 3 | import { getDownloadURL, ref, uploadBytesResumable } from 'firebase/storage'; 4 | import { RAFirebaseOptions } from '../../../src'; 5 | import { 6 | FireApp, 7 | FireAuth, 8 | FireAuthUserCredentials, 9 | FireStorage, 10 | FireStoragePutFileResult, 11 | FireStore, 12 | FireStoreBatch, 13 | FireStoreCollectionRef, 14 | FireUploadTaskSnapshot, 15 | FireUser, 16 | } from '../../../src/misc/firebase-models'; 17 | import { IFirebaseWrapper } from '../../../src/providers/database'; 18 | 19 | export class FirebaseWrapperStub implements IFirebaseWrapper { 20 | constructor( 21 | private _firestore: FireStore | any, 22 | private _storage: FireStorage, 23 | public options: RAFirebaseOptions 24 | ) {} 25 | 26 | GetApp(): FireApp { 27 | throw new Error('Method not implemented.'); 28 | } 29 | 30 | dbGetCollection(absolutePath: string): FireStoreCollectionRef { 31 | return collection(this._firestore, absolutePath); 32 | } 33 | dbCreateBatch(): FireStoreBatch { 34 | return writeBatch(this._firestore); 35 | } 36 | dbMakeNewId(): string { 37 | return doc(collection(this._firestore, 'collections')).id; 38 | } 39 | 40 | // tslint:disable-next-line:no-empty 41 | public OnUserLogout(callBack: (u: FireUser) => any) {} 42 | putFile: any = async ( 43 | storagePath: string, 44 | rawFile: any 45 | ): Promise => { 46 | const task = uploadBytesResumable(ref(this._storage, storagePath), rawFile); 47 | const taskResult = new Promise((res, rej) => 48 | task.then(res).catch(rej) 49 | ); 50 | const downloadUrl = taskResult 51 | .then((t) => getDownloadURL(t.ref)) 52 | .then((url) => url as string); 53 | return { 54 | task, 55 | taskResult, 56 | downloadUrl, 57 | }; 58 | }; 59 | async getStorageDownloadUrl(fieldSrc: string): Promise { 60 | return getDownloadURL(ref(this._storage, fieldSrc)); 61 | } 62 | authSetPersistence( 63 | persistenceInput: 'session' | 'local' | 'none' 64 | ): Promise { 65 | throw new Error('Method not implemented.'); 66 | } 67 | authGetUserLoggedIn(): Promise { 68 | return { uid: 'alice', email: 'alice@test.com' } as any; 69 | } 70 | authSigninEmailPassword( 71 | email: string, 72 | password: string 73 | ): Promise { 74 | throw new Error('Method not implemented.'); 75 | } 76 | authSignOut(): Promise { 77 | throw new Error('Method not implemented.'); 78 | } 79 | serverTimestamp() { 80 | return new Date(); 81 | } 82 | 83 | // Deprecated methods 84 | 85 | /** @deprecated */ 86 | auth(): FireAuth { 87 | return getAuth(this.GetApp()); 88 | } 89 | /** @deprecated */ 90 | storage(): FireStorage { 91 | return this._storage; 92 | } 93 | /** @deprecated */ 94 | db(): FireStore { 95 | return this._firestore; 96 | } 97 | /** @deprecated */ 98 | GetUserLogin(): Promise { 99 | return this.authGetUserLoggedIn(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/integration-tests/utils/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { initializeTestEnvironment } from '@firebase/rules-unit-testing'; 2 | import firebase from 'firebase/compat'; 3 | import { collection, doc, getDocs, setDoc } from 'firebase/firestore'; 4 | import { RAFirebaseOptions } from '../../../src'; 5 | import { IFirestoreLogger } from '../../../src/misc'; 6 | import { FireStore } from '../../../src/misc/firebase-models'; 7 | import { FireClient, IFirebaseWrapper } from '../../../src/providers/database'; 8 | import { FirebaseWrapperStub } from './FirebaseWrapperStub'; 9 | 10 | function makeSafeId(projectId: string): string { 11 | return projectId.split(' ').join('').toLowerCase(); 12 | } 13 | 14 | export class BlankLogger implements IFirestoreLogger { 15 | logDocument = (count: number) => () => null; 16 | SetEnabled = (isEnabled: boolean) => null; 17 | ResetCount = (shouldReset: boolean) => null; 18 | } 19 | 20 | export async function MakeMockClient(options: RAFirebaseOptions = {}) { 21 | const randomProjectId = Math.random().toString(32).slice(2, 10); 22 | const fire = await initFireWrapper(randomProjectId, options); 23 | return new FireClient(fire, options, new BlankLogger()); 24 | } 25 | 26 | export async function initFireWrapper( 27 | projectId: string, 28 | rafOptions: RAFirebaseOptions = {} 29 | ): Promise { 30 | const safeId = makeSafeId(projectId); 31 | const testOptions = { 32 | projectId: safeId, 33 | firestore: { host: 'localhost', port: 8080 }, 34 | }; 35 | const environment = await initializeTestEnvironment(testOptions); 36 | const context = environment.unauthenticatedContext(); 37 | return new FirebaseWrapperStub( 38 | // Slight (inconsequential) mismatch between test API and actual API 39 | context.firestore(), 40 | context.storage(), 41 | rafOptions 42 | ); 43 | } 44 | 45 | export const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); 46 | 47 | export async function createDoc( 48 | db: FireStore, 49 | collectionName: string, 50 | docName: string, 51 | obj: {} 52 | ): Promise { 53 | await setDoc(doc(collection(db, collectionName), docName), obj); 54 | } 55 | 56 | export async function getDocsFromCollection( 57 | db: FireStore | firebase.firestore.Firestore, 58 | collectionName: string 59 | ): Promise { 60 | const allDocs = await getDocs(collection(db as FireStore, collectionName)); 61 | return Promise.all( 62 | allDocs.docs.map((d) => ({ 63 | ...d.data(), 64 | id: d.id, 65 | })) 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /tests/objectFlatten.spec.ts: -------------------------------------------------------------------------------- 1 | import { objectFlatten, SearchObj } from '../src/misc'; 2 | 3 | describe('objectFlatten tests', () => { 4 | test('objectFlatten level 1', () => { 5 | const obj = { 6 | a: 9, 7 | }; 8 | const res = objectFlatten(obj); 9 | const expected: SearchObj[] = [ 10 | { 11 | searchField: 'a', 12 | searchValue: 9, 13 | }, 14 | ]; 15 | expect(res).toEqual(expected); 16 | }); 17 | 18 | test('objectFlatten level 2', () => { 19 | const obj = { 20 | a: 9, 21 | apple: { 22 | value: 1, 23 | }, 24 | }; 25 | const res = objectFlatten(obj); 26 | const expected: SearchObj[] = [ 27 | { 28 | searchField: 'a', 29 | searchValue: 9, 30 | }, 31 | { 32 | searchField: 'apple.value', 33 | searchValue: 1, 34 | }, 35 | ]; 36 | expect(res).toEqual(expected); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/providers/lazy-loading/paramsToQuery.spec.ts: -------------------------------------------------------------------------------- 1 | import { QueryConstraintType } from '@firebase/firestore'; 2 | import { getFiltersConstraints } from '../../../src/providers/lazy-loading/paramsToQuery'; 3 | 4 | describe('getFiltersConstraints', () => { 5 | it('should return where filter with array-contains-any operator when filter value is array', () => { 6 | const filters = { fieldA: ['valueA'] }; 7 | 8 | const result = getFiltersConstraints(filters); 9 | 10 | expect(result.length).toEqual(1); 11 | const queryConstraint = result[0]; 12 | expect(queryConstraint.type).toEqual('where' as QueryConstraintType); 13 | // @ts-ignore 14 | expect(queryConstraint['_op']).toEqual('array-contains-any'); 15 | }); 16 | 17 | it('should return two where filters when filter value is string', () => { 18 | const filters = { fieldA: 'valueA' }; 19 | 20 | const result = getFiltersConstraints(filters); 21 | 22 | expect(result.length).toEqual(2); 23 | const queryConstraintGte = result[0]; 24 | const queryConstraintLt = result[1]; 25 | expect(queryConstraintGte.type).toEqual('where' as QueryConstraintType); 26 | expect(queryConstraintLt.type).toEqual('where' as QueryConstraintType); 27 | // @ts-ignore 28 | expect(queryConstraintGte['_op']).toEqual('>='); 29 | // @ts-ignore 30 | expect(queryConstraintLt['_op']).toEqual('<'); 31 | }); 32 | 33 | it('should return where filter with == operator when field value is number', () => { 34 | const filters = { fieldA: 1 }; 35 | 36 | const result = getFiltersConstraints(filters); 37 | 38 | expect(result.length).toEqual(1); 39 | const queryConstraint = result[0]; 40 | expect(queryConstraint.type).toEqual('where' as QueryConstraintType); 41 | // @ts-ignore 42 | expect(queryConstraint['_op']).toEqual('=='); 43 | }); 44 | 45 | it('should return where filter with == operator when field value is boolean', () => { 46 | const filters = { fieldA: false }; 47 | 48 | const result = getFiltersConstraints(filters); 49 | 50 | expect(result.length).toEqual(1); 51 | const queryConstraint = result[0]; 52 | expect(queryConstraint.type).toEqual('where' as QueryConstraintType); 53 | // @ts-ignore 54 | expect(queryConstraint['_op']).toEqual('=='); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/reference-document-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | applyRefDocs, 3 | RefDocFound, 4 | REF_INDENTIFIER, 5 | translateDocFromFirestore, 6 | translateDocToFirestore, 7 | } from '../src/misc'; 8 | import { FireStoreDocumentRef } from '../src/misc/firebase-models'; 9 | 10 | describe('reference-document-parser.spec', () => { 11 | test('test translateDocToFirestore', () => { 12 | const dataToCreate = { 13 | name: 'Some guy', 14 | items: [ 15 | { 16 | user: 'dan', 17 | friend: 'ref', 18 | ___REF_FULLPATH_friend: 'my/ref', 19 | }, 20 | ], 21 | }; 22 | const result = translateDocToFirestore(dataToCreate); 23 | expect(result.refdocs.length).toBe(1); 24 | expect(result.refdocs[0].fieldDotsPath).toBe( 25 | 'items.0.___REF_FULLPATH_friend' 26 | ); 27 | expect(result.refdocs[0].refPath).toBe('my/ref'); 28 | }); 29 | 30 | test('test translateDocFromFirestore', () => { 31 | const refDocPath = 'fake/doc/path'; 32 | const dataFromDb = { 33 | myrefdoc: makeFakeRefDoc(refDocPath), 34 | }; 35 | const result = translateDocFromFirestore(dataFromDb); 36 | expect(result.refdocs.length).toBe(1); 37 | expect(result.refdocs[0].fieldPath).toBe('myrefdoc'); 38 | expect(result.refdocs[0].refDocPath).toBe(refDocPath); 39 | }); 40 | 41 | describe('applyRefDocs', () => { 42 | test('keeps existing fields', () => { 43 | const doc = { 44 | somefield: 'okay', 45 | }; 46 | const result = applyRefDocs(doc, [makeRefDocFound('doc1', 'my/doc')]); 47 | expect(result.somefield).toBe('okay'); 48 | }); 49 | test('adds refdoc field', () => { 50 | const doc = { 51 | somefield: 'okay', 52 | }; 53 | const result = applyRefDocs(doc, [makeRefDocFound('doc1', 'my/doc')]); 54 | expect(result[REF_INDENTIFIER + 'doc1']).toBe('my/doc'); 55 | }); 56 | }); 57 | }); 58 | 59 | function makeRefDocFound(fieldPath: string, refDocPath: string): RefDocFound { 60 | return { 61 | fieldPath, 62 | refDocPath, 63 | }; 64 | } 65 | 66 | function makeFakeRefDoc(docPath: string): FireStoreDocumentRef { 67 | return { 68 | id: docPath.split('/').pop(), 69 | firestore: {} as any, 70 | parent: {} as any, 71 | path: docPath, 72 | } as any as FireStoreDocumentRef; 73 | } 74 | -------------------------------------------------------------------------------- /tests/storagePath.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseStoragePath } from '../src/misc'; 2 | 3 | test('useFileNamesInStorage: false', () => { 4 | const mockFile = new File([], 'test.png'); 5 | const docPath = 'resource/id'; 6 | const fieldPath = 'picture'; 7 | 8 | const path = parseStoragePath(mockFile, docPath, fieldPath, false); 9 | expect(path).toBe('resource/id/picture.png'); 10 | }); 11 | 12 | test('useFileNamesInStorage: true', () => { 13 | const mockFile = new File([], 'test.png'); 14 | const docPath = 'resource/id'; 15 | const fieldPath = 'picture'; 16 | 17 | const path = parseStoragePath(mockFile, docPath, fieldPath, true); 18 | expect(path).toBe('resource/id/picture/test.png'); 19 | }); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "target": "es5", 7 | "lib": ["es6", "es2019", "dom"], 8 | "baseUrl": "src", 9 | "strict": true, 10 | "preserveConstEnums": true, 11 | "sourceMap": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": false, 14 | "skipLibCheck": true, 15 | "noImplicitAny": true, 16 | "esModuleInterop": true, 17 | "typeRoots": ["node_modules/@types", "src/types.d.ts"] 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_modules", "dist"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "typeRoots": ["node_modules/@types", "src/types.d.ts"] 5 | }, 6 | "include": ["src", "tests/**/*.ts"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "linterOptions": { 3 | "exclude": [ 4 | "config/**/*.js", 5 | "node_modules/**/*.ts", 6 | "coverage/lcov-report/*.js" 7 | ] 8 | }, 9 | "extends": ["tslint-config-prettier"], 10 | "rules": { 11 | "no-console": false, 12 | "ordered-imports": false, 13 | "no-string-literal": false, 14 | "no-empty": true, 15 | "object-literal-sort-keys": false, 16 | "no-floating-promises": true, 17 | "trailing-comma": [ 18 | true, 19 | { 20 | "multiline": { 21 | "objects": "always", 22 | "arrays": "always", 23 | "functions": "never", 24 | "typeLiterals": "ignore" 25 | }, 26 | "esSpecCompliant": true 27 | } 28 | ], 29 | "interface-name": false, 30 | "whitespace": [true, "check-module"], 31 | "max-line-length": [ 32 | 80, 33 | { 34 | "ignore": ["non-comments"], 35 | "ignorePattern": [ 36 | "/(https?://([-\\w\\.]+)+(:\\d+)?(/([\\w/_\\.]*(\\?\\S+)?)?)?)/" 37 | ] 38 | } 39 | ], 40 | "no-trailing-whitespace": [ 41 | true, 42 | "ignore-comments", 43 | "ignore-blank-lines", 44 | "ignore-jsdoc", 45 | "ignore-template-strings" 46 | ], 47 | "no-unsafe-any": true, 48 | "no-unused-variable": [true, "check-parameters"], 49 | "no-unused-expression": [true, "allow-fast-null-checks"], 50 | "arrow-return-shorthand": [true, "multiline"], 51 | "no-duplicate-imports": true, 52 | "no-duplicate-variable": [true, "check-parameters"], 53 | "no-return-await": true, 54 | "no-shadowed-variable": true, 55 | "only-arrow-functions": [ 56 | true, 57 | "allow-named-functions", 58 | "allow-declarations" 59 | ], 60 | "deprecation": true, 61 | "indent": [true, "spaces", 2], 62 | "curly": [true, "ignore-same-line"], 63 | "class-name": true, 64 | "prefer-conditional-expression": [true, "check-else-if"], 65 | "promise-function-async": true, 66 | "quotemark": [true, "single"], 67 | "semicolon": [true, "always", "ignore-bound-class-methods"], 68 | "triple-equals": [true, "allow-null-check", "allow-undefined-check"], 69 | "no-unnecessary-callback-wrapper": true 70 | } 71 | } 72 | --------------------------------------------------------------------------------