├── .eslintignore ├── .eslintrc.js ├── .gitbook.yaml ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── babel.config.js ├── docs ├── README.md ├── advanced │ ├── AssignUpdateResponse.md │ ├── CustomActions.md │ ├── CustomFetch.md │ ├── CustomPromise.md │ ├── HeadersOverride.md │ ├── PureActions.md │ ├── README.md │ ├── ResourceCombination.md │ ├── SingleActionHelper.md │ └── TransformResponse.md ├── basics │ ├── Actions.md │ ├── README.md │ ├── Reducers.md │ ├── Resources.md │ └── Types.md ├── defaults │ ├── DefaultActions.md │ ├── DefaultHeaders.md │ ├── DefaultState.md │ └── README.md ├── examples │ ├── ActionsExamples.md │ └── README.md └── usage │ ├── Quickstart.md │ └── README.md ├── jest.config.js ├── package.json ├── src ├── actions │ ├── index.ts │ └── transform.ts ├── defaults │ ├── index.ts │ └── pipeline.ts ├── helpers │ ├── fetch.ts │ ├── url.ts │ └── util.ts ├── index.ts ├── reducers │ ├── helpers.ts │ └── index.ts ├── types.ts └── typings │ ├── index.d.ts │ └── index.ts ├── test ├── .eslintrc ├── setup.ts └── spec │ ├── __snapshots__ │ └── actions.spec.ts.snap │ ├── actions.spec.ts │ ├── index.spec.ts │ ├── reducers.spec.ts │ └── types.spec.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | // 'airbnb', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'prettier', 7 | 'prettier/@typescript-eslint' 8 | ], 9 | plugins: ['@typescript-eslint', 'prettier', 'jest'], 10 | parserOptions: { 11 | project: './tsconfig.json', 12 | sourceType: 'module' 13 | }, 14 | env: { 15 | es6: true, 16 | node: true 17 | }, 18 | rules: { 19 | '@typescript-eslint/triple-slash-reference': 'off', 20 | '@typescript-eslint/ban-types': 'off' 21 | // '@typescript-eslint/no-explicit-any': 'off', 22 | // '@typescript-eslint/no-use-before-define': 'off', 23 | // '@typescript-eslint/explicit-member-accessibility': 'off', 24 | // '@typescript-eslint/explicit-function-return-type': 'off', 25 | // '@typescript-eslint/prefer-interface': 'off', 26 | // '@typescript-eslint/array-type': 'off' 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /.gitbook.yaml: -------------------------------------------------------------------------------- 1 | structure: 2 | readme: README.md 3 | summary: docs/README.md 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | package-lock.json 4 | node_modules/ 5 | _book/ 6 | coverage/ 7 | .history/ 8 | .tmp/ 9 | lib/ 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | .* 3 | package.json 4 | LICENSE* 5 | README* 6 | CHANGELOG* 7 | !lib/**/*.js 8 | !lib/**/*.d.ts 9 | !lib/**/*.js.map 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: false, 4 | printWidth: 120, 5 | semi: true, 6 | singleQuote: true, 7 | trailingComma: 'none' 8 | }; 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 'lts/*' 5 | - 'stable' 6 | 7 | env: 8 | global: 9 | - CODACY_PROJECT_TOKEN=f041e6cde1724bacae7ede212c5b8539 10 | 11 | before_script: 12 | - date --rfc-2822 13 | - yarn global add codecov 14 | 15 | script: 16 | - yarn run spec:coverage 17 | - yarn run typecheck 18 | - yarn run lint 19 | - yarn run pretty 20 | 21 | after_script: 22 | - cat coverage/lcov.info | codacy-coverage 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.24.5](https://github.com/mgcrea/redux-rest-resource/compare/v0.24.4...v0.24.5) (2020-05-29) 6 | 7 | 8 | ### Features 9 | 10 | * **update:** minor changes ([bf4dc77](https://github.com/mgcrea/redux-rest-resource/commit/bf4dc77f1a6ef173b621b5b3db70a89ae3ea475e)) 11 | 12 | ### [0.24.4](https://github.com/mgcrea/redux-rest-resource/compare/v0.24.3...v0.24.4) (2020-05-28) 13 | 14 | 15 | ### Features 16 | 17 | * **update:** minor changes ([fced3f8](https://github.com/mgcrea/redux-rest-resource/commit/fced3f8deb4018777e6acc39a2671613c2d5484c)) 18 | 19 | ### [0.24.3](https://github.com/mgcrea/redux-rest-resource/compare/v0.24.2...v0.24.3) (2020-05-28) 20 | 21 | 22 | ### Features 23 | 24 | * **update:** minor changes ([df4704b](https://github.com/mgcrea/redux-rest-resource/commit/df4704b24cbff3265e65d010e4f1c068ff12054a)) 25 | 26 | # ChangeLog 27 | 28 | **@TODO** 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Olivier Louvignes 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Redux Rest Resource](https://mgcrea.gitbook.io/redux-rest-resource/) 2 | 3 | [![npm version](https://img.shields.io/npm/v/redux-rest-resource.svg)](https://github.com/mgcrea/redux-rest-resource/releases) 4 | [![license](https://img.shields.io/github/license/mgcrea/redux-rest-resource.svg?style=flat)](https://tldrlegal.com/license/mit-license) 5 | [![build status](http://img.shields.io/travis/mgcrea/redux-rest-resource/master.svg?style=flat)](http://travis-ci.org/mgcrea/redux-rest-resource) 6 | [![dependencies status](https://img.shields.io/david/mgcrea/redux-rest-resource.svg?style=flat)](https://david-dm.org/mgcrea/redux-rest-resource) 7 | [![devDependencies status](https://img.shields.io/david/dev/mgcrea/redux-rest-resource.svg?style=flat)](https://david-dm.org/mgcrea/redux-rest-resource#info=devDependencies) 8 | [![Codacy Badge](https://api.codacy.com/project/badge/Coverage/fdbf36d00e5d49c4879b91920e3e9b08)](https://www.codacy.com/app/mgcrea/redux-rest-resource?utm_source=github.com&utm_medium=referral&utm_content=mgcrea/redux-rest-resource&utm_campaign=Badge_Coverage) 9 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/fdbf36d00e5d49c4879b91920e3e9b08)](https://www.codacy.com/app/mgcrea/redux-rest-resource?utm_source=github.com&utm_medium=referral&utm_content=mgcrea/redux-rest-resource&utm_campaign=Badge_Grade) 10 | 11 | Dead simple and ready-to-use store module for handling HTTP REST resources. 12 | 13 | ## 🚧 Deprecation notice 14 | 15 | **This project is no longer maintained as better alternatives came out.** 16 | 17 | **Please use [RTK-Query](https://redux-toolkit.js.org/tutorials/rtk-query) from the [Redux Toolkit](https://github.com/reduxjs/redux-toolkit) project instead.** 18 | 19 | ## Description 20 | 21 | Generates types, actions and reducers for you to easily interact with any REST API. 22 | 23 | Saves you from writing a lot of boilerplate code and ensures that your code stays [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself). 24 | 25 | - Requires [redux-thunk](https://github.com/gaearon/redux-thunk) to handle async actions. 26 | 27 | - Relies on [fetch](https://fetch.spec.whatwg.org/) to perform HTTP requests. 28 | 29 | - Built with [TypeScript](https://www.typescriptlang.org/) for static type checking with exported types along the library. 30 | 31 | ## [Documentation](https://mgcrea.gitbook.io/redux-rest-resource/) 32 | 33 | **Check the [full documentation](https://mgcrea.gitbook.io/redux-rest-resource/)** (built with [gitbook](https://github.com/GitbookIO/gitbook)). 34 | 35 | - **https://mgcrea.gitbook.io/redux-rest-resource/** 36 | 37 | ## [Preview](https://mgcrea.gitbook.io/redux-rest-resource//docs/usage/Quickstart.html) 38 | 39 | Basically using a REST endpoint with Redux can be done with only a few lines: 40 | 41 | ```ts 42 | import {createResource} from 'redux-rest-resource'; 43 | 44 | const hostUrl = 'https://api.mlab.com:443/api/1/databases/sandbox/collections'; 45 | const apiKey = 'xvDjirE9MCIi800xMxi4EKeTm8e9FUBR'; 46 | 47 | type User = { 48 | id: string; 49 | firstName: string; 50 | lastName: string; 51 | }; 52 | 53 | export const {types, actions, rootReducer} = createResource({ 54 | name: 'user', 55 | url: `${hostUrl}/users/:id?apiKey=${apiKey}` 56 | }); 57 | ``` 58 | 59 | > NOTE: If you want to use this in environments without a builtin `fetch` implementation, you need to [bring your own custom fetch polyfill](advanced/CustomFetch). 60 | 61 | ### Available scripts 62 | 63 | | **Script** | **Description** | 64 | | ------------- | ---------------------------- | 65 | | start | alias to `test:watch` | 66 | | test | Run unit tests | 67 | | test:watch | Watch unit tests | 68 | | test:coverage | Run unit tests with coverage | 69 | | lint | Run eslint static tests | 70 | | compile | Compile the library | 71 | | compile:watch | Watch compilation | 72 | | docs | Serve docs | 73 | | docs:compile | Compile docs | 74 | 75 | ## Authors 76 | 77 | **Olivier Louvignes** 78 | 79 | - http://olouv.com 80 | - http://github.com/mgcrea 81 | 82 | Inspired by the [AngularJS resource](https://github.com/angular/angular.js/blob/master/src/ngResource/resource.js). 83 | 84 | ## License 85 | 86 | ``` 87 | The MIT License 88 | 89 | Copyright (c) 2016 Olivier Louvignes 90 | 91 | Permission is hereby granted, free of charge, to any person obtaining a copy 92 | of this software and associated documentation files (the "Software"), to deal 93 | in the Software without restriction, including without limitation the rights 94 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 95 | copies of the Software, and to permit persons to whom the Software is 96 | furnished to do so, subject to the following conditions: 97 | 98 | The above copyright notice and this permission notice shall be included in 99 | all copies or substantial portions of the Software. 100 | 101 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 102 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 103 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 104 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 105 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 106 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 107 | THE SOFTWARE. 108 | ``` 109 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-typescript', '@babel/preset-react', '@babel/preset-env'], 3 | plugins: [ 4 | [ 5 | 'babel-plugin-module-name-mapper', 6 | { 7 | moduleNameMapper: { 8 | '^src/(.*)': '/src/$1' 9 | } 10 | } 11 | ], 12 | '@babel/plugin-proposal-class-properties', 13 | '@babel/plugin-transform-runtime' 14 | ] 15 | }; 16 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | 3 | * [Read Me](../README.md) 4 | * [Usage](usage/README.md) 5 | * [Quickstart](usage/Quickstart.md) 6 | * [Basics](basics/README.md) 7 | * [Resources](basics/Resources.md) 8 | * [Actions](basics/Actions.md) 9 | * [Reducers](basics/Reducers.md) 10 | * [Types](basics/Types.md) 11 | * [Examples](examples/README.md) 12 | * [Actions](examples/ActionsExamples.md) 13 | * [Advanced](advanced/README.md) 14 | * [Custom Actions](advanced/CustomActions.md) 15 | * [Pure Actions](advanced/PureActions.md) 16 | * [Single Action Helper](advanced/SingleActionHelper.md) 17 | * [Headers Override](advanced/HeadersOverride.md) 18 | * [Transform Response](advanced/TransformResponse.md) 19 | * [Assign Update Response](advanced/AssignUpdateResponse.md) 20 | * [Resource Combination](advanced/ResourceCombination.md) 21 | * [Custom Promise](advanced/CustomPromise.md) 22 | * [Custom fetch](advanced/CustomFetch.md) 23 | * [Defaults](defaults/README.md) 24 | * [Actions](defaults/DefaultActions.md) 25 | * [Headers](defaults/DefaultHeaders.md) 26 | * [State](defaults/DefaultState.md) 27 | * [Changelog](/CHANGELOG.md) 28 | -------------------------------------------------------------------------------- /docs/advanced/AssignUpdateResponse.md: -------------------------------------------------------------------------------- 1 | # Assign Update Response 2 | 3 | By default, the response of the update action (`PATCH` request) will be ignored. 4 | 5 | If you want to assign the response body (eg. for complex populated updates), you can use the `assignResponse` reducer option. 6 | 7 | - Usually you will want to configure it for a specific action: 8 | 9 | ```js 10 | export const {types, actions, reducers} = createResource({ 11 | name: 'user', 12 | url: 'https://foo.com/users/:id', 13 | actions: { 14 | update: { 15 | assignResponse: true 16 | } 17 | } 18 | }); 19 | ``` 20 | 21 | - Or as an one-time override at action call-time: 22 | 23 | ```js 24 | actions.updateUser({firstName: 'Olivier'}, {query: {pleaseSendResponse: true}, assignResponse: true}); 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/advanced/CustomActions.md: -------------------------------------------------------------------------------- 1 | # Custom Actions 2 | 3 | - You can configure custom actions: 4 | 5 | ```js 6 | const url = 'https://foo.com/users/:id'; 7 | export const {types, actions, reducers} = createResource({ 8 | name: 'user', 9 | url, 10 | actions: { 11 | run: {method: 'POST', gerundName: 'running', url: './run'}, 12 | merge: {method: 'POST', isArray: true} 13 | } 14 | }); 15 | ``` 16 | 17 | - It will generate the following action creators: 18 | 19 | ```js 20 | // Action creators available to interact with your REST resource 21 | Object.keys(actions) == ['runUser', 'mergeUsers']; 22 | ``` 23 | 24 | - And have the following state by default: 25 | 26 | ```js 27 | state == 28 | { 29 | isRunning: false, 30 | isMerging: false 31 | }; 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/advanced/CustomFetch.md: -------------------------------------------------------------------------------- 1 | # Custom Fetch 2 | 3 | You can easily plug in your own fetch library: 4 | 5 | ```js 6 | import fetch from 'fetch-everywhere'; 7 | import {defaultGlobals as reduxRestResourceGlobals} from 'redux-rest-resource'; 8 | Object.assign(reduxRestResourceGlobals, {fetch}); 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/advanced/CustomPromise.md: -------------------------------------------------------------------------------- 1 | # Custom Promise 2 | 3 | You can easily plug in your own Promise library: 4 | 5 | ```js 6 | import Promise from 'bluebird'; 7 | import {defaultGlobals as reduxRestResourceGlobals} from 'redux-rest-resource'; 8 | Object.assign(reduxRestResourceGlobals, {Promise}); 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/advanced/HeadersOverride.md: -------------------------------------------------------------------------------- 1 | # Headers Override 2 | 3 | - You can configure `headers` globally: 4 | 5 | ```js 6 | export const {types, actions, reducers} = createResource({ 7 | name: 'user', 8 | url: 'https://foo.com/users/:id', 9 | headers: { 10 | 'X-Custom-Header': 'foobar' 11 | } 12 | }); 13 | ``` 14 | 15 | - You may want to globally update default `headers` at run time (eg. you received a new JWT): 16 | 17 | ```js 18 | import {defaultHeaders} from 'redux-rest-resource'; 19 | const jwt = 'xvDjirE9MCIi800xMxi4EKeTm8e9FUBR'; 20 | Object.assign(defaultHeaders, {Authorization: `Bearer ${jwt}`}); 21 | ``` 22 | 23 | - You can also configure `headers` for a specific action: 24 | 25 | ```js 26 | export const {types, actions, reducers} = createResource({ 27 | name: 'user', 28 | url: 'https://foo.com/users/:id', 29 | actions: { 30 | update: { 31 | headers: { 32 | 'X-Custom-Header': 'foobar' 33 | } 34 | } 35 | } 36 | }); 37 | ``` 38 | 39 | - Or as an one-time override at action call-time: 40 | 41 | ```js 42 | actions.updateUser({firstName: 'Olivier'}, {headers: {Authorization: `Bearer ${jwt}`}}); 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/advanced/PureActions.md: -------------------------------------------------------------------------------- 1 | # Pure Actions 2 | 3 | You can create pure actions that won't perform any HTTP request and only impact the state. 4 | 5 | - You can configure pure actions with the `isPure` option, a `reduce` option must be set: 6 | 7 | ```js 8 | const url = 'https://foo.com/users/:id'; 9 | export const {types, actions, reducers} = createResource({ 10 | name: 'user', 11 | url, 12 | actions: { 13 | clear: {isPure: true, reduce: (state, action) => ({...state, item: null})} 14 | } 15 | }); 16 | ``` 17 | 18 | - It will generate the following action creators: 19 | 20 | ```js 21 | // Action creators available to interact with your REST resource 22 | Object.keys(actions) == ['clearUser']; 23 | ``` 24 | 25 | - That will always run your custom reducer 26 | 27 | ```js 28 | state == 29 | { 30 | item: null 31 | }; 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/advanced/README.md: -------------------------------------------------------------------------------- 1 | # Advanced 2 | 3 | Some more advanced examples 4 | 5 | - [Custom Actions](CustomActions.md) 6 | - [Pure Actions](PureActions.md) 7 | - [Single Action Helper](SingleActionHelper.md) 8 | - [Headers Override](HeadersOverride.md) 9 | - [Transform Response](TransformResponse.md) 10 | - [Assign Update Response](AssignUpdateResponse.md) 11 | - [Resource Combination](ResourceCombination.md) 12 | - [Custom Promise](CustomPromise.md) 13 | - [Custom fetch](CustomFetch.md) 14 | -------------------------------------------------------------------------------- /docs/advanced/ResourceCombination.md: -------------------------------------------------------------------------------- 1 | # Resource Combination 2 | 3 | You can easily combine multiple resources together. For instance, if you want to use children resources attached to a parent store node: 4 | 5 | ```js 6 | import {createResource} from 'redux-rest-resource'; 7 | const hostUrl = 'http://localhost:3000'; 8 | // Parent Library Store 9 | const libraryResource = createResource({ 10 | name: 'library', 11 | pluralName: 'libraries', 12 | url: `${hostUrl}/libraries/:id` 13 | }); 14 | // Children Library Asset Store 15 | const libraryAssetResource = createResource({ 16 | name: 'libraryAsset', 17 | url: `${hostUrl}/libraries/:libraryId/assets/:id` 18 | }); 19 | ``` 20 | 21 | Exported `types` and `actions` do expose unique keys that enables quick and easy merging. 22 | 23 | ```js 24 | const types = {...libraryResource.types, ...libraryAssetResource.types}; 25 | const actions = {...libraryResource.actions, ...libraryAssetResource.actions}; 26 | ``` 27 | 28 | Reducers do require extra care: 29 | 30 | ```js 31 | import {mergeReducers} from 'redux-rest-resource'; 32 | const reducers = mergeReducers(libraryResource.reducers, {assets: libraryAssetResource.reducers}); 33 | ``` 34 | 35 | Finally export an unified resource: 36 | 37 | ```js 38 | export {types, actions, reducers}; 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/advanced/SingleActionHelper.md: -------------------------------------------------------------------------------- 1 | # Single Action 2 | 3 | We also expose a helper to generate a store module for quick single action setup (eg. a basic fetch). 4 | 5 | ```js 6 | const url = 'https://foo.com/users/:id'; 7 | export const {types, actions, rootReducer} = createResourceAction({ 8 | name: 'environment', 9 | method: 'GET', 10 | url, 11 | headers: { 12 | Authorization: `Bearer ${apiJwt}` 13 | }, 14 | credentials: 'include' 15 | }); 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/advanced/TransformResponse.md: -------------------------------------------------------------------------------- 1 | # Transform Response 2 | 3 | - You can configure `transformResponse` for a specific action: 4 | 5 | For instance if you want to sort by your response before it hits the store 6 | 7 | ```js 8 | import {sortBy} from 'lodash'; 9 | 10 | export const {types, actions, reducers} = createResource({ 11 | name: 'user', 12 | url: 'https://foo.com/users/:id', 13 | actions: { 14 | fetch: { 15 | transformResponse: (res) => ({...res, body: sortBy(res.body, 'date')}) 16 | }, 17 | update: { 18 | headers: { 19 | 'X-Custom-Header': 'foobar' 20 | } 21 | } 22 | } 23 | }); 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/basics/Actions.md: -------------------------------------------------------------------------------- 1 | # Actions 2 | 3 | **Actions** are payloads of information that send data from your application to your store. 4 | 5 | **Action creators** are functions that create actions. It's easy to conflate the terms “action” and “action creator,” so do your best to use the proper term. 6 | 7 | ## Provided action creators 8 | 9 | **Redux REST Resource** gives you ready-to-use action creators that will dispatch associated actions. Out of the box, you have the following actions configured: 10 | 11 | ```js 12 | const {types, actions, reducers} = createResource({name: 'user', url: 'localhost:9000/api'}); 13 | 14 | Object.keys(actions) == 15 | ['createUser', 'fetchUsers', 'getUser', 'updateUser', 'updateUsers', 'deleteUser', 'deleteUsers']; 16 | ``` 17 | 18 | > You can check the [default actions configuration](../defaults/DefaultActions.md) 19 | 20 | ## Available options 21 | 22 | You can configure/override actions actions when using `createResource`: 23 | 24 | Every configuration can be specified at either a global level: 25 | 26 | ```js 27 | createResource({ 28 | name: 'user', 29 | url: `foo.com/users/:id`, 30 | headers: {Authorization: 'Bearer foobar'} 31 | }); 32 | ``` 33 | 34 | Or at the action level: 35 | 36 | ```js 37 | createResource({ 38 | name: 'user', 39 | url: `foo.com/users/:id`, 40 | actions: { 41 | update: { 42 | method: 'POST' 43 | } 44 | } 45 | }); 46 | ``` 47 | 48 | #### Fetch related options 49 | 50 | | Option name | Type | Default | Description | Example | 51 | | ------------- | ------------------- | ---------- | ------------------------ | ------------------------------- | 52 | | `url` | _String / Function_ | _Required_ | Base URL to fetch | `"https://foo.com/users/:id"` | 53 | | `method` | _String / Function_ | `"GET"` | HTTP method | `"PATCH"` | 54 | | `headers` | _Object / Function_ | {} | Headers to be sent along | `{Authorization: 'Bearer foo'}` | 55 | | `query` | _Object / Function_ | {} | Query params | `{from: 10, until: 20}` | 56 | | `credentials` | _String / Function_ | undefined | Credentials | `"include"` | 57 | | `body` | _String / Function_ | undefined | HTTP body override | `{foo: 'bar'}` | 58 | 59 | Every option also accept a function that will receive the `getState` helper, to act against the current state. 60 | 61 | ```js 62 | export const {types, actions, reducers} = createResource({ 63 | name: 'user', 64 | url: `foo.com/users/:id`, 65 | actions: { 66 | update: { 67 | method: (getState, {actionId, context, contextOpts}) => 'POST' 68 | } 69 | } 70 | }); 71 | ``` 72 | 73 | Every option can also be used at action call-time: 74 | 75 | ```js 76 | export const {types, actions, reducers} = createResource({ 77 | name: 'user', 78 | url: `foo.com/users/:id` 79 | }); 80 | actions.updateUser( 81 | { 82 | id: '5925b7f7d9808600076ce557', 83 | firstName: 'Olivier' 84 | }, 85 | { 86 | query: { 87 | foo: 'bar' 88 | }, 89 | credentials: 'include' 90 | } 91 | ); 92 | ``` 93 | 94 | #### Reduce related options 95 | 96 | | Option name | Type | Default | Description | 97 | | ---------------- | --------- | ------- | ----------------------------------------- | 98 | | `isArray` | _Boolean_ | false | Whether the expected response is an Array | 99 | | `assignResponse` | _Boolean_ | false | Whether to assign the response | 100 | | `isPure` | _Boolean_ | false | Whether to skip the HTTP request | 101 | 102 | ### Dispatched actions 103 | 104 | We're using a dedicated `status` field in our actions to reflect the Promise state. 105 | 106 | For instance, `fetchUsers()` will dispatch the following actions: 107 | 108 | ```js 109 | // Dispatched actions by the `fetchUsers` action creator 110 | // First a `pending` action is dispatched 111 | {type: '@@resource/USER/FETCH', status: 'pending', context} 112 | // then either a `resolved` action on success 113 | {type: '@@resource/USER/FETCH', status: 'resolved', context, options, body, receivedAt, headers} 114 | // or a `rejected` action if an error is caught 115 | {type: '@@resource/USER/FETCH', status: 'rejected', context, options, err, receivedAt} 116 | ``` 117 | 118 | Every REST action creator will dispatch at most two actions based on the async status of the request. 119 | -------------------------------------------------------------------------------- /docs/basics/README.md: -------------------------------------------------------------------------------- 1 | # Basics 2 | 3 | - [Resources](Resources.md) 4 | - [Actions](Actions.md) 5 | - [Reducers](Reducers.md) 6 | - [Types](Types.md) 7 | -------------------------------------------------------------------------------- /docs/basics/Reducers.md: -------------------------------------------------------------------------------- 1 | # Reducers 2 | 3 | **Reducers** are pure functions that takes the previous state and an action, and returns the next state. 4 | 5 | ```js 6 | (previousState, action) => newState; 7 | ``` 8 | 9 | #### Exported store state 10 | 11 | **Redux REST Resource** will manage the state for you and store related data inside it. You have state-related booleans (like `isFetching`) that enables smooth UI feedback. 12 | 13 | ```js 14 | import {initialState} from 'redux-rest-resource'; 15 | 16 | initialState == 17 | { 18 | // FETCH props 19 | items: [], 20 | isFetching: false, 21 | lastUpdated: 0, 22 | didInvalidate: true, 23 | // GET props 24 | item: null, 25 | isFetchingItem: false, 26 | lastUpdatedItem: 0, 27 | didInvalidateItem: true, 28 | // CREATE props 29 | isCreating: false, 30 | // UPDATE props 31 | isUpdating: false, 32 | isUpdatingMany: false, 33 | // DELETE props 34 | isDeleting: false, 35 | isDeletingMany: false 36 | }; 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/basics/Resources.md: -------------------------------------------------------------------------------- 1 | # Resources 2 | 3 | - **Resources** are ready-to-use store modules, simple javascript objects with the following structure: 4 | 5 | ```js 6 | { 7 | types, actions, reducers, rootReducer; 8 | } 9 | ``` 10 | 11 | > @NOTE: reducers is currently also exposing the root reducer, but using rootReducer is the recommended way forward. Will break once 1.0 is reached. 12 | 13 | - **Resources** are the ready-to-use abstraction for your REST related needs. 14 | 15 | - They can be created with a simple factory function: 16 | 17 | ```js 18 | import {createResource} from 'redux-rest-resource'; 19 | 20 | const hostUrl = 'https://api.mlab.com:443/api/1/databases/sandbox/collections'; 21 | const apiKey = 'xvDjirE9MCIi800xMxi4EKeTm8e9FUBR'; 22 | 23 | export const {types, actions, rootReducer} = createResource({ 24 | name: 'user', 25 | url: `${hostUrl}/users/:id?apiKey=${apiKey}` 26 | }); 27 | ``` 28 | 29 | ## Options 30 | 31 | | **Option name** | **Type** | **Description** | 32 | | --------------- | --------------- | ----------------------------------------------------- | 33 | | name | String | Actual name of the resource (required) | 34 | | url | Function/String | Actual url of the resource (required) | 35 | | pluralName | String | Plural name of the resource (optional) | 36 | | actions | Object | Action extra options, merged with defaults (optional) | 37 | 38 | - You can also pass any [action related option](Actions.md#available-options) to set a global default. 39 | 40 | ```js 41 | export const {types, actions, rootReducer} = createResource({ 42 | name: 'user', 43 | url: `${hostUrl}/users/:id?apiKey=${apiKey}`, 44 | credentials: 'include', 45 | headers: {'X-Custom-Header': 'Some static header'} 46 | }); 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/basics/Types.md: -------------------------------------------------------------------------------- 1 | # Types 2 | 3 | **Action types** are strings that indicates the type of action being performed. 4 | 5 | ### Exported action types 6 | 7 | **Redux REST Resource** does expose action types that will be used in dispatched actions. 8 | 9 | We're using scoped `types` with a namespace to prevent conflicts when using multiple resources. 10 | 11 | ```js 12 | types == 13 | { 14 | CREATE_USER: '@@resource/USER/CREATE', 15 | FETCH_USERS: '@@resource/USER/FETCH', 16 | GET_USER: '@@resource/USER/GET', 17 | UPDATE_USER: '@@resource/USER/UPDATE', 18 | UPDATE_USERS: '@@resource/USER/UPDATE_MANY', 19 | DELETE_USER: '@@resource/USER/DELETE', 20 | DELETE_USERS: '@@resource/USER/DELETE_MANY' 21 | }; 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/defaults/DefaultActions.md: -------------------------------------------------------------------------------- 1 | # Default Actions 2 | 3 | ```js 4 | import {defaultActions} from 'redux-rest-resource'; 5 | 6 | defaultActions == 7 | { 8 | create: { 9 | method: 'POST' 10 | }, 11 | fetch: { 12 | method: 'GET', 13 | isArray: true 14 | }, 15 | get: { 16 | method: 'GET' 17 | }, 18 | update: { 19 | method: 'PATCH' 20 | }, 21 | updateMany: { 22 | method: 'PATCH', 23 | isArray: true, 24 | alias: 'update' // to have `updateUsers` vs. `updateManyUsers` 25 | }, 26 | delete: { 27 | method: 'DELETE' 28 | }, 29 | deleteMany: { 30 | method: 'DELETE', 31 | isArray: true, 32 | alias: 'delete' // to have `deleteUsers` vs. `deleteManyUsers` 33 | } 34 | }; 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/defaults/DefaultHeaders.md: -------------------------------------------------------------------------------- 1 | # Default Headers 2 | 3 | ```js 4 | import {defaultHeaders} from 'redux-rest-resource'; 5 | 6 | defaultHeaders == 7 | { 8 | Accept: 'application/json', 9 | 'Content-Type': 'application/json' 10 | }; 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/defaults/DefaultState.md: -------------------------------------------------------------------------------- 1 | # Default State 2 | 3 | ```js 4 | import {initialState} from 'redux-rest-resource'; 5 | 6 | initialState == 7 | { 8 | // FETCH props 9 | items: [], 10 | isFetching: false, 11 | lastUpdated: 0, 12 | didInvalidate: true, 13 | // GET props 14 | item: null, 15 | isFetchingItem: false, 16 | lastUpdatedItem: 0, 17 | didInvalidateItem: true, 18 | // CREATE props 19 | isCreating: false, 20 | // UPDATE props 21 | isUpdating: false, 22 | isUpdatingMany: false, 23 | // DELETE props 24 | isDeleting: false, 25 | isDeletingMany: false 26 | }; 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/defaults/README.md: -------------------------------------------------------------------------------- 1 | # Defaults 2 | 3 | - [Actions](DefaultActions.md) 4 | - [Headers](DefaultHeaders.md) 5 | - [State](DefaultState.md) 6 | -------------------------------------------------------------------------------- /docs/examples/ActionsExamples.md: -------------------------------------------------------------------------------- 1 | # Actions Examples 2 | 3 | - `create`: 4 | 5 | ```js 6 | const createBody = {firstName: 'Olivier'}; 7 | const actionOpts = {query: {foo: 'bar'}}; 8 | actions.createUser(createBody, actionOpts); 9 | // Will POST `createBody` to localhost:9000/api/users?foo=bar 10 | ``` 11 | 12 | - `fetch`: 13 | 14 | ```js 15 | const actionOpts = {query: {foo: 'bar'}}; 16 | const fetchContext = null; 17 | // fetchContext could be used as an object to resolve extra url parameters (eg. localhost:9000/api/:version/users/:id) 18 | actions.fetchUsers(fetchContext, actionOpts); 19 | // Will GET localhost:9000/api/users?foo=bar 20 | ``` 21 | 22 | - `update`: 23 | 24 | ```js 25 | const updateBody = {id: '5925b7f7d9808600076ce557', firstName: 'Olivia'}; 26 | const actionOpts = {query: {foo: 'bar'}}; 27 | actions.updateUser(updateBody, actionOpts); 28 | // Will PATCH updateBody to localhost:9000/api/users/5925b7f7d9808600076ce557?foo=bar 29 | ``` 30 | 31 | - `get`: 32 | 33 | ```js 34 | const getContext = {id: '5925b7f7d9808600076ce557'}; 35 | // const getContext = '5925b7f7d9808600076ce557' would also work 36 | const actionOpts = {query: {foo: 'bar'}}; 37 | actions.getUser(getContext, actionOpts); 38 | // Will GET localhost:9000/api/users/5925b7f7d9808600076ce557?foo=bar 39 | ``` 40 | 41 | - `delete`: 42 | 43 | ```js 44 | const deleteContext = {id: '5925b7f7d9808600076ce557'}; 45 | // const deleteContext = '5925b7f7d9808600076ce557' would also work 46 | const actionOpts = {query: {foo: 'bar'}}; 47 | actions.deleteUser(getContext, actionOpts); 48 | // Will DELETE localhost:9000/api/users/5925b7f7d9808600076ce557?foo=bar 49 | ``` 50 | 51 | > List of [all available options](../basics/Actions.md#available-options) 52 | -------------------------------------------------------------------------------- /docs/examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | - [ActionsExamples](ActionsExamples.md) 4 | -------------------------------------------------------------------------------- /docs/usage/Quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | ```bash 4 | npm i redux-rest-resource --save 5 | ``` 6 | 7 | 1. Export created types, actions and reducers (eg. in `containers/Users/store/index.js`) 8 | 9 | ```js 10 | import {createResource} from 'redux-rest-resource'; 11 | 12 | const hostUrl = 'https://api.mlab.com:443/api/1/databases/sandbox/collections'; 13 | const apiKey = 'xvDjirE9MCIi800xMxi4EKeTm8e9FUBR'; 14 | 15 | export const {types, actions, rootReducer} = createResource({ 16 | name: 'user', 17 | url: `${hostUrl}/users/:id?apiKey=${apiKey}` 18 | }); 19 | ``` 20 | 21 | 2. Import reducers in your store 22 | 23 | ```js 24 | import {combineReducers} from 'redux'; 25 | import {rootReducer as usersReducer} from 'containers/Users/store'; 26 | export default combineReducers({ 27 | users: usersReducer 28 | }); 29 | ``` 30 | 31 | 3. Use provided actions inside connected components 32 | 33 | ```js 34 | import {bindActionCreators} from 'redux'; 35 | import {connect} from 'react-redux'; 36 | import {actions as userActions} from 'containers/Users/store'; 37 | import UserListItem from './UserListItem'; 38 | 39 | class UserList extends Component { 40 | componentDidMount() { 41 | const {actions} = this.props; 42 | actions.fetchUsers(); 43 | } 44 | 45 | render() { 46 | const {actions, users} = this.props; 47 | return
    {users.map(user => )}
; 48 | } 49 | } 50 | 51 | export default connect( 52 | // mapStateToProps 53 | state => ({users: state.users.items}), 54 | // mapDispatchToProps 55 | dispatch => ({ 56 | actions: bindActionCreators({...userActions}, dispatch) 57 | }) 58 | )(UserList); 59 | ``` 60 | 61 | > Next, You can check [usage examples](../examples/README.md) 62 | -------------------------------------------------------------------------------- /docs/usage/README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | - [Quickstart](Quickstart.md) 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src/', '/test/'], 3 | setupFiles: ['/test/setup.ts'], 4 | testEnvironment: 'jest-environment-jsdom-fourteen', 5 | modulePathIgnorePatterns: ['/_book', '/docs'], 6 | collectCoverageFrom: ['src/**/*.ts'] 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-rest-resource", 3 | "description": "Generates types, actions and reducers for you to easily interact with any REST API.", 4 | "version": "0.29.3", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "author": "Olivier Louvignes ", 8 | "repository": "github:mgcrea/redux-rest-resource", 9 | "license": "MIT", 10 | "scripts": { 11 | "start": "yarn spec:watch", 12 | "test": "yarn pretty && yarn lint && yarn typecheck && yarn spec", 13 | "spec": "CI=true jest --runInBand --colors", 14 | "spec:watch": "yarn spec --watch", 15 | "spec:coverage": "yarn spec --coverage", 16 | "lint": "eslint --ext .ts src/", 17 | "typecheck": "tsc", 18 | "pretty": "prettier --write '{src,test}/**/*.ts'", 19 | "build": "yarn build:source && yarn build:typings", 20 | "build:source": "babel --source-maps --extensions .js,.ts --out-dir lib/ --delete-dir-on-start --ignore **/__tests__ src/", 21 | "build:typings": "tsc --declaration", 22 | "build:watch": "yarn build --watch --verbose", 23 | "prepare": "yarn build" 24 | }, 25 | "dependencies": { 26 | "@babel/runtime": "^7.11.2" 27 | }, 28 | "devDependencies": { 29 | "@babel/cli": "^7.11.6", 30 | "@babel/core": "^7.11.6", 31 | "@babel/plugin-proposal-class-properties": "^7.10.4", 32 | "@babel/plugin-transform-runtime": "^7.11.5", 33 | "@babel/preset-env": "^7.11.5", 34 | "@babel/preset-react": "^7.10.4", 35 | "@babel/preset-typescript": "^7.10.4", 36 | "@types/jest": "^26.0.14", 37 | "@types/lodash": "^4.14.161", 38 | "@types/redux-mock-store": "^1.0.2", 39 | "@typescript-eslint/eslint-plugin": "^4.3.0", 40 | "@typescript-eslint/parser": "^4.3.0", 41 | "babel-eslint": "^10.1.0", 42 | "babel-plugin-module-name-mapper": "^1.2.0", 43 | "bluebird": "^3.7.2", 44 | "codacy-coverage": "^3.4.0", 45 | "cross-fetch": "^3.0.6", 46 | "debug-utils": "^0.5.3", 47 | "eslint": "^7.10.0", 48 | "eslint-config-prettier": "^6.12.0", 49 | "eslint-plugin-jest": "^24.0.2", 50 | "eslint-plugin-prettier": "^3.1.4", 51 | "expect": "^26.4.2", 52 | "gh-pages": "^3.1.0", 53 | "jest": "^26.4.2", 54 | "jest-environment-jsdom-fourteen": "^1.0.1", 55 | "lodash": "^4.17.20", 56 | "nock": "^13.0.4", 57 | "prettier": "^2.1.2", 58 | "redux": "^4.0.5", 59 | "redux-mock-store": "^1.5.4", 60 | "redux-thunk": "^2.3.0", 61 | "rimraf": "^3.0.2", 62 | "typescript": "^4.0.3" 63 | }, 64 | "peerDependencies": { 65 | "redux-thunk": "^2.3.0" 66 | }, 67 | "keywords": [ 68 | "elm", 69 | "flux", 70 | "functional", 71 | "http", 72 | "reducer", 73 | "redux", 74 | "resource", 75 | "rest", 76 | "state" 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import {BeforeErrorPipeline, AsyncActionCreatorsMapObject} from 'src/typings'; 2 | import {defaultTransformResponsePipeline} from '../defaults/pipeline'; 3 | import fetch, { 4 | buildFetchOpts, 5 | buildFetchUrl, 6 | HttpError, 7 | SerializableResponse, 8 | serializeResponse 9 | } from '../helpers/fetch'; 10 | import {parseUrlParams} from '../helpers/url'; 11 | import {getPluralName, isFunction, isString, pick, ucfirst} from '../helpers/util'; 12 | import {getActionType, getTypesScope, scopeType} from '../types'; 13 | import { 14 | ConfigActionsOptions, 15 | AsyncActionCreator, 16 | Context, 17 | ContextOptions, 18 | FetchOptions, 19 | ReduceOptions, 20 | State 21 | } from '../typings'; 22 | import {AnyTransform, applyTransformPipeline, buildTransformPipeline} from './transform'; 23 | export const SUPPORTED_FETCH_OPTS = ['url', 'method', 'headers', 'credentials', 'query', 'body', 'signal'] as const; 24 | export const SUPPORTED_REDUCE_OPTS = [ 25 | 'assignResponse', 26 | 'gerundName', 27 | 'invalidateState', 28 | 'isArray', 29 | 'isPure', 30 | 'mergeResponse', 31 | 'params', 32 | 'reduce' 33 | ] as const; 34 | 35 | type GetActionNameOptions = { 36 | resourceName: string; 37 | resourcePluralName?: string; 38 | isArray?: boolean; 39 | alias?: string; 40 | }; 41 | 42 | const getActionName = ( 43 | actionId: string, 44 | {resourceName, resourcePluralName = getPluralName(resourceName), isArray = false, alias}: GetActionNameOptions 45 | ): string => (!resourceName ? actionId : `${alias || actionId}${ucfirst(isArray ? resourcePluralName : resourceName)}`); 46 | 47 | export type CreateActionOptions = FetchOptions & 48 | ReduceOptions & { 49 | scope?: string; 50 | stripTrailingSlashes?: boolean; 51 | transformResponse?: AnyTransform; 52 | beforeError?: BeforeErrorPipeline; 53 | alias?: string; 54 | }; 55 | 56 | const createAction = ( 57 | actionId: string, 58 | {scope, stripTrailingSlashes = true, ...actionOpts}: CreateActionOptions 59 | ): AsyncActionCreator => { 60 | const type = scopeType(getActionType(actionId), scope); 61 | // Actual action function with two args 62 | // Context usage changes with resolved method: 63 | // - GET/DELETE will be used to resolve query params (eg. /users/:id) 64 | // - POST/PATCH will be used to resolve query params (eg. /users/:id) and as request body 65 | return (context: Context, contextOpts: ContextOptions = {}) => ( 66 | dispatch, 67 | getState 68 | ): Promise => { 69 | // Prepare reduce options 70 | const reduceOpts: ReduceOptions = { 71 | ...pick(actionOpts, ...SUPPORTED_REDUCE_OPTS), 72 | ...pick(contextOpts, ...SUPPORTED_REDUCE_OPTS) 73 | }; 74 | // Support pure actions 75 | if (actionOpts.isPure) { 76 | dispatch({ 77 | type, 78 | status: 'resolved', 79 | options: reduceOpts, 80 | context 81 | }); 82 | return Promise.resolve(null); 83 | } 84 | // First dispatch a pending action 85 | dispatch({ 86 | type, 87 | status: 'pending', 88 | options: reduceOpts, 89 | context 90 | }); 91 | // Prepare fetch options 92 | const fetchOpts: FetchOptions = { 93 | ...pick(actionOpts, ...SUPPORTED_FETCH_OPTS), 94 | ...pick(contextOpts, ...SUPPORTED_FETCH_OPTS) 95 | }; 96 | // Support dynamic fetch options 97 | const resolvedfetchOpts: FetchOptions = Object.keys(fetchOpts).reduce>((soFar, key) => { 98 | soFar[key] = isFunction(fetchOpts[key as keyof FetchOptions]) 99 | ? ((fetchOpts[key as keyof FetchOptions] as unknown) as ( 100 | getState: () => State, 101 | options: {context: Context; contextOpts: ContextOptions; actionId: string} 102 | ) => unknown)(getState, { 103 | context, 104 | contextOpts, 105 | actionId 106 | }) 107 | : fetchOpts[key as keyof FetchOptions]; 108 | return soFar; 109 | }, {}) as FetchOptions; 110 | const {url = '', ...eligibleFetchOptions} = resolvedfetchOpts; 111 | // Build fetch url and options 112 | const urlParams = parseUrlParams(url); 113 | const finalFetchOpts = buildFetchOpts(context, eligibleFetchOptions); 114 | const finalFetchUrl = buildFetchUrl(context, { 115 | url, 116 | method: finalFetchOpts.method, 117 | params: reduceOpts.params, 118 | urlParams, 119 | stripTrailingSlashes, 120 | isArray: actionOpts.isArray 121 | }); 122 | return fetch(finalFetchUrl, finalFetchOpts) 123 | .then(serializeResponse) 124 | .then( 125 | applyTransformPipeline( 126 | buildTransformPipeline(defaultTransformResponsePipeline, actionOpts.transformResponse) 127 | ) 128 | ) 129 | .then((payload) => { 130 | dispatch({ 131 | type, 132 | status: 'resolved', 133 | context, 134 | options: reduceOpts, 135 | payload 136 | }); 137 | return payload; 138 | }) 139 | .catch((err: Error) => { 140 | const payload: SerializableResponse = 141 | err instanceof HttpError 142 | ? { 143 | body: err.body, 144 | headers: err.headers, 145 | code: err.status, // deprecated 146 | status: err.status, 147 | ok: false, 148 | receivedAt: Date.now() 149 | } 150 | : { 151 | body: err.message, 152 | headers: {}, 153 | code: 0, // deprecated 154 | status: 0, 155 | ok: false, 156 | receivedAt: Date.now() 157 | }; 158 | 159 | // beforeError hook 160 | const nextErr = actionOpts.beforeError 161 | ? actionOpts.beforeError.reduce( 162 | (errSoFar, beforeErrorHook) => (errSoFar ? beforeErrorHook(errSoFar) : null), 163 | err 164 | ) 165 | : err; 166 | 167 | dispatch({ 168 | type, 169 | status: 'rejected', 170 | context, 171 | options: reduceOpts, 172 | payload 173 | }); 174 | 175 | if (nextErr === null) { 176 | return payload; 177 | } 178 | if (!(nextErr instanceof Error)) { 179 | return nextErr; 180 | } 181 | throw nextErr; 182 | }); 183 | }; 184 | }; 185 | 186 | type CreateActionsOptions = { 187 | resourceName: string; 188 | resourcePluralName?: string; 189 | scope?: string; 190 | stripTrailingSlashes?: boolean; 191 | transformResponse?: AnyTransform; 192 | beforeError?: BeforeErrorPipeline; 193 | } & FetchOptions & 194 | ReduceOptions; 195 | 196 | const createActions = ( 197 | actions: ConfigActionsOptions = {}, 198 | { 199 | resourceName, 200 | resourcePluralName = getPluralName(resourceName), 201 | scope = getTypesScope(resourceName), 202 | ...globalOpts 203 | }: CreateActionsOptions 204 | ): AsyncActionCreatorsMapObject => { 205 | const actionKeys = Object.keys(actions); 206 | return actionKeys.reduce((actionFuncs, actionId) => { 207 | // Add support for relative url override 208 | const {url} = actions[actionId]; 209 | 210 | if (globalOpts.url && url && isString(url) && url.substr(0, 1) === '.') { 211 | actions[actionId] = { 212 | ...actions[actionId], 213 | url: `${globalOpts.url}${url.substr(1)}` 214 | }; 215 | } 216 | const actionOpts = actions[actionId]; 217 | const actionName: string = actionOpts.name 218 | ? actionOpts.name 219 | : getActionName(actionId, { 220 | resourceName, 221 | resourcePluralName, 222 | isArray: actionOpts.isArray, 223 | alias: actionOpts.alias 224 | }); 225 | actionFuncs[actionName] = createAction(actionId, { 226 | scope, 227 | ...globalOpts, 228 | ...actionOpts 229 | }); 230 | return actionFuncs; 231 | }, {}); 232 | }; 233 | 234 | export {getActionName, createAction, createActions}; 235 | -------------------------------------------------------------------------------- /src/actions/transform.ts: -------------------------------------------------------------------------------- 1 | import {defaultGlobals} from '../defaults'; 2 | 3 | export type AnyTransform = (value: T) => Promise; 4 | 5 | const buildTransformPipeline = ( 6 | initial: Array>, 7 | transform?: AnyTransform 8 | ): Array> => { 9 | let transformResponsePipeline: Array>; 10 | if (transform) { 11 | transformResponsePipeline = Array.isArray(transform) ? transform : [...initial, transform]; 12 | } else { 13 | transformResponsePipeline = [...initial]; 14 | } 15 | return transformResponsePipeline; 16 | }; 17 | 18 | const applyTransformPipeline = (pipeline: Array>) => (initial: T): Promise => 19 | pipeline.reduce>((soFar, fn) => soFar.then(fn), defaultGlobals.Promise.resolve(initial)); 20 | 21 | export {buildTransformPipeline, applyTransformPipeline}; 22 | -------------------------------------------------------------------------------- /src/defaults/index.ts: -------------------------------------------------------------------------------- 1 | import {ConfigActionOptions, DefaultActionVerb, State, UnknownObject} from '../typings'; 2 | 3 | const defaultActions: Record = { 4 | create: {method: 'POST', assignResponse: true}, 5 | fetch: {method: 'GET', isArray: true}, 6 | get: {method: 'GET'}, 7 | update: {method: 'PATCH'}, 8 | updateMany: {method: 'PATCH', isArray: true, alias: 'update'}, 9 | delete: {method: 'DELETE'}, 10 | deleteMany: {method: 'DELETE', isArray: true, alias: 'delete'} 11 | }; 12 | 13 | const defaultHeaders: RequestInit['headers'] = { 14 | Accept: 'application/json', 15 | 'Content-Type': 'application/json' 16 | }; 17 | 18 | const defaultIdKeys = { 19 | singular: 'id', 20 | plural: 'ids' 21 | }; 22 | 23 | const defaultState: Record> = { 24 | create: { 25 | isCreating: false 26 | }, 27 | fetch: { 28 | items: [], 29 | isFetching: false, 30 | lastUpdated: 0, 31 | didInvalidate: true 32 | }, 33 | get: { 34 | item: null, 35 | isFetchingItem: false, 36 | lastUpdatedItem: 0, 37 | didInvalidateItem: true 38 | }, 39 | update: { 40 | isUpdating: false 41 | }, 42 | delete: { 43 | isDeleting: false 44 | } 45 | }; 46 | 47 | const initialState: State = Object.keys(defaultState).reduce( 48 | (soFar, key) => ({...soFar, ...defaultState[key]}), 49 | {} as State 50 | ); 51 | 52 | export const defaultMergeItem = (prevItem: T | null, nextItem: Partial): T => { 53 | return Object.assign(prevItem || {}, nextItem) as T; 54 | }; 55 | 56 | const defaultGlobals = { 57 | Promise, 58 | fetch 59 | }; 60 | 61 | export {defaultGlobals, defaultActions, defaultHeaders, defaultIdKeys, defaultState, initialState}; 62 | -------------------------------------------------------------------------------- /src/defaults/pipeline.ts: -------------------------------------------------------------------------------- 1 | import {SerializableResponse} from '../helpers/fetch'; 2 | import {parseContentRangeHeader, toString} from '../helpers/util'; 3 | 4 | const defaultTransformResponsePipeline = [ 5 | // Default plugin to parse headers['Content-Range'] 6 | async (res: SerializableResponse): Promise => { 7 | const {status, headers} = res; 8 | const isPartialContent = status === 206; 9 | if (isPartialContent) { 10 | res.contentRange = parseContentRangeHeader(toString(headers['Content-Range'])); 11 | } 12 | return res; 13 | } 14 | ]; 15 | 16 | export {defaultTransformResponsePipeline}; 17 | -------------------------------------------------------------------------------- /src/helpers/fetch.ts: -------------------------------------------------------------------------------- 1 | import {defaultGlobals, defaultHeaders, defaultIdKeys} from '../defaults'; 2 | import {Context, FetchOptions, ContentRange} from '../typings'; 3 | import { 4 | encodeUriQuery, 5 | encodeUriSegment, 6 | replaceQueryStringParamFromUrl, 7 | replaceUrlParamFromUrl, 8 | splitUrlByProtocolAndDomain 9 | } from './url'; 10 | import {endsWith, isObject, isString, startsWith, toString} from './util'; 11 | 12 | export class HttpError extends Error { 13 | statusCode: number; 14 | status: number; 15 | body: unknown; 16 | headers: Record = {}; 17 | constructor( 18 | statusCode = 500, 19 | {body, message = 'HttpError', headers}: {body?: unknown; message?: string; headers?: Response['headers']} 20 | ) { 21 | super(message); 22 | this.name = this.constructor.name; 23 | this.message = message; 24 | if (typeof Error.captureStackTrace === 'function') { 25 | Error.captureStackTrace(this, this.constructor); 26 | } else { 27 | this.stack = new Error(message).stack; 28 | } 29 | // Http 30 | this.statusCode = statusCode; 31 | this.status = statusCode; 32 | this.body = body; 33 | if (headers) { 34 | this.headers = Object.fromEntries(headers.entries()); 35 | } 36 | } 37 | } 38 | 39 | type BuildFetchUrlOptions = { 40 | url: string; 41 | urlParams: Record; 42 | stripTrailingSlashes?: boolean; 43 | method?: string; 44 | params?: Record; 45 | isArray?: boolean; 46 | }; 47 | export const buildFetchUrl = ( 48 | context: Context, 49 | {url, method = 'get', urlParams, params = {}, isArray = false, stripTrailingSlashes = true}: BuildFetchUrlOptions 50 | ): string => { 51 | const [protocolAndDomain = '', remainderUrl] = splitUrlByProtocolAndDomain(url); 52 | const contextAsObject = !isObject(context) 53 | ? { 54 | [defaultIdKeys.singular]: context 55 | } 56 | : context; 57 | // Replace urlParams with values from context 58 | let builtUrl = Object.keys(urlParams).reduce((wipUrl, urlParam) => { 59 | const urlParamInfo = urlParams[urlParam]; 60 | const value = params[urlParam] || contextAsObject[urlParam] || ''; // self.defaults[urlParam]; 61 | if (value) { 62 | const encodedValue = urlParamInfo.isQueryParamValue 63 | ? encodeUriQuery(toString(value), true) 64 | : encodeUriSegment(toString(value)); 65 | return replaceUrlParamFromUrl(wipUrl, urlParam, encodedValue); 66 | } else if (!isArray && urlParam === defaultIdKeys.singular && !['post'].includes(method.toLowerCase())) { 67 | throw new Error(`Failed to resolve required "${urlParam}" from context=${JSON.stringify(context)}`); 68 | } 69 | return replaceUrlParamFromUrl(wipUrl, urlParam); 70 | }, remainderUrl); 71 | // Strip trailing slashes and set the url (unless this behavior is specifically disabled) 72 | if (stripTrailingSlashes) { 73 | builtUrl = builtUrl.replace(/\/+$/, '') || '/'; 74 | } 75 | return protocolAndDomain + builtUrl; 76 | }; 77 | 78 | export const buildFetchOpts = ( 79 | context: Context, 80 | {method, headers, credentials, query, body}: FetchOptions 81 | ): FetchOptions => { 82 | const opts: FetchOptions = { 83 | method: 'GET', 84 | headers: defaultHeaders 85 | }; 86 | if (method) { 87 | opts.method = method; 88 | } 89 | if (headers) { 90 | opts.headers = { 91 | ...opts.headers, 92 | ...headers 93 | }; 94 | } 95 | if (credentials) { 96 | opts.credentials = credentials; 97 | } 98 | if (query) { 99 | opts.query = query; 100 | } 101 | const hasBody = /^(POST|PUT|PATCH|DELETE)$/i.test(opts.method as string); 102 | if (body) { 103 | opts.body = isString(body) ? body : JSON.stringify(body); 104 | } else if (hasBody && context) { 105 | const contextAsObject = !isObject(context) 106 | ? { 107 | [defaultIdKeys.singular]: context 108 | } 109 | : context; 110 | opts.body = JSON.stringify(contextAsObject); 111 | } 112 | return opts; 113 | }; 114 | 115 | export const parseResponseBody = (res: Response): Promise => { 116 | const contentType = res.headers.get('Content-Type'); 117 | // @NOTE parses 'application/problem+json; charset=utf-8' for example 118 | // see https://tools.ietf.org/html/rfc6839 119 | const isJson = 120 | contentType && (startsWith(contentType, 'application/json') || endsWith(contentType.split(';')[0], '+json')); 121 | return res[isJson ? 'json' : 'text'](); 122 | }; 123 | 124 | export interface SerializableResponse { 125 | body: T; 126 | code: Response['status']; // @deprecated 127 | status: Response['status']; 128 | headers: Record; 129 | ok: Response['ok']; 130 | contentRange?: ContentRange | null; 131 | receivedAt: number; 132 | [s: string]: unknown; 133 | } 134 | 135 | export const serializeResponse = async (res: Response): Promise> => ({ 136 | body: await parseResponseBody(res), 137 | code: res.status, // @deprecated, 138 | status: res.status, 139 | headers: Object.fromEntries(res.headers.entries()), 140 | receivedAt: Date.now(), 141 | ok: res.ok 142 | }); 143 | 144 | const fetch = async (url: string, options: FetchOptions = {}): Promise => { 145 | // Support options.query 146 | const query = isObject(options.query) ? options.query : {}; 147 | const builtUrl = Object.keys(query).reduce((wipUrl, queryParam) => { 148 | const queryParamValue: string = isString(query[queryParam]) 149 | ? (query[queryParam] as string) 150 | : JSON.stringify(query[queryParam]); 151 | return replaceQueryStringParamFromUrl(wipUrl, queryParam, queryParamValue); 152 | }, url); 153 | return (options.Promise || defaultGlobals.Promise) 154 | .resolve((defaultGlobals.fetch || fetch)(builtUrl, options as RequestInit)) 155 | .then(async (res) => { 156 | if (!res.ok) { 157 | const body = await parseResponseBody(res); 158 | throw new HttpError(res.status, { 159 | body, 160 | message: res.statusText, 161 | headers: res.headers 162 | }); 163 | } 164 | return res; 165 | }); 166 | }; 167 | 168 | export default fetch; 169 | -------------------------------------------------------------------------------- /src/helpers/url.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/angular/angular.js/blob/master/src/ngResource/resource.js#L473 2 | 3 | const PROTOCOL_AND_DOMAIN_REGEX = /^https?:\/\/[^/]*/; 4 | const NUMBER_REGEX = /^[0-9]+$/; 5 | 6 | /** 7 | * This method is intended for encoding *key* or *value* parts of query component. We need a 8 | * custom method because encodeURIComponent is too aggressive and encodes stuff that doesn't 9 | * have to be encoded per http://tools.ietf.org/html/rfc3986 10 | */ 11 | export const encodeUriQuery = (val: string, pctEncodeSpaces: boolean): string => 12 | encodeURIComponent(val) 13 | .replace(/%40/gi, '@') 14 | .replace(/%3A/gi, ':') 15 | .replace(/%24/g, '$') 16 | .replace(/%2C/gi, ',') 17 | .replace(/%20/g, pctEncodeSpaces ? '%20' : '+'); 18 | 19 | /** 20 | * We need our custom method because encodeURIComponent is too aggressive and doesn't follow 21 | * http://www.ietf.org/rfc/rfc3986.txt with regards to the character set 22 | * (pchar) allowed in path segments 23 | */ 24 | export const encodeUriSegment = (val: string): string => 25 | encodeUriQuery(val, true).replace(/%26/gi, '&').replace(/%3D/gi, '=').replace(/%2B/gi, '+'); 26 | 27 | export const parseUrlParams = (url: string): Record => 28 | url.split(/\W/).reduce>((urlParams, param) => { 29 | if (!NUMBER_REGEX.test(param) && param && new RegExp(`(^|[^\\\\]):${param}(\\W|$)`).test(url)) { 30 | urlParams[param] = { 31 | // eslint-disable-line no-param-reassign 32 | isQueryParamValue: new RegExp(`\\?.*=:${param}(?:\\W|$)`).test(url) 33 | }; 34 | } 35 | return urlParams; 36 | }, {}); 37 | 38 | export const replaceUrlParamFromUrl = (url: string, urlParam: string, replace = ''): string => 39 | url.replace( 40 | new RegExp(`(/?):${urlParam}(\\W|$)`, 'g'), 41 | (_match, leadingSlashes, tail) => (replace ? leadingSlashes : '') + replace + tail 42 | ); 43 | 44 | export const replaceQueryStringParamFromUrl = (url: string, key: string, value: string): string => { 45 | const re = new RegExp(`([?&])${key}=.*?(&|$)`, 'i'); 46 | const sep = url.indexOf('?') !== -1 ? '&' : '?'; 47 | return url.match(re) ? url.replace(re, `$1${key}=${value}$2`) : `${url}${sep}${key}=${value}`; 48 | }; 49 | 50 | export const splitUrlByProtocolAndDomain = (url: string): [string, string] => { 51 | let protocolAndDomain = ''; 52 | const remainderUrl = url.replace(PROTOCOL_AND_DOMAIN_REGEX, (match) => { 53 | protocolAndDomain = match; 54 | return ''; 55 | }); 56 | return [protocolAndDomain, remainderUrl]; 57 | }; 58 | -------------------------------------------------------------------------------- /src/helpers/util.ts: -------------------------------------------------------------------------------- 1 | import {defaultIdKeys} from '../defaults'; 2 | import {Action, ContentRange} from '../typings'; 3 | 4 | // type UnknownObject = Record; 5 | 6 | export const includes = (array: unknown[], value: unknown): boolean => array.indexOf(value) !== -1; 7 | 8 | export const isString = (maybeString: unknown): maybeString is string => typeof maybeString === 'string'; 9 | 10 | export const toString = (value: unknown): string => String(value); 11 | 12 | export const isObject = (maybeObject: unknown): maybeObject is Record => 13 | typeof maybeObject === 'object'; 14 | 15 | export const isFunction = (maybeFunction: unknown): maybeFunction is Function => typeof maybeFunction === 'function'; 16 | 17 | export const pick = (obj: T, ...keys: Array): Partial => 18 | keys.reduce>((soFar, key) => { 19 | if (includes(keys, key) && obj[key] !== undefined) { 20 | soFar[key] = obj[key]; 21 | } 22 | return soFar; 23 | }, {}); 24 | 25 | export const find = >( 26 | collection: T[], 27 | query: Partial 28 | ): T | undefined => { 29 | const queryKeys = Object.keys(query); 30 | let foundItem: T | undefined; 31 | collection.some((item) => { 32 | const doesMatch = !queryKeys.some((key) => item[key] !== query[key]); 33 | if (doesMatch) { 34 | foundItem = item; 35 | } 36 | return doesMatch; 37 | }); 38 | return foundItem; 39 | }; 40 | 41 | export const mapObject = , V>( 42 | object: T, 43 | func: (v: V) => V 44 | ): Record => 45 | Object.keys(object).reduce>((soFar, key: string) => { 46 | soFar[key] = func(object[key]); 47 | return soFar; 48 | }, {}); 49 | 50 | type ObjectMap = Record>; 51 | 52 | export const mergeObjects = (object: ObjectMap, ...sources: ObjectMap[]): ObjectMap => { 53 | const {concat} = Array.prototype; 54 | const uniqueKeys: string[] = concat 55 | .apply(Object.keys(object), sources.map(Object.keys)) 56 | .filter((value: keyof ObjectMap, index: number, self: (keyof ObjectMap)[]) => self.indexOf(value) === index); 57 | return uniqueKeys.reduce((soFar, key) => { 58 | soFar[key] = Object.assign(soFar[key] || {}, ...sources.map((source) => source[key] || {})); 59 | return soFar; 60 | }, object); 61 | }; 62 | 63 | export const startsWith = (string: string, target: string): boolean => 64 | String(string).slice(0, target.length) === target; 65 | 66 | export const endsWith = (string: string, target: string): boolean => 67 | String(string).slice(string.length - target.length) === target; 68 | 69 | export const ucfirst = (string: string): string => string.charAt(0).toUpperCase() + string.substr(1); 70 | 71 | export const upperSnakeCase = (string: string): string => 72 | String( 73 | string.split('').reduce((soFar, letter, index) => { 74 | const charCode = letter.charCodeAt(0); 75 | return soFar + (index && charCode < 97 ? `_${letter}` : letter).toUpperCase(); 76 | }, '') 77 | ); 78 | 79 | export const getGerundName = (name: string): string => `${name.replace(/e$/, '')}ing`; 80 | 81 | export const getPluralName = (name = ''): string => (name.endsWith('s') ? name : `${name}s`); 82 | 83 | export const parseContentRangeHeader = (string: string): ContentRange | null => { 84 | if (typeof string === 'string') { 85 | const matches = string.match(/^(\w+) (\d+)-(\d+)\/(\d+|\*)/); 86 | if (matches) { 87 | return { 88 | unit: matches[1], 89 | first: +matches[2], 90 | last: +matches[3], 91 | length: matches[4] === '*' ? null : +matches[4] 92 | }; 93 | } 94 | } 95 | return null; 96 | }; 97 | 98 | export const getIdKey = (_action: Action, {multi = false}: {multi: boolean}): string => 99 | multi ? defaultIdKeys.plural : defaultIdKeys.singular; 100 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/angular/angular.js/blob/master/src/ngResource/resource.js 2 | // var User = $resource('/user/:userId', {userId:'@id'}); 3 | 4 | import {createAction, CreateActionOptions, createActions, getActionName} from './actions'; 5 | import {defaultActions} from './defaults'; 6 | import fetch, {HttpError} from './helpers/fetch'; 7 | import {mergeObjects, pick, getPluralName} from './helpers/util'; 8 | import {createReducer, createReducers, createRootReducer} from './reducers'; 9 | import {createType, createTypes, getTypesScope, scopeTypes} from './types'; 10 | import {ConfigActionsOptions, AsyncActionCreator, Reducer, Types, UnknownObject, ReduceOptions} from './typings'; 11 | export * from './defaults'; 12 | export {combineReducers, mergeReducers, reduceReducers} from './reducers/helpers'; 13 | export * from './typings'; 14 | export {fetch, HttpError}; 15 | 16 | export type CreateResourceOptions = CreateActionOptions & { 17 | url: string; 18 | name: string; 19 | pluralName?: string; 20 | actions?: ConfigActionsOptions; 21 | mergeDefaultActions?: boolean; 22 | pick?: string[]; 23 | scope?: string; 24 | }; 25 | 26 | export type Resource = { 27 | actions: Record; 28 | reducers: Reducer; 29 | rootReducer: Reducer; 30 | types: Types; 31 | }; 32 | 33 | export function createResource({ 34 | url, 35 | name: resourceName, 36 | pluralName: resourcePluralName = getPluralName(resourceName), 37 | actions: givenActions = {}, 38 | mergeDefaultActions = true, 39 | pick: pickedActions = [], 40 | scope, 41 | ...otherOptions 42 | }: CreateResourceOptions): Resource { 43 | // Merge passed actions with common defaults 44 | let resolvedActions = mergeDefaultActions 45 | ? (mergeObjects({}, defaultActions, givenActions) as ConfigActionsOptions) 46 | : givenActions; 47 | // Eventually pick selected actions 48 | if (pickedActions.length) { 49 | resolvedActions = pick(resolvedActions, ...pickedActions) as ConfigActionsOptions; 50 | } 51 | const types = createTypes(resolvedActions, {resourceName, resourcePluralName, scope}); 52 | const actions = createActions(resolvedActions, {resourceName, resourcePluralName, scope, url, ...otherOptions}); 53 | const reducers = createReducers(resolvedActions, otherOptions as ReduceOptions); 54 | const rootReducer = createRootReducer(reducers, {resourceName, scope}); 55 | return { 56 | actions, 57 | reducers: rootReducer, // breaking change 58 | rootReducer, 59 | types 60 | }; 61 | } 62 | 63 | export type CreateResourceActionOptions = CreateActionOptions & { 64 | name: string; 65 | pluralName: string; 66 | method?: RequestInit['method']; 67 | isArray?: boolean; 68 | }; 69 | 70 | export type ResourceAction = { 71 | actions: Record; 72 | reducers: Record; 73 | rootReducer: Reducer; 74 | types: Record; 75 | }; 76 | 77 | export function createResourceAction({ 78 | name: resourceName, 79 | pluralName: resourcePluralName, 80 | method = 'GET', 81 | isArray = false, 82 | ...args 83 | }: CreateResourceActionOptions): ResourceAction { 84 | const actionId = method.toLowerCase(); 85 | const scope = getTypesScope(resourceName); 86 | const types = scopeTypes(createType(actionId, {resourceName, resourcePluralName, isArray}), scope); 87 | const actionName = getActionName(actionId, {resourceName, resourcePluralName}); 88 | const actions = {[actionName]: createAction(actionId, {scope, isArray, ...args})}; 89 | const reducers = {[actionId]: createReducer(actionId, args)}; 90 | const rootReducer = createRootReducer(reducers, {resourceName, scope}); 91 | return { 92 | actions, 93 | reducers, // new API 94 | rootReducer, 95 | types 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /src/reducers/helpers.ts: -------------------------------------------------------------------------------- 1 | import {AnyAction, Reducer} from 'redux'; 2 | 3 | const reduceReducers = (...reducers: Reducer[]): Reducer => (state, action): S => 4 | reducers.reduce((stateSoFar, reducer) => reducer(stateSoFar, action), state as S); 5 | 6 | const combineReducers = (...reducers: Record>[]): Reducer => ( 7 | state, 8 | action 9 | ): S => 10 | reducers.reduce( 11 | (stateSoFar, reducerMap) => 12 | Object.keys(reducerMap).reduce((innerStateSoFar, key) => { 13 | const reducer = reducerMap[key]; 14 | const previousStateForKey = (stateSoFar[key as keyof S] as unknown) as S; 15 | const nextStateForKey = reducer(previousStateForKey, action); 16 | return {...innerStateSoFar, [key]: nextStateForKey}; 17 | }, stateSoFar), 18 | state as S 19 | ); 20 | 21 | const mergeReducers = ( 22 | baseReducer: Reducer, 23 | ...reducers: Record>[] 24 | ): Reducer => { 25 | const combinedReducers = combineReducers(...reducers); 26 | return (state, action): S => combinedReducers(baseReducer(state, action), action); 27 | }; 28 | 29 | export {reduceReducers, combineReducers, mergeReducers}; 30 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import {isPlainObject} from 'lodash'; 2 | import {defaultMergeItem, initialState as defaultInitialState} from '../defaults'; 3 | import {find, getGerundName, getIdKey, isFunction, isObject, isString, ucfirst} from '../helpers/util'; 4 | import {getActionType, getTypesScope} from '../types'; 5 | import { 6 | Action, 7 | ConfigActionsOptions, 8 | Context, 9 | ReduceOptions, 10 | Reducer, 11 | ReducerMapObject, 12 | State, 13 | UnknownObject 14 | } from '../typings'; 15 | 16 | const getUpdateArrayData = (action: Action, itemId: string | number): UnknownObject | undefined => { 17 | const actionOpts = action.options || {}; 18 | const idKey = getIdKey(action, {multi: false}); 19 | if (!isPlainObject(action.context)) { 20 | return {}; 21 | } 22 | const actionContext = action.context as UnknownObject; 23 | 24 | return actionOpts.assignResponse 25 | ? find(action.payload?.body as Array, { 26 | [idKey]: itemId 27 | }) 28 | : Object.keys(actionContext).reduce((soFar, key) => { 29 | if (key !== 'ids') { 30 | soFar[key] = actionContext[key as keyof Context]; 31 | } 32 | return soFar; 33 | }, {}); 34 | }; 35 | 36 | const getIdFromAction = (action: Action, {multi}: {multi: boolean}): [string, unknown] => { 37 | const idKey = getIdKey(action, {multi}); 38 | const {context, options = {}} = action; 39 | const {params = {}} = options; 40 | if (params[idKey]) { 41 | return [idKey, params[idKey]]; 42 | } 43 | if (isObject(context) && context[idKey]) { 44 | return [idKey, context[idKey]]; 45 | } 46 | if (isString(context)) { 47 | return [idKey, context]; 48 | } 49 | return [idKey, undefined]; 50 | // throw new Error( 51 | // `Failed to resolve id with key="${idKey}" from context=${JSON.stringify(context)} or params=${JSON.stringify( 52 | // params 53 | // )}` 54 | // ); 55 | }; 56 | 57 | const createDefaultReducers = (reduceOptions: ReduceOptions): ReducerMapObject => { 58 | const initialState = defaultInitialState as State; 59 | const mergeItem = reduceOptions.mergeItem || defaultMergeItem; 60 | return { 61 | create: (state = initialState, action): State => { 62 | const actionOpts = action.options || {}; 63 | switch (action.status) { 64 | case 'pending': 65 | // @TODO optimistic updates? 66 | return { 67 | ...state, 68 | isCreating: true 69 | }; 70 | case 'resolved': { 71 | const nextState: Partial> = {}; 72 | if (actionOpts.assignResponse) { 73 | const createdItem = action.payload?.body as T; 74 | nextState.items = (state.items || []).concat(createdItem); 75 | } 76 | return { 77 | ...state, 78 | isCreating: false, 79 | ...nextState 80 | }; 81 | } 82 | case 'rejected': 83 | return { 84 | ...state, 85 | isCreating: false 86 | }; 87 | default: 88 | return state; 89 | } 90 | }, 91 | fetch: (state = initialState, action) => { 92 | switch (action.status) { 93 | case 'pending': { 94 | const actionOpts = action.options || {}; 95 | const didInvalidate = !!actionOpts.invalidateState; 96 | return { 97 | ...state, 98 | isFetching: true, 99 | didInvalidate, 100 | ...(didInvalidate ? {items: []} : {}) 101 | }; 102 | } 103 | case 'resolved': { 104 | const nextState: Partial> = {}; 105 | const {body, contentRange, code, receivedAt = Date.now()} = action.payload || {}; 106 | nextState.items = body as T[]; 107 | const isPartialContent = code === 206; 108 | if (isPartialContent && contentRange && contentRange.first > 0) { 109 | nextState.items = state.items 110 | .slice(0, contentRange.first) 111 | .concat(nextState.items) 112 | .concat(state.items.slice(contentRange.last, state.items.length)); 113 | } 114 | return { 115 | ...state, 116 | isFetching: false, 117 | didInvalidate: false, 118 | lastUpdated: receivedAt, 119 | ...nextState 120 | }; 121 | } 122 | case 'rejected': 123 | return { 124 | ...state, 125 | isFetching: false 126 | }; 127 | default: 128 | return state; 129 | } 130 | }, 131 | get: (state = initialState, action) => { 132 | const actionOpts = action.options || {}; 133 | switch (action.status) { 134 | case 'pending': { 135 | const [idKey, id] = getIdFromAction(action, {multi: false}); 136 | const hasConflictingContext = id && state.item ? state.item[idKey] !== id : false; 137 | const didInvalidate = !!actionOpts.invalidateState || hasConflictingContext; 138 | return { 139 | ...state, 140 | isFetchingItem: true, 141 | didInvalidateItem: didInvalidate, 142 | ...(didInvalidate ? {item: null} : {}) 143 | }; 144 | } 145 | case 'resolved': { 146 | const {body, receivedAt = Date.now()} = action.payload || {}; 147 | const partialItem = body as Partial; 148 | const nextItem = (actionOpts.mergeResponse ? mergeItem(state.item, partialItem) : partialItem) as T; 149 | const nextState: Partial> = {item: {...nextItem}}; 150 | if (actionOpts.assignResponse) { 151 | const idKey = getIdKey(action, {multi: false}); 152 | const prevListItemIndex = state.items.findIndex((el) => el[idKey] === partialItem[idKey]); 153 | if (prevListItemIndex !== -1) { 154 | const prevListItem = state.items[prevListItemIndex]; 155 | const nextListItem = actionOpts.mergeResponse ? mergeItem(prevListItem, partialItem) : (partialItem as T); 156 | state.items.splice(prevListItemIndex, 1, nextListItem); 157 | nextState.items = state.items.slice(); 158 | } 159 | } 160 | return { 161 | ...state, 162 | isFetchingItem: false, 163 | didInvalidateItem: false, 164 | lastUpdatedItem: receivedAt, 165 | ...nextState 166 | }; 167 | } 168 | case 'rejected': 169 | return { 170 | ...state, 171 | isFetchingItem: false 172 | }; 173 | default: 174 | return state; 175 | } 176 | }, 177 | update: (state = initialState, action) => { 178 | const actionOpts = action.options || {}; 179 | switch (action.status) { 180 | case 'pending': 181 | // Update object in store as soon as possible? 182 | return { 183 | ...state, 184 | isUpdating: true 185 | }; 186 | case 'resolved': { 187 | // Assign context or returned object 188 | const [idKey, id] = getIdFromAction(action, {multi: false}); 189 | const update = (actionOpts.assignResponse ? action.payload?.body : action.context) as Partial; 190 | const listItemIndex = state.items.findIndex((el) => el[idKey] === id); 191 | const updatedItems = state.items.slice(); 192 | if (listItemIndex !== -1) { 193 | updatedItems[listItemIndex] = { 194 | ...updatedItems[listItemIndex], 195 | ...update 196 | }; 197 | } 198 | const updatedItem = state.item && state.item[idKey] === id ? mergeItem(state.item, update) : state.item; 199 | return { 200 | ...state, 201 | isUpdating: false, 202 | items: updatedItems, 203 | item: updatedItem 204 | }; 205 | } 206 | case 'rejected': 207 | return { 208 | ...state, 209 | isUpdating: false 210 | }; 211 | default: 212 | return state; 213 | } 214 | }, 215 | updateMany: (state = initialState, action) => { 216 | switch (action.status) { 217 | case 'pending': 218 | // Update object in store as soon as possible? 219 | return { 220 | ...state, 221 | isUpdatingMany: true 222 | }; 223 | case 'resolved': { 224 | // Assign context or returned object 225 | const idKey = getIdKey(action, {multi: false}); 226 | const [, ids] = getIdFromAction(action, {multi: true}); 227 | 228 | const updatedItems = state.items.map((item) => { 229 | if (!ids || (ids as string[]).includes(item[idKey] as string)) { 230 | const updatedItem = getUpdateArrayData(action, item[idKey] as string); 231 | return updatedItem 232 | ? { 233 | ...item, 234 | ...updatedItem 235 | } 236 | : item; 237 | } 238 | return item; 239 | }); 240 | // Also impact state.item? (@TODO opt-in/defautl?) 241 | const updatedItem = 242 | state.item && (!ids || (ids as string[]).includes(state.item[idKey] as string)) 243 | ? { 244 | ...state.item, 245 | ...getUpdateArrayData(action, state.item[idKey] as string) 246 | } 247 | : state.item; 248 | return { 249 | ...state, 250 | isUpdatingMany: false, 251 | items: updatedItems, 252 | item: updatedItem 253 | }; 254 | } 255 | case 'rejected': 256 | return { 257 | ...state, 258 | isUpdatingMany: false 259 | }; 260 | default: 261 | return state; 262 | } 263 | }, 264 | delete: (state = initialState, action) => { 265 | switch (action.status) { 266 | case 'pending': 267 | // Update object in store as soon as possible? 268 | return { 269 | ...state, 270 | isDeleting: true 271 | }; 272 | case 'resolved': { 273 | // @NOTE Do not update items array when an empty context was provided 274 | // Can happen with custom resource not using id params 275 | if (!action.context) { 276 | return {...state, isDeleting: false}; 277 | } 278 | const [idKey, id] = getIdFromAction(action, {multi: false}); 279 | return { 280 | ...state, 281 | isDeleting: false, 282 | items: [...state.items.filter((el) => id && el[idKey] !== id)] 283 | }; 284 | } 285 | case 'rejected': 286 | return { 287 | ...state, 288 | isDeleting: false 289 | }; 290 | default: 291 | return state; 292 | } 293 | }, 294 | deleteMany: (state = initialState, action) => { 295 | switch (action.status) { 296 | case 'pending': 297 | // Update object in store as soon as possible? 298 | return { 299 | ...state, 300 | isDeletingMany: true 301 | }; 302 | case 'resolved': { 303 | const idKey = getIdKey(action, {multi: false}); 304 | const [, ids] = getIdFromAction(action, {multi: true}); 305 | 306 | if (!ids) { 307 | return { 308 | ...state, 309 | isDeletingMany: false, 310 | items: [], 311 | item: null 312 | }; 313 | } 314 | return { 315 | ...state, 316 | isDeletingMany: false, 317 | items: [...state.items.filter((el) => !(ids as string[]).includes(el[idKey] as string))], 318 | item: state.item && (ids as string[]).includes(state.item[idKey] as string) ? null : state.item 319 | }; 320 | } 321 | case 'rejected': 322 | return { 323 | ...state, 324 | isDeletingMany: false 325 | }; 326 | default: 327 | return state; 328 | } 329 | } 330 | }; 331 | }; 332 | 333 | const createReducer = ( 334 | actionId: string, 335 | reduceOptions: ReduceOptions, 336 | defaultReducers = createDefaultReducers(reduceOptions) 337 | ): Reducer => { 338 | // Custom reducers 339 | if (reduceOptions.reduce && isFunction(reduceOptions.reduce)) { 340 | return reduceOptions.reduce; 341 | } 342 | // Do require a custom reduce function for pure actions 343 | if (reduceOptions.isPure) { 344 | throw new Error(`Missing \`reduce\` option for pure action \`${actionId}\``); 345 | } 346 | // Default reducers 347 | if (defaultReducers[actionId]) { 348 | return defaultReducers[actionId] as Reducer; 349 | } 350 | // Custom actions 351 | const gerundName = reduceOptions.gerundName || getGerundName(actionId); 352 | const gerundStateKey = `is${ucfirst(gerundName)}`; 353 | return (state = defaultInitialState as State, action): State => { 354 | switch (action.status) { 355 | case 'pending': 356 | // Update object in store as soon as possible? 357 | return { 358 | ...state, 359 | [gerundStateKey]: true 360 | }; 361 | case 'resolved': // eslint-disable-line 362 | return { 363 | ...state, 364 | [gerundStateKey]: false 365 | }; 366 | case 'rejected': 367 | return { 368 | ...state, 369 | [gerundStateKey]: false 370 | }; 371 | default: 372 | return state; 373 | } 374 | }; 375 | }; 376 | 377 | const createReducers = ( 378 | actions: ConfigActionsOptions = {}, 379 | reduceOptions: ReduceOptions = {} 380 | ): ReducerMapObject => { 381 | const actionKeys = Object.keys(actions); 382 | const defaultReducers = createDefaultReducers(reduceOptions); 383 | return actionKeys.reduce>((actionReducers, actionId) => { 384 | // d(omit(actions[actionId], SUPPORTED_REDUCE_OPTS)); 385 | const combinedReduceOptions: ReduceOptions = { 386 | ...reduceOptions, 387 | ...actions[actionId] 388 | // ...pick(actions[actionId], (SUPPORTED_REDUCE_OPTS as unknown) as keyof ConfigActionOptions) 389 | }; 390 | const reducerKey = getActionType(actionId).toLowerCase(); 391 | actionReducers[reducerKey] = createReducer(actionId, combinedReduceOptions, defaultReducers); 392 | return actionReducers; 393 | }, {}); 394 | }; 395 | 396 | type CreateRootReducerOptions = { 397 | resourceName: string; 398 | scope?: string; 399 | }; 400 | 401 | const createRootReducer = ( 402 | reducers: ReducerMapObject = {}, 403 | {resourceName, scope = getTypesScope(resourceName)}: CreateRootReducerOptions 404 | ): Reducer => { 405 | const scopeNamespace = scope ? `${scope}/` : ''; 406 | const rootReducer: Reducer = ( 407 | state = { 408 | ...(defaultInitialState as State) 409 | }, 410 | action 411 | ) => { 412 | // Only process relevant namespace 413 | if (scopeNamespace && !String(action.type).startsWith(scopeNamespace)) { 414 | return state; 415 | } 416 | // Only process relevant action type 417 | const type = action.type.substr(scopeNamespace.length).toLowerCase(); 418 | // Check for a matching reducer 419 | if (reducers[type]) { 420 | return reducers[type](state, action); 421 | } 422 | return state; 423 | }; 424 | return rootReducer; 425 | }; 426 | 427 | export {defaultInitialState as initialState, createReducer, createReducers, createRootReducer}; 428 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {getPluralName, mapObject, upperSnakeCase} from './helpers/util'; 2 | import {ConfigActionsOptions, Types} from './typings'; 3 | 4 | const scopeType = (type: string, scope?: string): string => (scope ? `${scope}/${type}` : type); 5 | 6 | const scopeTypes = (types: Types = {}, scope: string): Types => 7 | scope ? mapObject(types, (type) => scopeType(type, scope)) : types; 8 | 9 | const getTypesScope = (resourceName: string): string => 10 | resourceName ? `@@resource/${upperSnakeCase(resourceName)}` : ''; 11 | 12 | type GetActionTypeKeyOptions = { 13 | resourceName: string; 14 | resourcePluralName?: string; 15 | isArray?: boolean; 16 | }; 17 | 18 | const getActionTypeKey = ( 19 | actionId: string, 20 | {resourceName, resourcePluralName = getPluralName(resourceName), isArray = false}: GetActionTypeKeyOptions 21 | ): string => 22 | resourceName 23 | ? `${actionId.toUpperCase()}_${upperSnakeCase(isArray ? resourcePluralName : resourceName)}` 24 | : upperSnakeCase(actionId); 25 | 26 | const getActionType = (actionId: string): string => upperSnakeCase(actionId); 27 | 28 | type CreateTypeOptions = { 29 | resourceName: string; 30 | resourcePluralName?: string; 31 | isArray?: boolean; 32 | alias?: string; 33 | }; 34 | 35 | const createType = ( 36 | actionId: string, 37 | {resourceName, resourcePluralName, isArray = false, alias}: CreateTypeOptions 38 | ): Types => { 39 | const typeKey = getActionTypeKey(resourceName ? alias || actionId : actionId, { 40 | resourceName, 41 | resourcePluralName, 42 | isArray 43 | }); 44 | return { 45 | [typeKey]: getActionType(actionId) 46 | }; 47 | }; 48 | 49 | type CreateTypesOptions = { 50 | resourceName: string; 51 | resourcePluralName?: string; 52 | scope?: string; 53 | }; 54 | 55 | const createTypes = ( 56 | actions: ConfigActionsOptions = {}, 57 | {resourceName, resourcePluralName, scope = getTypesScope(resourceName)}: CreateTypesOptions 58 | ): Types => { 59 | const rawTypes = Object.keys(actions).reduce((types, actionId) => { 60 | const actionOpts = actions[actionId]; 61 | return Object.assign( 62 | types, 63 | createType(actionId, { 64 | resourceName, 65 | resourcePluralName, 66 | isArray: actionOpts.isArray, 67 | alias: actionOpts.alias 68 | }) 69 | ); 70 | }, {}); 71 | return scopeTypes(rawTypes, scope); 72 | }; 73 | 74 | export {scopeType, scopeTypes, getTypesScope, createType, createTypes, getActionType, getActionTypeKey}; 75 | -------------------------------------------------------------------------------- /src/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | interface ErrorConstructor { 4 | captureStackTrace(targetObject: Error, constructorOpt?: Function): void; 5 | } 6 | -------------------------------------------------------------------------------- /src/typings/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import {Action as ReduxAction, Reducer as ReduxReducer} from 'redux'; 4 | import {ThunkAction} from 'redux-thunk'; 5 | import {SerializableResponse} from 'src/helpers/fetch'; 6 | 7 | export type UnknownObject = Record; 8 | 9 | export type SupportedReduceConfigActionOptionKeys = 10 | | 'invalidateState' 11 | | 'assignResponse' 12 | | 'mergeResponse' 13 | | 'isArray' 14 | | 'isPure'; 15 | export type SupportedReduceConfigActionOptions = Pick; 16 | export type SupportedFetchConfigActionOptionKeys = 'url' | 'method' | 'headers' | 'credentials' | 'query' | 'body'; 17 | export type SupportedFetchConfigActionOptions = Pick; 18 | 19 | export type ConfigActionOptions = SupportedReduceConfigActionOptions & 20 | SupportedFetchConfigActionOptions & { 21 | alias?: string; 22 | gerundName?: string; 23 | name?: string; 24 | }; 25 | 26 | export type DefaultActionVerb = 'create' | 'fetch' | 'get' | 'update' | 'updateMany' | 'delete' | 'deleteMany'; 27 | 28 | export type ConfigActionsOptions = Record; 29 | 30 | export type State = { 31 | didInvalidate: boolean; 32 | didInvalidateItem: boolean; 33 | isCreating: boolean; 34 | isDeleting: boolean; 35 | isFetching: boolean; 36 | isFetchingItem: boolean; 37 | isUpdating: boolean; 38 | item: T | null; 39 | items: Array; 40 | lastUpdated: number; 41 | lastUpdatedItem: number; 42 | }; 43 | 44 | export type ReduceOptions = { 45 | invalidateState?: boolean; 46 | assignResponse?: boolean; 47 | mergeResponse?: boolean; 48 | isArray?: boolean; 49 | isPure?: boolean; 50 | reduce?: Reducer; 51 | mergeItem?: (prev: T | null, next: Partial) => T; 52 | gerundName?: string; 53 | params?: Record; 54 | }; 55 | 56 | export type FetchOptions = Pick & { 57 | url?: string; 58 | query?: Record; 59 | body?: string | Record | Array; 60 | Promise?: PromiseConstructor; 61 | }; 62 | 63 | export type Context = undefined | string | Record; 64 | 65 | // @TODO vs ActionOptions? 66 | export type ContextOptions = Partial; 67 | 68 | export type ContentRange = { 69 | unit: string | number; 70 | first: number; 71 | last: number; 72 | length: number | null; 73 | }; 74 | 75 | export type Types = Record; 76 | 77 | export type Action = ReduxAction & { 78 | status: 'pending' | 'resolved' | 'rejected'; 79 | options: ContextOptions; 80 | context: Context; 81 | payload?: Partial>; 82 | }; 83 | 84 | // export type RequiredReduxReducer = ( 85 | // state: S, 86 | // action: A 87 | // ) => S; 88 | export type Reducer = ReduxReducer, Action>; 89 | export type ReducerMapObject = Record>; 90 | 91 | export type AsyncActionCreator = ( 92 | context?: Context, 93 | contextOpts?: ContextOptions 94 | ) => ThunkAction, State, void, Action>; 95 | 96 | export type AsyncActionCreatorsMapObject = Record>; 97 | 98 | export type UnwrapAsyncActionCreatorsMapObject< 99 | T extends Record 100 | > = T extends AsyncActionCreatorsMapObject 101 | ? Record Promise>> 102 | : T; 103 | 104 | export type BeforeErrorPipeline = Array<(err: Error) => Error | null>; 105 | 106 | /* 107 | export type ThunkAction = ( 108 | dispatch: ThunkDispatch, 109 | getState: () => S, 110 | extraArgument: E 111 | ) => R; 112 | */ 113 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./../.eslintrc", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "jest": true, 6 | "jasmine": true 7 | }, 8 | "rules": { 9 | "func-names": "off", 10 | "no-underscore-dangle": "off", 11 | "import/no-extraneous-dependencies": "off", 12 | "react/destructuring-assignment": "off", 13 | "max-len": ["warn", {"code": 120, "ignoreComments": true}] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill'; 2 | import {warn} from 'console'; 3 | import {inspect} from 'util'; 4 | 5 | declare global { 6 | namespace NodeJS { 7 | interface Global { 8 | d: Console['warn']; 9 | dd: Console['warn']; 10 | } 11 | } 12 | } 13 | 14 | global.d = (...args: unknown[]) => warn(inspect(args.length > 1 ? args : args[0], {colors: true, depth: 10})); 15 | global.dd = (...args: unknown[]) => { 16 | global.d(...args); 17 | expect(1).toEqual(2); 18 | }; 19 | -------------------------------------------------------------------------------- /test/spec/__snapshots__/actions.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`custom actions .editFolder() 1`] = ` 4 | Array [ 5 | Object { 6 | "context": Object { 7 | "folder": 2, 8 | "id": 1, 9 | "name": "New Name", 10 | }, 11 | "options": Object {}, 12 | "status": "pending", 13 | "type": "@@resource/USER/EDIT_FOLDER", 14 | }, 15 | Object { 16 | "context": Object { 17 | "folder": 2, 18 | "id": 1, 19 | "name": "New Name", 20 | }, 21 | "options": Object {}, 22 | "payload": Object { 23 | "body": Object { 24 | "folder": 2, 25 | "name": "New Name", 26 | }, 27 | "code": 200, 28 | "headers": Object { 29 | "content-type": "application/json", 30 | }, 31 | "ok": true, 32 | "receivedAt": null, 33 | "status": 200, 34 | }, 35 | "status": "resolved", 36 | "type": "@@resource/USER/EDIT_FOLDER", 37 | }, 38 | ] 39 | `; 40 | 41 | exports[`custom actions .merge() 1`] = ` 42 | Array [ 43 | Object { 44 | "context": Object {}, 45 | "options": Object { 46 | "isArray": true, 47 | }, 48 | "status": "pending", 49 | "type": "@@resource/USER/MERGE", 50 | }, 51 | Object { 52 | "context": Object {}, 53 | "options": Object { 54 | "isArray": true, 55 | }, 56 | "payload": Object { 57 | "body": Array [ 58 | Object { 59 | "firstName": "Olivier", 60 | "id": 1, 61 | }, 62 | ], 63 | "code": 200, 64 | "headers": Object { 65 | "content-type": "application/json", 66 | }, 67 | "ok": true, 68 | "receivedAt": null, 69 | "status": 200, 70 | }, 71 | "status": "resolved", 72 | "type": "@@resource/USER/MERGE", 73 | }, 74 | ] 75 | `; 76 | 77 | exports[`custom actions .promote() 1`] = ` 78 | Array [ 79 | Object { 80 | "context": Object { 81 | "firstName": "Olivier", 82 | "id": 1, 83 | }, 84 | "options": Object {}, 85 | "status": "pending", 86 | "type": "@@resource/USER/PROMOTE", 87 | }, 88 | Object { 89 | "context": Object { 90 | "firstName": "Olivier", 91 | "id": 1, 92 | }, 93 | "options": Object {}, 94 | "payload": Object { 95 | "body": Object { 96 | "ok": true, 97 | }, 98 | "code": 200, 99 | "headers": Object { 100 | "content-type": "application/json", 101 | }, 102 | "ok": true, 103 | "receivedAt": null, 104 | "status": 200, 105 | }, 106 | "status": "resolved", 107 | "type": "@@resource/USER/PROMOTE", 108 | }, 109 | ] 110 | `; 111 | 112 | exports[`custom actions with a custom action name 1`] = ` 113 | Array [ 114 | Object { 115 | "context": Object { 116 | "id": 1, 117 | }, 118 | "options": Object { 119 | "gerundName": "fetchingApplications", 120 | "isArray": true, 121 | }, 122 | "status": "pending", 123 | "type": "@@resource/USER/APPLICATIONS", 124 | }, 125 | Object { 126 | "context": Object { 127 | "id": 1, 128 | }, 129 | "options": Object { 130 | "gerundName": "fetchingApplications", 131 | "isArray": true, 132 | }, 133 | "payload": Object { 134 | "body": Array [ 135 | Object { 136 | "id": 1, 137 | "name": "Foo", 138 | }, 139 | ], 140 | "code": 200, 141 | "headers": Object { 142 | "content-type": "application/json", 143 | }, 144 | "ok": true, 145 | "receivedAt": null, 146 | "status": 200, 147 | }, 148 | "status": "resolved", 149 | "type": "@@resource/USER/APPLICATIONS", 150 | }, 151 | ] 152 | `; 153 | 154 | exports[`custom pure actions .clear() 1`] = ` 155 | Array [ 156 | Object { 157 | "context": Object { 158 | "firstName": "Olivier", 159 | "id": 1, 160 | }, 161 | "options": Object { 162 | "isPure": true, 163 | "reduce": [Function], 164 | }, 165 | "status": "resolved", 166 | "type": "@@resource/USER/CLEAR", 167 | }, 168 | ] 169 | `; 170 | 171 | exports[`defaultActions crudOperations .create() 1`] = ` 172 | Object { 173 | "body": Object { 174 | "ok": true, 175 | }, 176 | "code": 200, 177 | "headers": Object { 178 | "content-type": "application/json", 179 | }, 180 | "ok": true, 181 | "receivedAt": null, 182 | "status": 200, 183 | } 184 | `; 185 | 186 | exports[`defaultActions crudOperations .create() 2`] = ` 187 | Array [ 188 | Object { 189 | "context": Object { 190 | "firstName": "Olivier", 191 | }, 192 | "options": Object { 193 | "assignResponse": true, 194 | }, 195 | "status": "pending", 196 | "type": "@@resource/USER/CREATE", 197 | }, 198 | Object { 199 | "context": Object { 200 | "firstName": "Olivier", 201 | }, 202 | "options": Object { 203 | "assignResponse": true, 204 | }, 205 | "payload": Object { 206 | "body": Object { 207 | "ok": true, 208 | }, 209 | "code": 200, 210 | "headers": Object { 211 | "content-type": "application/json", 212 | }, 213 | "ok": true, 214 | "receivedAt": null, 215 | "status": 200, 216 | }, 217 | "status": "resolved", 218 | "type": "@@resource/USER/CREATE", 219 | }, 220 | ] 221 | `; 222 | 223 | exports[`defaultActions crudOperations .delete() 1`] = ` 224 | Object { 225 | "body": Object { 226 | "ok": true, 227 | }, 228 | "code": 200, 229 | "headers": Object { 230 | "content-type": "application/json", 231 | }, 232 | "ok": true, 233 | "receivedAt": null, 234 | "status": 200, 235 | } 236 | `; 237 | 238 | exports[`defaultActions crudOperations .delete() 2`] = ` 239 | Array [ 240 | Object { 241 | "context": Object { 242 | "id": 1, 243 | }, 244 | "options": Object {}, 245 | "status": "pending", 246 | "type": "@@resource/USER/DELETE", 247 | }, 248 | Object { 249 | "context": Object { 250 | "id": 1, 251 | }, 252 | "options": Object {}, 253 | "payload": Object { 254 | "body": Object { 255 | "ok": true, 256 | }, 257 | "code": 200, 258 | "headers": Object { 259 | "content-type": "application/json", 260 | }, 261 | "ok": true, 262 | "receivedAt": null, 263 | "status": 200, 264 | }, 265 | "status": "resolved", 266 | "type": "@@resource/USER/DELETE", 267 | }, 268 | ] 269 | `; 270 | 271 | exports[`defaultActions crudOperations .fetch() 1`] = ` 272 | Object { 273 | "body": Array [ 274 | Object { 275 | "firstName": "Olivier", 276 | "id": 1, 277 | }, 278 | ], 279 | "code": 200, 280 | "headers": Object { 281 | "content-type": "application/json", 282 | }, 283 | "ok": true, 284 | "receivedAt": null, 285 | "status": 200, 286 | } 287 | `; 288 | 289 | exports[`defaultActions crudOperations .fetch() 2`] = ` 290 | Array [ 291 | Object { 292 | "context": Object {}, 293 | "options": Object { 294 | "isArray": true, 295 | }, 296 | "status": "pending", 297 | "type": "@@resource/USER/FETCH", 298 | }, 299 | Object { 300 | "context": Object {}, 301 | "options": Object { 302 | "isArray": true, 303 | }, 304 | "payload": Object { 305 | "body": Array [ 306 | Object { 307 | "firstName": "Olivier", 308 | "id": 1, 309 | }, 310 | ], 311 | "code": 200, 312 | "headers": Object { 313 | "content-type": "application/json", 314 | }, 315 | "ok": true, 316 | "receivedAt": null, 317 | "status": 200, 318 | }, 319 | "status": "resolved", 320 | "type": "@@resource/USER/FETCH", 321 | }, 322 | ] 323 | `; 324 | 325 | exports[`defaultActions crudOperations .get() 1`] = ` 326 | Object { 327 | "body": Object { 328 | "firstName": "Olivier", 329 | "id": 1, 330 | }, 331 | "code": 200, 332 | "headers": Object { 333 | "content-type": "application/json", 334 | }, 335 | "ok": true, 336 | "receivedAt": null, 337 | "status": 200, 338 | } 339 | `; 340 | 341 | exports[`defaultActions crudOperations .get() 2`] = ` 342 | Array [ 343 | Object { 344 | "context": Object { 345 | "id": 1, 346 | }, 347 | "options": Object {}, 348 | "status": "pending", 349 | "type": "@@resource/USER/GET", 350 | }, 351 | Object { 352 | "context": Object { 353 | "id": 1, 354 | }, 355 | "options": Object {}, 356 | "payload": Object { 357 | "body": Object { 358 | "firstName": "Olivier", 359 | "id": 1, 360 | }, 361 | "code": 200, 362 | "headers": Object { 363 | "content-type": "application/json", 364 | }, 365 | "ok": true, 366 | "receivedAt": null, 367 | "status": 200, 368 | }, 369 | "status": "resolved", 370 | "type": "@@resource/USER/GET", 371 | }, 372 | ] 373 | `; 374 | 375 | exports[`defaultActions crudOperations .update() 1`] = ` 376 | Object { 377 | "body": Object { 378 | "ok": true, 379 | }, 380 | "code": 200, 381 | "headers": Object { 382 | "content-type": "application/json", 383 | }, 384 | "ok": true, 385 | "receivedAt": null, 386 | "status": 200, 387 | } 388 | `; 389 | 390 | exports[`defaultActions crudOperations .update() 2`] = ` 391 | Array [ 392 | Object { 393 | "context": Object { 394 | "firstName": "Olivier", 395 | "id": 1, 396 | }, 397 | "options": Object {}, 398 | "status": "pending", 399 | "type": "@@resource/USER/UPDATE", 400 | }, 401 | Object { 402 | "context": Object { 403 | "firstName": "Olivier", 404 | "id": 1, 405 | }, 406 | "options": Object {}, 407 | "payload": Object { 408 | "body": Object { 409 | "ok": true, 410 | }, 411 | "code": 200, 412 | "headers": Object { 413 | "content-type": "application/json", 414 | }, 415 | "ok": true, 416 | "receivedAt": null, 417 | "status": 200, 418 | }, 419 | "status": "resolved", 420 | "type": "@@resource/USER/UPDATE", 421 | }, 422 | ] 423 | `; 424 | 425 | exports[`defaultActions errorHandling .fetch() with HTML response errors 1`] = ` 426 | Array [ 427 | Object { 428 | "context": Object {}, 429 | "options": Object { 430 | "isArray": true, 431 | }, 432 | "status": "pending", 433 | "type": "@@resource/USER/FETCH", 434 | }, 435 | Object { 436 | "context": Object {}, 437 | "options": Object { 438 | "isArray": true, 439 | }, 440 | "payload": Object { 441 | "body": "

something awful happened

", 442 | "code": 400, 443 | "headers": Object {}, 444 | "ok": false, 445 | "receivedAt": null, 446 | "status": 400, 447 | }, 448 | "status": "rejected", 449 | "type": "@@resource/USER/FETCH", 450 | }, 451 | ] 452 | `; 453 | 454 | exports[`defaultActions errorHandling .fetch() with JSON response errors 1`] = ` 455 | Array [ 456 | Object { 457 | "context": Object {}, 458 | "options": Object { 459 | "isArray": true, 460 | }, 461 | "status": "pending", 462 | "type": "@@resource/USER/FETCH", 463 | }, 464 | Object { 465 | "context": Object {}, 466 | "options": Object { 467 | "isArray": true, 468 | }, 469 | "payload": Object { 470 | "body": Object { 471 | "err": "something awful happened", 472 | }, 473 | "code": 400, 474 | "headers": Object { 475 | "content-type": "application/json", 476 | }, 477 | "ok": false, 478 | "receivedAt": null, 479 | "status": 400, 480 | }, 481 | "status": "rejected", 482 | "type": "@@resource/USER/FETCH", 483 | }, 484 | ] 485 | `; 486 | 487 | exports[`defaultActions errorHandling .fetch() with request errors 1`] = ` 488 | Array [ 489 | Object { 490 | "context": Object {}, 491 | "options": Object { 492 | "isArray": true, 493 | }, 494 | "status": "pending", 495 | "type": "@@resource/USER/FETCH", 496 | }, 497 | Object { 498 | "context": Object {}, 499 | "options": Object { 500 | "isArray": true, 501 | }, 502 | "payload": Object { 503 | "body": "request to http://localhost:3000/users failed, reason: something awful happened", 504 | "code": 0, 505 | "headers": Object {}, 506 | "ok": false, 507 | "receivedAt": null, 508 | "status": 0, 509 | }, 510 | "status": "rejected", 511 | "type": "@@resource/USER/FETCH", 512 | }, 513 | ] 514 | `; 515 | 516 | exports[`defaultActions exoticResponses .fetch() with an empty body 1`] = ` 517 | Object { 518 | "body": "", 519 | "code": 200, 520 | "headers": Object {}, 521 | "ok": true, 522 | "receivedAt": null, 523 | "status": 200, 524 | } 525 | `; 526 | 527 | exports[`defaultActions exoticResponses .fetch() with an empty body 2`] = ` 528 | Array [ 529 | Object { 530 | "context": Object { 531 | "id": 1, 532 | }, 533 | "options": Object {}, 534 | "status": "pending", 535 | "type": "@@resource/USER/DELETE", 536 | }, 537 | Object { 538 | "context": Object { 539 | "id": 1, 540 | }, 541 | "options": Object {}, 542 | "payload": Object { 543 | "body": "", 544 | "code": 200, 545 | "headers": Object {}, 546 | "ok": true, 547 | "receivedAt": null, 548 | "status": 200, 549 | }, 550 | "status": "resolved", 551 | "type": "@@resource/USER/DELETE", 552 | }, 553 | ] 554 | `; 555 | 556 | exports[`defaultActions exoticResponses .fetch() with an exotic json Content-Type 1`] = ` 557 | Object { 558 | "body": Object { 559 | "firstName": "Olivier", 560 | "id": 1, 561 | }, 562 | "code": 200, 563 | "headers": Object { 564 | "content-type": "application/problem+json; charset=utf-8", 565 | }, 566 | "ok": true, 567 | "receivedAt": null, 568 | "status": 200, 569 | } 570 | `; 571 | 572 | exports[`defaultActions exoticResponses .fetch() with an exotic json Content-Type 2`] = ` 573 | Array [ 574 | Object { 575 | "context": Object { 576 | "id": 1, 577 | }, 578 | "options": Object {}, 579 | "status": "pending", 580 | "type": "@@resource/USER/GET", 581 | }, 582 | Object { 583 | "context": Object { 584 | "id": 1, 585 | }, 586 | "options": Object {}, 587 | "payload": Object { 588 | "body": Object { 589 | "firstName": "Olivier", 590 | "id": 1, 591 | }, 592 | "code": 200, 593 | "headers": Object { 594 | "content-type": "application/problem+json; charset=utf-8", 595 | }, 596 | "ok": true, 597 | "receivedAt": null, 598 | "status": 200, 599 | }, 600 | "status": "resolved", 601 | "type": "@@resource/USER/GET", 602 | }, 603 | ] 604 | `; 605 | 606 | exports[`defaultActions exoticResponses .fetch() with an non-json body 1`] = ` 607 | Object { 608 | "body": "foobar", 609 | "code": 200, 610 | "headers": Object {}, 611 | "ok": true, 612 | "receivedAt": null, 613 | "status": 200, 614 | } 615 | `; 616 | 617 | exports[`defaultActions exoticResponses .fetch() with an non-json body 2`] = ` 618 | Array [ 619 | Object { 620 | "context": Object { 621 | "id": 1, 622 | }, 623 | "options": Object {}, 624 | "status": "pending", 625 | "type": "@@resource/USER/DELETE", 626 | }, 627 | Object { 628 | "context": Object { 629 | "id": 1, 630 | }, 631 | "options": Object {}, 632 | "payload": Object { 633 | "body": "foobar", 634 | "code": 200, 635 | "headers": Object {}, 636 | "ok": true, 637 | "receivedAt": null, 638 | "status": 200, 639 | }, 640 | "status": "resolved", 641 | "type": "@@resource/USER/DELETE", 642 | }, 643 | ] 644 | `; 645 | 646 | exports[`fetch options \`body\` option should support context override 1`] = ` 647 | Array [ 648 | Object { 649 | "context": Object { 650 | "id": 1, 651 | }, 652 | "options": Object {}, 653 | "status": "pending", 654 | "type": "@@resource/USER/UPDATE", 655 | }, 656 | Object { 657 | "context": Object { 658 | "id": 1, 659 | }, 660 | "options": Object {}, 661 | "payload": Object { 662 | "body": Object { 663 | "ok": true, 664 | }, 665 | "code": 200, 666 | "headers": Object { 667 | "content-type": "application/json", 668 | }, 669 | "ok": true, 670 | "receivedAt": null, 671 | "status": 200, 672 | }, 673 | "status": "resolved", 674 | "type": "@@resource/USER/UPDATE", 675 | }, 676 | ] 677 | `; 678 | 679 | exports[`fetch options \`credentials\` option should support action override 1`] = ` 680 | Array [ 681 | Object { 682 | "context": Object {}, 683 | "options": Object { 684 | "isArray": true, 685 | }, 686 | "status": "pending", 687 | "type": "@@resource/USER/FETCH", 688 | }, 689 | Object { 690 | "context": Object {}, 691 | "options": Object { 692 | "isArray": true, 693 | }, 694 | "payload": Object { 695 | "body": Array [ 696 | Object { 697 | "firstName": "Olivier", 698 | "id": 1, 699 | }, 700 | ], 701 | "code": 200, 702 | "headers": Object { 703 | "content-type": "application/json", 704 | }, 705 | "ok": true, 706 | "receivedAt": null, 707 | "status": 200, 708 | }, 709 | "status": "resolved", 710 | "type": "@@resource/USER/FETCH", 711 | }, 712 | ] 713 | `; 714 | 715 | exports[`fetch options \`credentials\` option should support action override via function 1`] = ` 716 | Array [ 717 | Object { 718 | "context": Object {}, 719 | "options": Object { 720 | "isArray": true, 721 | }, 722 | "status": "pending", 723 | "type": "@@resource/USER/FETCH", 724 | }, 725 | Object { 726 | "context": Object {}, 727 | "options": Object { 728 | "isArray": true, 729 | }, 730 | "payload": Object { 731 | "body": Array [ 732 | Object { 733 | "firstName": "Olivier", 734 | "id": 1, 735 | }, 736 | ], 737 | "code": 200, 738 | "headers": Object { 739 | "content-type": "application/json", 740 | }, 741 | "ok": true, 742 | "receivedAt": null, 743 | "status": 200, 744 | }, 745 | "status": "resolved", 746 | "type": "@@resource/USER/FETCH", 747 | }, 748 | ] 749 | `; 750 | 751 | exports[`fetch options \`credentials\` option should support context override 1`] = ` 752 | Array [ 753 | Object { 754 | "context": Object {}, 755 | "options": Object { 756 | "isArray": true, 757 | }, 758 | "status": "pending", 759 | "type": "@@resource/USER/FETCH", 760 | }, 761 | Object { 762 | "context": Object {}, 763 | "options": Object { 764 | "isArray": true, 765 | }, 766 | "payload": Object { 767 | "body": Array [ 768 | Object { 769 | "firstName": "Olivier", 770 | "id": 1, 771 | }, 772 | ], 773 | "code": 200, 774 | "headers": Object { 775 | "content-type": "application/json", 776 | }, 777 | "ok": true, 778 | "receivedAt": null, 779 | "status": 200, 780 | }, 781 | "status": "resolved", 782 | "type": "@@resource/USER/FETCH", 783 | }, 784 | ] 785 | `; 786 | 787 | exports[`fetch options \`headers\` option should support action override 1`] = ` 788 | Array [ 789 | Object { 790 | "context": Object {}, 791 | "options": Object { 792 | "isArray": true, 793 | }, 794 | "status": "pending", 795 | "type": "@@resource/USER/FETCH", 796 | }, 797 | Object { 798 | "context": Object {}, 799 | "options": Object { 800 | "isArray": true, 801 | }, 802 | "payload": Object { 803 | "body": Array [ 804 | Object { 805 | "firstName": "Olivier", 806 | "id": 1, 807 | }, 808 | ], 809 | "code": 200, 810 | "headers": Object { 811 | "content-type": "application/json", 812 | }, 813 | "ok": true, 814 | "receivedAt": null, 815 | "status": 200, 816 | }, 817 | "status": "resolved", 818 | "type": "@@resource/USER/FETCH", 819 | }, 820 | ] 821 | `; 822 | 823 | exports[`fetch options \`headers\` option should support action override via function 1`] = ` 824 | Array [ 825 | Object { 826 | "context": Object {}, 827 | "options": Object { 828 | "isArray": true, 829 | }, 830 | "status": "pending", 831 | "type": "@@resource/USER/FETCH", 832 | }, 833 | Object { 834 | "context": Object {}, 835 | "options": Object { 836 | "isArray": true, 837 | }, 838 | "payload": Object { 839 | "body": Array [ 840 | Object { 841 | "firstName": "Olivier", 842 | "id": 1, 843 | }, 844 | ], 845 | "code": 200, 846 | "headers": Object { 847 | "content-type": "application/json", 848 | }, 849 | "ok": true, 850 | "receivedAt": null, 851 | "status": 200, 852 | }, 853 | "status": "resolved", 854 | "type": "@@resource/USER/FETCH", 855 | }, 856 | ] 857 | `; 858 | 859 | exports[`fetch options \`headers\` option should support context override 1`] = ` 860 | Array [ 861 | Object { 862 | "context": Object {}, 863 | "options": Object { 864 | "isArray": true, 865 | }, 866 | "status": "pending", 867 | "type": "@@resource/USER/FETCH", 868 | }, 869 | Object { 870 | "context": Object {}, 871 | "options": Object { 872 | "isArray": true, 873 | }, 874 | "payload": Object { 875 | "body": Array [ 876 | Object { 877 | "firstName": "Olivier", 878 | "id": 1, 879 | }, 880 | ], 881 | "code": 200, 882 | "headers": Object { 883 | "content-type": "application/json", 884 | }, 885 | "ok": true, 886 | "receivedAt": null, 887 | "status": 200, 888 | }, 889 | "status": "resolved", 890 | "type": "@@resource/USER/FETCH", 891 | }, 892 | ] 893 | `; 894 | 895 | exports[`fetch options \`headers\` option should support defaults override 1`] = ` 896 | Array [ 897 | Object { 898 | "context": Object {}, 899 | "options": Object { 900 | "isArray": true, 901 | }, 902 | "status": "pending", 903 | "type": "@@resource/USER/FETCH", 904 | }, 905 | Object { 906 | "context": Object {}, 907 | "options": Object { 908 | "isArray": true, 909 | }, 910 | "payload": Object { 911 | "body": Array [ 912 | Object { 913 | "firstName": "Olivier", 914 | "id": 1, 915 | }, 916 | ], 917 | "code": 200, 918 | "headers": Object { 919 | "content-type": "application/json", 920 | }, 921 | "ok": true, 922 | "receivedAt": null, 923 | "status": 200, 924 | }, 925 | "status": "resolved", 926 | "type": "@@resource/USER/FETCH", 927 | }, 928 | ] 929 | `; 930 | 931 | exports[`fetch options \`method\` option should support action override 1`] = ` 932 | Array [ 933 | Object { 934 | "context": Object {}, 935 | "options": Object { 936 | "isArray": true, 937 | }, 938 | "status": "pending", 939 | "type": "@@resource/USER/FETCH", 940 | }, 941 | Object { 942 | "context": Object {}, 943 | "options": Object { 944 | "isArray": true, 945 | }, 946 | "payload": Object { 947 | "body": Array [ 948 | Object { 949 | "firstName": "Olivier", 950 | "id": 1, 951 | }, 952 | ], 953 | "code": 200, 954 | "headers": Object { 955 | "content-type": "application/json", 956 | }, 957 | "ok": true, 958 | "receivedAt": null, 959 | "status": 200, 960 | }, 961 | "status": "resolved", 962 | "type": "@@resource/USER/FETCH", 963 | }, 964 | ] 965 | `; 966 | 967 | exports[`fetch options \`method\` option should support action override via function 1`] = ` 968 | Array [ 969 | Object { 970 | "context": Object {}, 971 | "options": Object { 972 | "isArray": true, 973 | }, 974 | "status": "pending", 975 | "type": "@@resource/USER/FETCH", 976 | }, 977 | Object { 978 | "context": Object {}, 979 | "options": Object { 980 | "isArray": true, 981 | }, 982 | "payload": Object { 983 | "body": Array [ 984 | Object { 985 | "firstName": "Olivier", 986 | "id": 1, 987 | }, 988 | ], 989 | "code": 200, 990 | "headers": Object { 991 | "content-type": "application/json", 992 | }, 993 | "ok": true, 994 | "receivedAt": null, 995 | "status": 200, 996 | }, 997 | "status": "resolved", 998 | "type": "@@resource/USER/FETCH", 999 | }, 1000 | ] 1001 | `; 1002 | 1003 | exports[`fetch options \`method\` option should support context override 1`] = ` 1004 | Array [ 1005 | Object { 1006 | "context": Object {}, 1007 | "options": Object { 1008 | "isArray": true, 1009 | }, 1010 | "status": "pending", 1011 | "type": "@@resource/USER/FETCH", 1012 | }, 1013 | Object { 1014 | "context": Object {}, 1015 | "options": Object { 1016 | "isArray": true, 1017 | }, 1018 | "payload": Object { 1019 | "body": Array [ 1020 | Object { 1021 | "firstName": "Olivier", 1022 | "id": 1, 1023 | }, 1024 | ], 1025 | "code": 200, 1026 | "headers": Object { 1027 | "content-type": "application/json", 1028 | }, 1029 | "ok": true, 1030 | "receivedAt": null, 1031 | "status": 200, 1032 | }, 1033 | "status": "resolved", 1034 | "type": "@@resource/USER/FETCH", 1035 | }, 1036 | ] 1037 | `; 1038 | 1039 | exports[`fetch options \`params\` option should support context override 1`] = ` 1040 | Array [ 1041 | Object { 1042 | "context": Object { 1043 | "firstName": "Olivia", 1044 | }, 1045 | "options": Object { 1046 | "isArray": true, 1047 | "params": Object { 1048 | "id": 1, 1049 | }, 1050 | }, 1051 | "status": "pending", 1052 | "type": "@@resource/USER/UPDATE_MANY", 1053 | }, 1054 | Object { 1055 | "context": Object { 1056 | "firstName": "Olivia", 1057 | }, 1058 | "options": Object { 1059 | "isArray": true, 1060 | "params": Object { 1061 | "id": 1, 1062 | }, 1063 | }, 1064 | "payload": Object { 1065 | "body": Array [ 1066 | Object { 1067 | "firstName": "Olivier", 1068 | "id": 1, 1069 | }, 1070 | ], 1071 | "code": 200, 1072 | "headers": Object { 1073 | "content-type": "application/json", 1074 | }, 1075 | "ok": true, 1076 | "receivedAt": null, 1077 | "status": 200, 1078 | }, 1079 | "status": "resolved", 1080 | "type": "@@resource/USER/UPDATE_MANY", 1081 | }, 1082 | ] 1083 | `; 1084 | 1085 | exports[`fetch options \`query\` option should support action override 1`] = ` 1086 | Array [ 1087 | Object { 1088 | "context": Object {}, 1089 | "options": Object { 1090 | "isArray": true, 1091 | }, 1092 | "status": "pending", 1093 | "type": "@@resource/USER/FETCH", 1094 | }, 1095 | Object { 1096 | "context": Object {}, 1097 | "options": Object { 1098 | "isArray": true, 1099 | }, 1100 | "payload": Object { 1101 | "body": Array [ 1102 | Object { 1103 | "firstName": "Olivier", 1104 | "id": 1, 1105 | }, 1106 | ], 1107 | "code": 200, 1108 | "headers": Object { 1109 | "content-type": "application/json", 1110 | }, 1111 | "ok": true, 1112 | "receivedAt": null, 1113 | "status": 200, 1114 | }, 1115 | "status": "resolved", 1116 | "type": "@@resource/USER/FETCH", 1117 | }, 1118 | ] 1119 | `; 1120 | 1121 | exports[`fetch options \`query\` option should support action override via function 1`] = ` 1122 | Array [ 1123 | Object { 1124 | "context": Object {}, 1125 | "options": Object { 1126 | "isArray": true, 1127 | }, 1128 | "status": "pending", 1129 | "type": "@@resource/USER/FETCH", 1130 | }, 1131 | Object { 1132 | "context": Object {}, 1133 | "options": Object { 1134 | "isArray": true, 1135 | }, 1136 | "payload": Object { 1137 | "body": Array [ 1138 | Object { 1139 | "firstName": "Olivier", 1140 | "id": 1, 1141 | }, 1142 | ], 1143 | "code": 200, 1144 | "headers": Object { 1145 | "content-type": "application/json", 1146 | }, 1147 | "ok": true, 1148 | "receivedAt": null, 1149 | "status": 200, 1150 | }, 1151 | "status": "resolved", 1152 | "type": "@@resource/USER/FETCH", 1153 | }, 1154 | ] 1155 | `; 1156 | 1157 | exports[`fetch options \`query\` option should support context override 1`] = ` 1158 | Array [ 1159 | Object { 1160 | "context": Object {}, 1161 | "options": Object { 1162 | "isArray": true, 1163 | }, 1164 | "status": "pending", 1165 | "type": "@@resource/USER/FETCH", 1166 | }, 1167 | Object { 1168 | "context": Object {}, 1169 | "options": Object { 1170 | "isArray": true, 1171 | }, 1172 | "payload": Object { 1173 | "body": Array [ 1174 | Object { 1175 | "firstName": "Olivier", 1176 | "id": 1, 1177 | }, 1178 | ], 1179 | "code": 200, 1180 | "headers": Object { 1181 | "content-type": "application/json", 1182 | }, 1183 | "ok": true, 1184 | "receivedAt": null, 1185 | "status": 200, 1186 | }, 1187 | "status": "resolved", 1188 | "type": "@@resource/USER/FETCH", 1189 | }, 1190 | ] 1191 | `; 1192 | 1193 | exports[`fetch options \`query\` option should support non-string query params 1`] = ` 1194 | Array [ 1195 | Object { 1196 | "context": Object {}, 1197 | "options": Object { 1198 | "isArray": true, 1199 | }, 1200 | "status": "pending", 1201 | "type": "@@resource/USER/FETCH", 1202 | }, 1203 | Object { 1204 | "context": Object {}, 1205 | "options": Object { 1206 | "isArray": true, 1207 | }, 1208 | "payload": Object { 1209 | "body": Array [ 1210 | Object { 1211 | "firstName": "Olivier", 1212 | "id": 1, 1213 | }, 1214 | ], 1215 | "code": 200, 1216 | "headers": Object { 1217 | "content-type": "application/json", 1218 | }, 1219 | "ok": true, 1220 | "receivedAt": null, 1221 | "status": 200, 1222 | }, 1223 | "status": "resolved", 1224 | "type": "@@resource/USER/FETCH", 1225 | }, 1226 | ] 1227 | `; 1228 | 1229 | exports[`fetch options \`signal\` option should support action override 1`] = ` 1230 | Array [ 1231 | Object { 1232 | "context": Object {}, 1233 | "options": Object { 1234 | "isArray": true, 1235 | }, 1236 | "status": "pending", 1237 | "type": "@@resource/USER/FETCH", 1238 | }, 1239 | Object { 1240 | "context": Object {}, 1241 | "options": Object { 1242 | "isArray": true, 1243 | }, 1244 | "payload": Object { 1245 | "body": Array [ 1246 | Object { 1247 | "firstName": "Olivier", 1248 | "id": 1, 1249 | }, 1250 | ], 1251 | "code": 200, 1252 | "headers": Object { 1253 | "content-type": "application/json", 1254 | }, 1255 | "ok": true, 1256 | "receivedAt": null, 1257 | "status": 200, 1258 | }, 1259 | "status": "resolved", 1260 | "type": "@@resource/USER/FETCH", 1261 | }, 1262 | ] 1263 | `; 1264 | 1265 | exports[`fetch options \`transformResponse\` option should support action options 1`] = ` 1266 | Array [ 1267 | Object { 1268 | "context": Object {}, 1269 | "options": Object { 1270 | "isArray": true, 1271 | }, 1272 | "status": "pending", 1273 | "type": "@@resource/USER/FETCH", 1274 | }, 1275 | Object { 1276 | "context": Object {}, 1277 | "options": Object { 1278 | "isArray": true, 1279 | }, 1280 | "payload": Object { 1281 | "body": Array [ 1282 | Object { 1283 | "firstName": "Olivier", 1284 | "foo": "bar", 1285 | "id": 1, 1286 | }, 1287 | ], 1288 | "code": 200, 1289 | "headers": Object { 1290 | "content-type": "application/json", 1291 | }, 1292 | "ok": true, 1293 | "receivedAt": null, 1294 | "status": 200, 1295 | }, 1296 | "status": "resolved", 1297 | "type": "@@resource/USER/FETCH", 1298 | }, 1299 | ] 1300 | `; 1301 | 1302 | exports[`fetch options \`url\` option should support action override 1`] = ` 1303 | Array [ 1304 | Object { 1305 | "context": Object {}, 1306 | "options": Object { 1307 | "isArray": true, 1308 | }, 1309 | "status": "pending", 1310 | "type": "@@resource/USER/FETCH", 1311 | }, 1312 | Object { 1313 | "context": Object {}, 1314 | "options": Object { 1315 | "isArray": true, 1316 | }, 1317 | "payload": Object { 1318 | "body": Array [ 1319 | Object { 1320 | "firstName": "Olivier", 1321 | "id": 1, 1322 | }, 1323 | ], 1324 | "code": 200, 1325 | "headers": Object { 1326 | "content-type": "application/json", 1327 | }, 1328 | "ok": true, 1329 | "receivedAt": null, 1330 | "status": 200, 1331 | }, 1332 | "status": "resolved", 1333 | "type": "@@resource/USER/FETCH", 1334 | }, 1335 | ] 1336 | `; 1337 | 1338 | exports[`fetch options \`url\` option should support action override via function 1`] = ` 1339 | Array [ 1340 | Object { 1341 | "context": Object {}, 1342 | "options": Object { 1343 | "isArray": true, 1344 | }, 1345 | "status": "pending", 1346 | "type": "@@resource/USER/FETCH", 1347 | }, 1348 | Object { 1349 | "context": Object {}, 1350 | "options": Object { 1351 | "isArray": true, 1352 | }, 1353 | "payload": Object { 1354 | "body": Array [ 1355 | Object { 1356 | "firstName": "Olivier", 1357 | "id": 1, 1358 | }, 1359 | ], 1360 | "code": 200, 1361 | "headers": Object { 1362 | "content-type": "application/json", 1363 | }, 1364 | "ok": true, 1365 | "receivedAt": null, 1366 | "status": 200, 1367 | }, 1368 | "status": "resolved", 1369 | "type": "@@resource/USER/FETCH", 1370 | }, 1371 | ] 1372 | `; 1373 | 1374 | exports[`fetch options \`url\` option should support context override 1`] = ` 1375 | Array [ 1376 | Object { 1377 | "context": Object {}, 1378 | "options": Object { 1379 | "isArray": true, 1380 | }, 1381 | "status": "pending", 1382 | "type": "@@resource/USER/FETCH", 1383 | }, 1384 | Object { 1385 | "context": Object {}, 1386 | "options": Object { 1387 | "isArray": true, 1388 | }, 1389 | "payload": Object { 1390 | "body": Array [ 1391 | Object { 1392 | "firstName": "Olivier", 1393 | "id": 1, 1394 | }, 1395 | ], 1396 | "code": 200, 1397 | "headers": Object { 1398 | "content-type": "application/json", 1399 | }, 1400 | "ok": true, 1401 | "receivedAt": null, 1402 | "status": 200, 1403 | }, 1404 | "status": "resolved", 1405 | "type": "@@resource/USER/FETCH", 1406 | }, 1407 | ] 1408 | `; 1409 | 1410 | exports[`fetch options \`url\` option should support relative urls 1`] = ` 1411 | Array [ 1412 | Object { 1413 | "context": Object { 1414 | "firstName": "Olivier", 1415 | "id": 1, 1416 | }, 1417 | "options": Object {}, 1418 | "status": "pending", 1419 | "type": "@@resource/USER/UPDATE", 1420 | }, 1421 | Object { 1422 | "context": Object { 1423 | "firstName": "Olivier", 1424 | "id": 1, 1425 | }, 1426 | "options": Object {}, 1427 | "payload": Object { 1428 | "body": Object { 1429 | "ok": 1, 1430 | }, 1431 | "code": 200, 1432 | "headers": Object { 1433 | "content-type": "application/json", 1434 | }, 1435 | "ok": true, 1436 | "receivedAt": null, 1437 | "status": 200, 1438 | }, 1439 | "status": "resolved", 1440 | "type": "@@resource/USER/UPDATE", 1441 | }, 1442 | ] 1443 | `; 1444 | 1445 | exports[`fetch options \`url\` option should support relative urls for array methods 1`] = ` 1446 | Array [ 1447 | Object { 1448 | "context": Object {}, 1449 | "options": Object { 1450 | "gerundName": "aggregating", 1451 | "isArray": true, 1452 | }, 1453 | "status": "pending", 1454 | "type": "@@resource/USER/AGGREGATE", 1455 | }, 1456 | Object { 1457 | "context": Object {}, 1458 | "options": Object { 1459 | "gerundName": "aggregating", 1460 | "isArray": true, 1461 | }, 1462 | "payload": Object { 1463 | "body": Object { 1464 | "ok": 1, 1465 | }, 1466 | "code": 200, 1467 | "headers": Object { 1468 | "content-type": "application/json", 1469 | }, 1470 | "ok": true, 1471 | "receivedAt": null, 1472 | "status": 200, 1473 | }, 1474 | "status": "resolved", 1475 | "type": "@@resource/USER/AGGREGATE", 1476 | }, 1477 | ] 1478 | `; 1479 | 1480 | exports[`other options \`alias\` option should support action override 1`] = ` 1481 | Array [ 1482 | Object { 1483 | "context": Object {}, 1484 | "options": Object { 1485 | "isArray": true, 1486 | }, 1487 | "status": "pending", 1488 | "type": "@@resource/USER/FETCH", 1489 | }, 1490 | Object { 1491 | "context": Object {}, 1492 | "options": Object { 1493 | "isArray": true, 1494 | }, 1495 | "payload": Object { 1496 | "body": Array [ 1497 | Object { 1498 | "firstName": "Olivier", 1499 | "id": 1, 1500 | }, 1501 | ], 1502 | "code": 200, 1503 | "headers": Object { 1504 | "content-type": "application/json", 1505 | }, 1506 | "ok": true, 1507 | "receivedAt": null, 1508 | "status": 200, 1509 | }, 1510 | "status": "resolved", 1511 | "type": "@@resource/USER/FETCH", 1512 | }, 1513 | ] 1514 | `; 1515 | 1516 | exports[`other options \`name\` option should support action override 1`] = ` 1517 | Array [ 1518 | Object { 1519 | "context": Object {}, 1520 | "options": Object { 1521 | "isArray": true, 1522 | }, 1523 | "status": "pending", 1524 | "type": "@@resource/USER/FETCH", 1525 | }, 1526 | Object { 1527 | "context": Object {}, 1528 | "options": Object { 1529 | "isArray": true, 1530 | }, 1531 | "payload": Object { 1532 | "body": Array [ 1533 | Object { 1534 | "firstName": "Olivier", 1535 | "id": 1, 1536 | }, 1537 | ], 1538 | "code": 200, 1539 | "headers": Object { 1540 | "content-type": "application/json", 1541 | }, 1542 | "ok": true, 1543 | "receivedAt": null, 1544 | "status": 200, 1545 | }, 1546 | "status": "resolved", 1547 | "type": "@@resource/USER/FETCH", 1548 | }, 1549 | ] 1550 | `; 1551 | 1552 | exports[`reduce options \`assignResponse\` option should support action override 1`] = ` 1553 | Array [ 1554 | Object { 1555 | "context": Object { 1556 | "firstName": "Olivier", 1557 | "id": 1, 1558 | }, 1559 | "options": Object { 1560 | "assignResponse": true, 1561 | }, 1562 | "status": "pending", 1563 | "type": "@@resource/USER/UPDATE", 1564 | }, 1565 | Object { 1566 | "context": Object { 1567 | "firstName": "Olivier", 1568 | "id": 1, 1569 | }, 1570 | "options": Object { 1571 | "assignResponse": true, 1572 | }, 1573 | "payload": Object { 1574 | "body": Object { 1575 | "ok": 1, 1576 | }, 1577 | "code": 200, 1578 | "headers": Object { 1579 | "content-type": "application/json", 1580 | }, 1581 | "ok": true, 1582 | "receivedAt": null, 1583 | "status": 200, 1584 | }, 1585 | "status": "resolved", 1586 | "type": "@@resource/USER/UPDATE", 1587 | }, 1588 | ] 1589 | `; 1590 | 1591 | exports[`reduce options \`assignResponse\` option should support context override 1`] = ` 1592 | Array [ 1593 | Object { 1594 | "context": Object { 1595 | "firstName": "Olivier", 1596 | "id": 1, 1597 | }, 1598 | "options": Object { 1599 | "assignResponse": true, 1600 | }, 1601 | "status": "pending", 1602 | "type": "@@resource/USER/UPDATE", 1603 | }, 1604 | Object { 1605 | "context": Object { 1606 | "firstName": "Olivier", 1607 | "id": 1, 1608 | }, 1609 | "options": Object { 1610 | "assignResponse": true, 1611 | }, 1612 | "payload": Object { 1613 | "body": Object { 1614 | "ok": 1, 1615 | }, 1616 | "code": 200, 1617 | "headers": Object { 1618 | "content-type": "application/json", 1619 | }, 1620 | "ok": true, 1621 | "receivedAt": null, 1622 | "status": 200, 1623 | }, 1624 | "status": "resolved", 1625 | "type": "@@resource/USER/UPDATE", 1626 | }, 1627 | ] 1628 | `; 1629 | 1630 | exports[`reduce options \`beforeError\` hook should support action override 1`] = ` 1631 | Array [ 1632 | Object { 1633 | "context": Object {}, 1634 | "options": Object { 1635 | "isArray": true, 1636 | }, 1637 | "status": "pending", 1638 | "type": "@@resource/USER/FETCH", 1639 | }, 1640 | Object { 1641 | "context": Object {}, 1642 | "options": Object { 1643 | "isArray": true, 1644 | }, 1645 | "payload": Object { 1646 | "body": "request to http://localhost:3000/users failed, reason: something awful happened", 1647 | "code": 0, 1648 | "headers": Object {}, 1649 | "ok": false, 1650 | "receivedAt": null, 1651 | "status": 0, 1652 | }, 1653 | "status": "rejected", 1654 | "type": "@@resource/USER/FETCH", 1655 | }, 1656 | ] 1657 | `; 1658 | 1659 | exports[`reduce options \`invalidateState\` option should support action override 1`] = ` 1660 | Array [ 1661 | Object { 1662 | "context": Object {}, 1663 | "options": Object { 1664 | "invalidateState": true, 1665 | "isArray": true, 1666 | }, 1667 | "status": "pending", 1668 | "type": "@@resource/USER/FETCH", 1669 | }, 1670 | Object { 1671 | "context": Object {}, 1672 | "options": Object { 1673 | "invalidateState": true, 1674 | "isArray": true, 1675 | }, 1676 | "payload": Object { 1677 | "body": Array [ 1678 | Object { 1679 | "firstName": "Olivier", 1680 | "id": 1, 1681 | }, 1682 | ], 1683 | "code": 200, 1684 | "headers": Object { 1685 | "content-type": "application/json", 1686 | }, 1687 | "ok": true, 1688 | "receivedAt": null, 1689 | "status": 200, 1690 | }, 1691 | "status": "resolved", 1692 | "type": "@@resource/USER/FETCH", 1693 | }, 1694 | ] 1695 | `; 1696 | 1697 | exports[`reduce options \`isArray\` option should support action override 1`] = ` 1698 | Array [ 1699 | Object { 1700 | "context": Object { 1701 | "firstName": "Olivier", 1702 | }, 1703 | "options": Object { 1704 | "assignResponse": true, 1705 | "isArray": true, 1706 | }, 1707 | "status": "pending", 1708 | "type": "@@resource/USER/CREATE", 1709 | }, 1710 | Object { 1711 | "context": Object { 1712 | "firstName": "Olivier", 1713 | }, 1714 | "options": Object { 1715 | "assignResponse": true, 1716 | "isArray": true, 1717 | }, 1718 | "payload": Object { 1719 | "body": Object { 1720 | "ok": 1, 1721 | }, 1722 | "code": 200, 1723 | "headers": Object { 1724 | "content-type": "application/json", 1725 | }, 1726 | "ok": true, 1727 | "receivedAt": null, 1728 | "status": 200, 1729 | }, 1730 | "status": "resolved", 1731 | "type": "@@resource/USER/CREATE", 1732 | }, 1733 | ] 1734 | `; 1735 | 1736 | exports[`reduce options \`isArray\` option should support context override 1`] = ` 1737 | Array [ 1738 | Object { 1739 | "context": Object { 1740 | "firstName": "Olivier", 1741 | "id": 1, 1742 | }, 1743 | "options": Object { 1744 | "isArray": true, 1745 | }, 1746 | "status": "pending", 1747 | "type": "@@resource/USER/UPDATE", 1748 | }, 1749 | Object { 1750 | "context": Object { 1751 | "firstName": "Olivier", 1752 | "id": 1, 1753 | }, 1754 | "options": Object { 1755 | "isArray": true, 1756 | }, 1757 | "payload": Object { 1758 | "body": Object { 1759 | "ok": 1, 1760 | }, 1761 | "code": 200, 1762 | "headers": Object { 1763 | "content-type": "application/json", 1764 | }, 1765 | "ok": true, 1766 | "receivedAt": null, 1767 | "status": 200, 1768 | }, 1769 | "status": "resolved", 1770 | "type": "@@resource/USER/UPDATE", 1771 | }, 1772 | ] 1773 | `; 1774 | 1775 | exports[`reduce options \`mergeResponse\` option should support action override 1`] = ` 1776 | Array [ 1777 | Object { 1778 | "context": Object { 1779 | "id": 1, 1780 | }, 1781 | "options": Object { 1782 | "mergeResponse": true, 1783 | }, 1784 | "status": "pending", 1785 | "type": "@@resource/USER/GET", 1786 | }, 1787 | Object { 1788 | "context": Object { 1789 | "id": 1, 1790 | }, 1791 | "options": Object { 1792 | "mergeResponse": true, 1793 | }, 1794 | "payload": Object { 1795 | "body": Object { 1796 | "firstName": "John", 1797 | "id": 1, 1798 | }, 1799 | "code": 200, 1800 | "headers": Object { 1801 | "content-type": "application/json", 1802 | }, 1803 | "ok": true, 1804 | "receivedAt": null, 1805 | "status": 200, 1806 | }, 1807 | "status": "resolved", 1808 | "type": "@@resource/USER/GET", 1809 | }, 1810 | ] 1811 | `; 1812 | 1813 | exports[`reduce options \`mergeResponse\` option should support context override 1`] = ` 1814 | Array [ 1815 | Object { 1816 | "context": Object { 1817 | "id": 1, 1818 | }, 1819 | "options": Object { 1820 | "mergeResponse": true, 1821 | }, 1822 | "status": "pending", 1823 | "type": "@@resource/USER/GET", 1824 | }, 1825 | Object { 1826 | "context": Object { 1827 | "id": 1, 1828 | }, 1829 | "options": Object { 1830 | "mergeResponse": true, 1831 | }, 1832 | "payload": Object { 1833 | "body": Object { 1834 | "firstName": "John", 1835 | "id": 1, 1836 | }, 1837 | "code": 200, 1838 | "headers": Object { 1839 | "content-type": "application/json", 1840 | }, 1841 | "ok": true, 1842 | "receivedAt": null, 1843 | "status": 200, 1844 | }, 1845 | "status": "resolved", 1846 | "type": "@@resource/USER/GET", 1847 | }, 1848 | ] 1849 | `; 1850 | -------------------------------------------------------------------------------- /test/spec/actions.spec.ts: -------------------------------------------------------------------------------- 1 | import {values} from 'lodash'; 2 | import configureMockStore from 'redux-mock-store'; 3 | import expect from 'expect'; 4 | import nock from 'nock'; 5 | import thunk from 'redux-thunk'; 6 | 7 | import {defaultActions, defaultHeaders} from '../../src'; 8 | import {createActions, getActionName} from '../../src/actions'; 9 | 10 | const middlewares = [thunk]; 11 | const mockStore = configureMockStore(middlewares); 12 | 13 | // Configuration 14 | const resourceName = 'user'; 15 | const host = 'http://localhost:3000'; 16 | const url = `${host}/users/:id`; 17 | 18 | const checkActionMethodSignature = (getState, {actionId = ''} = {}) => { 19 | expect(typeof getState).toBe('function'); 20 | expect(typeof actionId).toBe('string'); 21 | expect(typeof getState()).toBe('object'); 22 | }; 23 | 24 | describe('createActions', () => { 25 | describe('when using a resource', () => { 26 | it('should return an object with properly named keys', () => { 27 | const actionFuncs = createActions(defaultActions, { 28 | resourceName, 29 | url 30 | }); 31 | const expectedKeys = [ 32 | 'createUser', 33 | 'fetchUsers', 34 | 'getUser', 35 | 'updateUser', 36 | 'updateUsers', 37 | 'deleteUser', 38 | 'deleteUsers' 39 | ]; 40 | expect(Object.keys(actionFuncs)).toEqual(expectedKeys); 41 | }); 42 | it('should return an object with properly typed values', () => { 43 | const actionFuncs = createActions(defaultActions, { 44 | resourceName, 45 | url 46 | }); 47 | const expectedValuesFn = (action) => expect(typeof action).toBe('function'); 48 | values(actionFuncs).forEach(expectedValuesFn); 49 | }); 50 | }); 51 | describe('when not using a resource', () => { 52 | it('should return an object with properly named keys', () => { 53 | const actionFuncs = createActions(defaultActions, { 54 | url 55 | }); 56 | const expectedKeys = ['create', 'fetch', 'get', 'update', 'updateMany', 'delete', 'deleteMany']; 57 | expect(Object.keys(actionFuncs)).toEqual(expectedKeys); 58 | }); 59 | it('should return an object with properly typed values', () => { 60 | const actionFuncs = createActions(defaultActions, { 61 | resourceName, 62 | url 63 | }); 64 | const expectedValuesFn = (action) => expect(typeof action).toBe('function'); 65 | values(actionFuncs).forEach(expectedValuesFn); 66 | }); 67 | }); 68 | }); 69 | 70 | describe('defaultActions', () => { 71 | afterEach(() => { 72 | nock.cleanAll(); 73 | }); 74 | const actionFuncs = createActions(defaultActions, { 75 | resourceName, 76 | url 77 | }); 78 | 79 | describe('crudOperations', () => { 80 | it('.create()', () => { 81 | const actionId = 'create'; 82 | const action = getActionName(actionId, { 83 | resourceName 84 | }); 85 | const context = { 86 | firstName: 'Olivier' 87 | }; 88 | const body = { 89 | ok: true 90 | }; 91 | const code = 200; 92 | nock(host).post('/users', context).reply(code, body); 93 | const store = mockStore({ 94 | users: {} 95 | }); 96 | return store.dispatch(actionFuncs[action](context)).then((res) => { 97 | res.receivedAt = null; 98 | expect(res).toMatchSnapshot(); 99 | const actions = store.getActions(); 100 | actions[1].payload.receivedAt = null; 101 | expect(actions).toMatchSnapshot(); 102 | }); 103 | }); 104 | it('.fetch()', () => { 105 | const actionId = 'fetch'; 106 | const action = getActionName(actionId, { 107 | resourceName, 108 | isArray: true 109 | }); 110 | const context = {}; 111 | const body = [ 112 | { 113 | id: 1, 114 | firstName: 'Olivier' 115 | } 116 | ]; 117 | const code = 200; 118 | 119 | nock(host).get('/users').reply(code, body); 120 | const store = mockStore({ 121 | users: {} 122 | }); 123 | return store.dispatch(actionFuncs[action](context)).then((res) => { 124 | res.receivedAt = null; 125 | expect(res).toMatchSnapshot(); 126 | const actions = store.getActions(); 127 | actions[1].payload.receivedAt = null; 128 | expect(actions).toMatchSnapshot(); 129 | }); 130 | }); 131 | it('.get()', () => { 132 | const actionId = 'get'; 133 | const action = getActionName(actionId, { 134 | resourceName 135 | }); 136 | const context = { 137 | id: 1 138 | }; 139 | const body = { 140 | id: 1, 141 | firstName: 'Olivier' 142 | }; 143 | const code = 200; 144 | nock(host).get(`/users/${context.id}`).reply(code, body); 145 | const store = mockStore({ 146 | users: {} 147 | }); 148 | return store.dispatch(actionFuncs[action](context)).then((res) => { 149 | res.receivedAt = null; 150 | expect(res).toMatchSnapshot(); 151 | const actions = store.getActions(); 152 | actions[1].payload.receivedAt = null; 153 | expect(actions).toMatchSnapshot(); 154 | }); 155 | }); 156 | it('.update()', () => { 157 | const actionId = 'update'; 158 | const action = getActionName(actionId, { 159 | resourceName 160 | }); 161 | const context = { 162 | id: 1, 163 | firstName: 'Olivier' 164 | }; 165 | const body = { 166 | ok: true 167 | }; 168 | const code = 200; 169 | nock(host).patch(`/users/${context.id}`, context).reply(code, body); 170 | const store = mockStore({ 171 | users: {} 172 | }); 173 | return store.dispatch(actionFuncs[action](context)).then((res) => { 174 | res.receivedAt = null; 175 | expect(res).toMatchSnapshot(); 176 | const actions = store.getActions(); 177 | actions[1].payload.receivedAt = null; 178 | expect(actions).toMatchSnapshot(); 179 | }); 180 | }); 181 | it('.delete()', () => { 182 | const actionId = 'delete'; 183 | const action = getActionName(actionId, { 184 | resourceName 185 | }); 186 | const context = { 187 | id: 1 188 | }; 189 | const body = { 190 | ok: true 191 | }; 192 | const code = 200; 193 | nock(host).delete(`/users/${context.id}`).reply(code, body); 194 | const store = mockStore({ 195 | users: {} 196 | }); 197 | return store.dispatch(actionFuncs[action](context)).then((res) => { 198 | res.receivedAt = null; 199 | expect(res).toMatchSnapshot(); 200 | const actions = store.getActions(); 201 | actions[1].payload.receivedAt = null; 202 | expect(actions).toMatchSnapshot(); 203 | }); 204 | }); 205 | }); 206 | 207 | describe('exoticResponses', () => { 208 | it('.fetch() with an exotic json Content-Type', () => { 209 | const actionId = 'get'; 210 | const action = getActionName(actionId, { 211 | resourceName 212 | }); 213 | const context = { 214 | id: 1 215 | }; 216 | const body = { 217 | id: 1, 218 | firstName: 'Olivier' 219 | }; 220 | const code = 200; 221 | nock(host).get(`/users/${context.id}`).reply(code, body, { 222 | 'Content-Type': 'application/problem+json; charset=utf-8' 223 | }); 224 | const store = mockStore({ 225 | users: {} 226 | }); 227 | return store.dispatch(actionFuncs[action](context)).then((res) => { 228 | res.receivedAt = null; 229 | expect(res).toMatchSnapshot(); 230 | const actions = store.getActions(); 231 | actions[1].payload.receivedAt = null; 232 | expect(actions).toMatchSnapshot(); 233 | }); 234 | }); 235 | it('.fetch() with an empty body', () => { 236 | const actionId = 'delete'; 237 | const action = getActionName(actionId, { 238 | resourceName 239 | }); 240 | const context = { 241 | id: 1 242 | }; 243 | const code = 200; 244 | nock(host).delete(`/users/${context.id}`).reply(code); 245 | const store = mockStore({ 246 | users: {} 247 | }); 248 | return store.dispatch(actionFuncs[action](context)).then((res) => { 249 | res.receivedAt = null; 250 | expect(res).toMatchSnapshot(); 251 | const actions = store.getActions(); 252 | actions[1].payload.receivedAt = null; 253 | expect(actions).toMatchSnapshot(); 254 | }); 255 | }); 256 | it('.fetch() with an non-json body', () => { 257 | const actionId = 'delete'; 258 | const action = getActionName(actionId, { 259 | resourceName 260 | }); 261 | const context = { 262 | id: 1 263 | }; 264 | const body = 'foobar'; 265 | const code = 200; 266 | nock(host).delete(`/users/${context.id}`).reply(code, body); 267 | const store = mockStore({ 268 | users: {} 269 | }); 270 | return store.dispatch(actionFuncs[action](context)).then((res) => { 271 | res.receivedAt = null; 272 | expect(res).toMatchSnapshot(); 273 | const actions = store.getActions(); 274 | actions[1].payload.receivedAt = null; 275 | expect(actions).toMatchSnapshot(); 276 | }); 277 | }); 278 | }); 279 | 280 | describe('errorHandling', () => { 281 | it('.fetch() with request errors', () => { 282 | const actionId = 'fetch'; 283 | const action = getActionName(actionId, { 284 | resourceName, 285 | isArray: true 286 | }); 287 | const context = {}; 288 | nock(host).get('/users').replyWithError('something awful happened'); 289 | const store = mockStore({ 290 | users: {} 291 | }); 292 | return expect(store.dispatch(actionFuncs[action](context))) 293 | .rejects.toBeDefined() 294 | .then(() => { 295 | const actions = store.getActions(); 296 | actions[1].payload.receivedAt = null; 297 | expect(actions).toMatchSnapshot(); 298 | }); 299 | }); 300 | it('.fetch() with JSON response errors', () => { 301 | const actionId = 'fetch'; 302 | const action = getActionName(actionId, { 303 | resourceName, 304 | isArray: true 305 | }); 306 | const context = {}; 307 | const body = { 308 | err: 'something awful happened' 309 | }; 310 | const code = 400; 311 | nock(host).get('/users').reply(code, body); 312 | const store = mockStore({ 313 | users: {} 314 | }); 315 | let thrownErr; 316 | return expect( 317 | store.dispatch(actionFuncs[action](context)).catch((err) => { 318 | thrownErr = err; 319 | throw err; 320 | }) 321 | ) 322 | .rejects.toBeDefined() 323 | .then(() => { 324 | const actions = store.getActions(); 325 | actions[1].payload.receivedAt = null; 326 | expect(thrownErr.status).toEqual(code); 327 | expect(actions).toMatchSnapshot(); 328 | }); 329 | }); 330 | it('.fetch() with HTML response errors', () => { 331 | const actionId = 'fetch'; 332 | const action = getActionName(actionId, { 333 | resourceName, 334 | isArray: true 335 | }); 336 | const context = {}; 337 | const body = '

something awful happened

'; 338 | const code = 400; 339 | nock(host).get('/users').reply(code, body); 340 | const store = mockStore({ 341 | users: {} 342 | }); 343 | let thrownErr; 344 | return expect( 345 | store.dispatch(actionFuncs[action](context)).catch((err) => { 346 | thrownErr = err; 347 | throw err; 348 | }) 349 | ) 350 | .rejects.toBeDefined() 351 | .then(() => { 352 | const actions = store.getActions(); 353 | actions[1].payload.receivedAt = null; 354 | expect(thrownErr.status).toEqual(code); 355 | expect(actions).toMatchSnapshot(); 356 | }); 357 | }); 358 | }); 359 | }); 360 | 361 | describe('custom actions', () => { 362 | afterEach(() => { 363 | nock.cleanAll(); 364 | }); 365 | const customActions = { 366 | promote: { 367 | method: 'POST', 368 | url: `${url}/promote` 369 | }, 370 | applications: { 371 | name: 'fetchApplications', 372 | gerundName: 'fetchingApplications', 373 | method: 'GET', 374 | isArray: true, 375 | url: `${url}/applications` 376 | }, 377 | merge: { 378 | method: 'POST', 379 | isArray: true 380 | }, 381 | editFolder: { 382 | method: 'PATCH', 383 | url: `./folders/:folder` 384 | } 385 | }; 386 | const actionFuncs = createActions(customActions, { 387 | resourceName, 388 | url 389 | }); 390 | it('.promote()', () => { 391 | const actionId = 'promote'; 392 | const action = getActionName(actionId, { 393 | resourceName 394 | }); 395 | const context = { 396 | id: 1, 397 | firstName: 'Olivier' 398 | }; 399 | const body = { 400 | ok: true 401 | }; 402 | const code = 200; 403 | nock(host).post(`/users/${context.id}/promote`, context).reply(code, body); 404 | const store = mockStore({ 405 | users: {} 406 | }); 407 | return store.dispatch(actionFuncs[action](context)).then(() => { 408 | const actions = store.getActions(); 409 | actions[1].payload.receivedAt = null; 410 | expect(actions).toMatchSnapshot(); 411 | }); 412 | }); 413 | it('with a custom action name', () => { 414 | const action = 'fetchApplications'; 415 | const context = { 416 | id: 1 417 | }; 418 | const body = [ 419 | { 420 | id: 1, 421 | name: 'Foo' 422 | } 423 | ]; 424 | const code = 200; 425 | nock(host).get(`/users/${context.id}/applications`).reply(code, body); 426 | const store = mockStore({ 427 | users: {} 428 | }); 429 | return store.dispatch(actionFuncs[action](context)).then(() => { 430 | const actions = store.getActions(); 431 | actions[1].payload.receivedAt = null; 432 | expect(actions).toMatchSnapshot(); 433 | }); 434 | }); 435 | it('.merge()', () => { 436 | const actionId = 'merge'; 437 | const action = getActionName(actionId, { 438 | resourceName, 439 | isArray: true 440 | }); 441 | const context = {}; 442 | const body = [ 443 | { 444 | id: 1, 445 | firstName: 'Olivier' 446 | } 447 | ]; 448 | const code = 200; 449 | nock(host).post('/users').reply(code, body); 450 | const store = mockStore({ 451 | users: {} 452 | }); 453 | return store.dispatch(actionFuncs[action](context)).then((res) => { 454 | const actions = store.getActions(); 455 | actions[1].payload.receivedAt = null; 456 | expect(actions).toMatchSnapshot(); 457 | expect(res.body).toEqual(actions[1].payload.body); 458 | }); 459 | }); 460 | it('.editFolder()', () => { 461 | const actionId = 'editFolder'; 462 | const action = getActionName(actionId, { 463 | resourceName 464 | }); 465 | const context = { 466 | id: 1, 467 | folder: 2, 468 | name: 'New Name' 469 | }; 470 | const body = { 471 | folder: 2, 472 | name: 'New Name' 473 | }; 474 | const code = 200; 475 | nock(host).patch(`/users/${context.id}/folders/${context.folder}`, context).reply(code, body); 476 | const store = mockStore({ 477 | users: {} 478 | }); 479 | return store.dispatch(actionFuncs[action](context)).then((res) => { 480 | const actions = store.getActions(); 481 | actions[1].payload.receivedAt = null; 482 | expect(actions).toMatchSnapshot(); 483 | expect(res.body).toEqual(actions[1].payload.body); 484 | }); 485 | }); 486 | }); 487 | 488 | describe('custom pure actions', () => { 489 | afterEach(() => { 490 | nock.cleanAll(); 491 | }); 492 | const customActions = { 493 | clear: { 494 | isPure: true, 495 | reduce: (state, _action) => ({ 496 | ...state, 497 | item: null 498 | }) 499 | } 500 | }; 501 | const actionFuncs = createActions(customActions, { 502 | resourceName, 503 | url 504 | }); 505 | it('.clear()', () => { 506 | const actionId = 'clear'; 507 | const action = getActionName(actionId, { 508 | resourceName 509 | }); 510 | const context = { 511 | id: 1, 512 | firstName: 'Olivier' 513 | }; 514 | const store = mockStore({ 515 | users: {} 516 | }); 517 | return store.dispatch(actionFuncs[action](context)).then(() => { 518 | const actions = store.getActions(); 519 | expect(actions).toMatchSnapshot(); 520 | }); 521 | }); 522 | }); 523 | 524 | describe('other options', () => { 525 | afterEach(() => { 526 | nock.cleanAll(); 527 | }); 528 | describe('`alias` option', () => { 529 | it('should support action override', () => { 530 | const alias = 'grab'; 531 | const actionFuncs = createActions( 532 | { 533 | ...defaultActions, 534 | fetch: { 535 | ...defaultActions.fetch, 536 | alias 537 | } 538 | }, 539 | { 540 | resourceName, 541 | url 542 | } 543 | ); 544 | const actionId = 'fetch'; 545 | const action = getActionName(actionId, { 546 | alias, 547 | resourceName, 548 | isArray: true 549 | }); 550 | const context = {}; 551 | const body = [ 552 | { 553 | id: 1, 554 | firstName: 'Olivier' 555 | } 556 | ]; 557 | const code = 200; 558 | nock(host).get('/users').reply(code, body); 559 | const store = mockStore({ 560 | users: {} 561 | }); 562 | return store.dispatch(actionFuncs[action](context)).then(() => { 563 | const actions = store.getActions(); 564 | actions[1].payload.receivedAt = null; 565 | expect(actions).toMatchSnapshot(); 566 | }); 567 | }); 568 | }); 569 | describe('`name` option', () => { 570 | it('should support action override', () => { 571 | const name = 'grabWorkers'; 572 | const actionFuncs = createActions( 573 | { 574 | ...defaultActions, 575 | fetch: { 576 | ...defaultActions.fetch, 577 | name 578 | } 579 | }, 580 | { 581 | resourceName, 582 | url 583 | } 584 | ); 585 | const action = name; 586 | const context = {}; 587 | const body = [ 588 | { 589 | id: 1, 590 | firstName: 'Olivier' 591 | } 592 | ]; 593 | const code = 200; 594 | nock(host).get('/users').reply(code, body); 595 | const store = mockStore({ 596 | users: {} 597 | }); 598 | return store.dispatch(actionFuncs[action](context)).then(() => { 599 | const actions = store.getActions(); 600 | actions[1].payload.receivedAt = null; 601 | expect(actions).toMatchSnapshot(); 602 | }); 603 | }); 604 | }); 605 | }); 606 | 607 | describe('fetch options', () => { 608 | afterEach(() => { 609 | nock.cleanAll(); 610 | }); 611 | describe('`transformResponse` option', () => { 612 | it('should support action options', () => { 613 | const transformResponse = (res) => ({ 614 | ...res, 615 | body: res.body.map((item) => ({ 616 | ...item, 617 | foo: 'bar' 618 | })) 619 | }); 620 | const actionFuncs = createActions( 621 | { 622 | ...defaultActions, 623 | fetch: { 624 | ...defaultActions.fetch, 625 | transformResponse 626 | } 627 | }, 628 | { 629 | resourceName, 630 | url 631 | } 632 | ); 633 | const actionId = 'fetch'; 634 | const action = getActionName(actionId, { 635 | resourceName, 636 | isArray: true 637 | }); 638 | const context = {}; 639 | const body = [ 640 | { 641 | id: 1, 642 | firstName: 'Olivier' 643 | } 644 | ]; 645 | const code = 200; 646 | nock(host).get('/users').reply(code, body); 647 | const store = mockStore({ 648 | users: {} 649 | }); 650 | return store.dispatch(actionFuncs[action](context)).then(() => { 651 | const actions = store.getActions(); 652 | actions[1].payload.receivedAt = null; 653 | expect(actions).toMatchSnapshot(); 654 | }); 655 | }); 656 | }); 657 | describe('`url` option', () => { 658 | it('should support action override', () => { 659 | const overridenUrl = `${host}/teams/:id`; 660 | const actionFuncs = createActions( 661 | { 662 | ...defaultActions, 663 | fetch: { 664 | ...defaultActions.fetch, 665 | url: overridenUrl 666 | } 667 | }, 668 | { 669 | resourceName, 670 | url 671 | } 672 | ); 673 | const actionId = 'fetch'; 674 | const action = getActionName(actionId, { 675 | resourceName, 676 | isArray: true 677 | }); 678 | const context = {}; 679 | const body = [ 680 | { 681 | id: 1, 682 | firstName: 'Olivier' 683 | } 684 | ]; 685 | const code = 200; 686 | nock(host).get('/teams').reply(code, body); 687 | const store = mockStore({ 688 | users: {} 689 | }); 690 | return store.dispatch(actionFuncs[action](context)).then(() => { 691 | const actions = store.getActions(); 692 | actions[1].payload.receivedAt = null; 693 | expect(actions).toMatchSnapshot(); 694 | }); 695 | }); 696 | it('should support action override via function', () => { 697 | const overridenUrl = (...args) => { 698 | checkActionMethodSignature(...args); 699 | return `${host}/teams/:id`; 700 | }; 701 | const actionFuncs = createActions( 702 | { 703 | ...defaultActions, 704 | fetch: { 705 | ...defaultActions.fetch, 706 | url: overridenUrl 707 | } 708 | }, 709 | { 710 | resourceName, 711 | url 712 | } 713 | ); 714 | const actionId = 'fetch'; 715 | const action = getActionName(actionId, { 716 | resourceName, 717 | isArray: true 718 | }); 719 | const context = {}; 720 | const body = [ 721 | { 722 | id: 1, 723 | firstName: 'Olivier' 724 | } 725 | ]; 726 | const code = 200; 727 | nock(host).get('/teams').reply(code, body); 728 | const store = mockStore({ 729 | users: {} 730 | }); 731 | return store.dispatch(actionFuncs[action](context)).then(() => { 732 | const actions = store.getActions(); 733 | actions[1].payload.receivedAt = null; 734 | expect(actions).toMatchSnapshot(); 735 | }); 736 | }); 737 | it('should support context override', () => { 738 | const overridenUrl = `${host}/teams/:id`; 739 | const actionFuncs = createActions(defaultActions, { 740 | resourceName, 741 | url 742 | }); 743 | const actionId = 'fetch'; 744 | const action = getActionName(actionId, { 745 | resourceName, 746 | isArray: true 747 | }); 748 | const context = {}; 749 | const body = [ 750 | { 751 | id: 1, 752 | firstName: 'Olivier' 753 | } 754 | ]; 755 | const code = 200; 756 | nock(host).get('/teams').reply(code, body); 757 | const store = mockStore({ 758 | users: {} 759 | }); 760 | return store 761 | .dispatch( 762 | actionFuncs[action](context, { 763 | url: overridenUrl 764 | }) 765 | ) 766 | .then(() => { 767 | const actions = store.getActions(); 768 | actions[1].payload.receivedAt = null; 769 | expect(actions).toMatchSnapshot(); 770 | }); 771 | }); 772 | it('should support relative urls', () => { 773 | const overridenUrl = './merge'; 774 | const actionFuncs = createActions( 775 | { 776 | ...defaultActions, 777 | update: { 778 | ...defaultActions.update, 779 | url: overridenUrl 780 | } 781 | }, 782 | { 783 | resourceName, 784 | url 785 | } 786 | ); 787 | const actionId = 'update'; 788 | const action = getActionName(actionId, { 789 | resourceName 790 | }); 791 | const context = { 792 | id: 1, 793 | firstName: 'Olivier' 794 | }; 795 | const body = { 796 | ok: 1 797 | }; 798 | const code = 200; 799 | nock(host).patch(`/users/${context.id}/merge`, context).reply(code, body); 800 | const store = mockStore({ 801 | users: {} 802 | }); 803 | return store.dispatch(actionFuncs[action](context)).then(() => { 804 | const actions = store.getActions(); 805 | actions[1].payload.receivedAt = null; 806 | expect(actions).toMatchSnapshot(); 807 | }); 808 | }); 809 | it('should support relative urls for array methods', () => { 810 | const overridenUrl = './aggregate'; 811 | const actionFuncs = createActions( 812 | { 813 | ...defaultActions, 814 | aggregate: {method: 'GET', gerundName: 'aggregating', url: overridenUrl, isArray: true} 815 | }, 816 | { 817 | resourceName, 818 | url 819 | } 820 | ); 821 | const actionId = 'aggregate'; 822 | const action = getActionName(actionId, { 823 | resourceName, 824 | isArray: true 825 | }); 826 | const context = {}; 827 | const body = { 828 | ok: 1 829 | }; 830 | const code = 200; 831 | nock(host).get(`/users/aggregate`).reply(code, body); 832 | const store = mockStore({ 833 | users: {} 834 | }); 835 | return store.dispatch(actionFuncs[action](context)).then(() => { 836 | const actions = store.getActions(); 837 | actions[1].payload.receivedAt = null; 838 | expect(actions).toMatchSnapshot(); 839 | }); 840 | }); 841 | }); 842 | describe('`method` option', () => { 843 | it('should support action override', () => { 844 | const method = 'PATCH'; 845 | const actionFuncs = createActions( 846 | { 847 | ...defaultActions, 848 | fetch: { 849 | ...defaultActions.fetch, 850 | method 851 | } 852 | }, 853 | { 854 | resourceName, 855 | url 856 | } 857 | ); 858 | const actionId = 'fetch'; 859 | const action = getActionName(actionId, { 860 | resourceName, 861 | isArray: true 862 | }); 863 | const context = {}; 864 | const body = [ 865 | { 866 | id: 1, 867 | firstName: 'Olivier' 868 | } 869 | ]; 870 | const code = 200; 871 | nock(host).patch('/users').reply(code, body); 872 | const store = mockStore({ 873 | users: {} 874 | }); 875 | return store.dispatch(actionFuncs[action](context)).then(() => { 876 | const actions = store.getActions(); 877 | actions[1].payload.receivedAt = null; 878 | expect(actions).toMatchSnapshot(); 879 | }); 880 | }); 881 | it('should support action override via function', () => { 882 | const method = (...args) => { 883 | checkActionMethodSignature(...args); 884 | return 'PATCH'; 885 | }; 886 | const actionFuncs = createActions( 887 | { 888 | ...defaultActions, 889 | fetch: { 890 | ...defaultActions.fetch, 891 | method 892 | } 893 | }, 894 | { 895 | resourceName, 896 | url 897 | } 898 | ); 899 | const actionId = 'fetch'; 900 | const action = getActionName(actionId, { 901 | resourceName, 902 | isArray: true 903 | }); 904 | const type = '@@resource/USER/FETC'; 905 | const context = {}; 906 | const body = [ 907 | { 908 | id: 1, 909 | firstName: 'Olivier' 910 | } 911 | ]; 912 | const code = 200; 913 | nock(host).patch('/users').reply(code, body); 914 | const store = mockStore({ 915 | users: {} 916 | }); 917 | return store.dispatch(actionFuncs[action](context)).then(() => { 918 | const actions = store.getActions(); 919 | actions[1].payload.receivedAt = null; 920 | expect(actions).toMatchSnapshot(); 921 | }); 922 | }); 923 | it('should support context override', () => { 924 | const method = 'PATCH'; 925 | const actionFuncs = createActions(defaultActions, { 926 | resourceName, 927 | url 928 | }); 929 | const actionId = 'fetch'; 930 | const action = getActionName(actionId, { 931 | resourceName, 932 | isArray: true 933 | }); 934 | const context = {}; 935 | const body = [ 936 | { 937 | id: 1, 938 | firstName: 'Olivier' 939 | } 940 | ]; 941 | const code = 200; 942 | nock(host).patch('/users').reply(code, body); 943 | const store = mockStore({ 944 | users: {} 945 | }); 946 | return store 947 | .dispatch( 948 | actionFuncs[action](context, { 949 | method 950 | }) 951 | ) 952 | .then(() => { 953 | const actions = store.getActions(); 954 | actions[1].payload.receivedAt = null; 955 | expect(actions).toMatchSnapshot(); 956 | }); 957 | }); 958 | }); 959 | describe('`query` option', () => { 960 | it('should support action override', () => { 961 | const query = { 962 | foo: 'bar' 963 | }; 964 | const actionFuncs = createActions( 965 | { 966 | ...defaultActions, 967 | fetch: { 968 | ...defaultActions.fetch, 969 | query 970 | } 971 | }, 972 | { 973 | resourceName, 974 | url 975 | } 976 | ); 977 | const actionId = 'fetch'; 978 | const action = getActionName(actionId, { 979 | resourceName, 980 | isArray: true 981 | }); 982 | const context = {}; 983 | const body = [ 984 | { 985 | id: 1, 986 | firstName: 'Olivier' 987 | } 988 | ]; 989 | const code = 200; 990 | nock(host).get('/users?foo=bar').reply(code, body); 991 | const store = mockStore({ 992 | users: {} 993 | }); 994 | return store.dispatch(actionFuncs[action](context)).then(() => { 995 | const actions = store.getActions(); 996 | actions[1].payload.receivedAt = null; 997 | expect(actions).toMatchSnapshot(); 998 | }); 999 | }); 1000 | it('should support action override via function', () => { 1001 | const query = (...args) => { 1002 | checkActionMethodSignature(...args); 1003 | return { 1004 | foo: 'bar' 1005 | }; 1006 | }; 1007 | const actionFuncs = createActions( 1008 | { 1009 | ...defaultActions, 1010 | fetch: { 1011 | ...defaultActions.fetch, 1012 | query 1013 | } 1014 | }, 1015 | { 1016 | resourceName, 1017 | url 1018 | } 1019 | ); 1020 | const actionId = 'fetch'; 1021 | const action = getActionName(actionId, { 1022 | resourceName, 1023 | isArray: true 1024 | }); 1025 | const type = '@@resource/USER/FETCH'; 1026 | const context = {}; 1027 | const body = [ 1028 | { 1029 | id: 1, 1030 | firstName: 'Olivier' 1031 | } 1032 | ]; 1033 | const code = 200; 1034 | const options = { 1035 | isArray: true 1036 | }; 1037 | nock(host).get('/users?foo=bar').reply(code, body); 1038 | const store = mockStore({ 1039 | users: {} 1040 | }); 1041 | return store.dispatch(actionFuncs[action](context)).then(() => { 1042 | const actions = store.getActions(); 1043 | actions[1].payload.receivedAt = null; 1044 | expect(actions).toMatchSnapshot(); 1045 | }); 1046 | }); 1047 | it('should support context override', () => { 1048 | const query = { 1049 | foo: 'bar' 1050 | }; 1051 | const actionFuncs = createActions(defaultActions, { 1052 | resourceName, 1053 | url 1054 | }); 1055 | const actionId = 'fetch'; 1056 | const action = getActionName(actionId, { 1057 | resourceName, 1058 | isArray: true 1059 | }); 1060 | const context = {}; 1061 | const body = [ 1062 | { 1063 | id: 1, 1064 | firstName: 'Olivier' 1065 | } 1066 | ]; 1067 | const code = 200; 1068 | nock(host).get('/users?foo=bar').reply(code, body); 1069 | const store = mockStore({ 1070 | users: {} 1071 | }); 1072 | return store 1073 | .dispatch( 1074 | actionFuncs[action](context, { 1075 | query 1076 | }) 1077 | ) 1078 | .then(() => { 1079 | const actions = store.getActions(); 1080 | actions[1].payload.receivedAt = null; 1081 | expect(actions).toMatchSnapshot(); 1082 | }); 1083 | }); 1084 | it('should support non-string query params', () => { 1085 | const query = { 1086 | select: ['firstName', 'lastName'], 1087 | populate: [ 1088 | { 1089 | path: 'team', 1090 | model: 'Team', 1091 | select: ['id', 'name'] 1092 | } 1093 | ] 1094 | }; 1095 | const actionFuncs = createActions(defaultActions, { 1096 | resourceName, 1097 | url 1098 | }); 1099 | const actionId = 'fetch'; 1100 | const action = getActionName(actionId, { 1101 | resourceName, 1102 | isArray: true 1103 | }); 1104 | const context = {}; 1105 | const body = [ 1106 | { 1107 | id: 1, 1108 | firstName: 'Olivier' 1109 | } 1110 | ]; 1111 | const code = 200; 1112 | nock(host) 1113 | .get( 1114 | '/users?select=[%22firstName%22,%22lastName%22]&populate=[%7B%22path%22:%22team%22,%22model%22:%22Team%22,%22select%22:[%22id%22,%22name%22]%7D]' 1115 | ) 1116 | .reply(code, body); 1117 | const store = mockStore({ 1118 | users: {} 1119 | }); 1120 | return store 1121 | .dispatch( 1122 | actionFuncs[action](context, { 1123 | query 1124 | }) 1125 | ) 1126 | .then(() => { 1127 | const actions = store.getActions(); 1128 | actions[1].payload.receivedAt = null; 1129 | expect(actions).toMatchSnapshot(); 1130 | }); 1131 | }); 1132 | }); 1133 | describe('`params` option', () => { 1134 | it('should support context override', () => { 1135 | const params = { 1136 | id: 1 1137 | }; 1138 | const actionFuncs = createActions(defaultActions, { 1139 | resourceName, 1140 | url 1141 | }); 1142 | const actionId = 'update'; 1143 | const action = getActionName(actionId, { 1144 | resourceName, 1145 | isArray: true 1146 | }); 1147 | const context = {firstName: 'Olivia'}; 1148 | const body = [ 1149 | { 1150 | id: 1, 1151 | firstName: 'Olivier' 1152 | } 1153 | ]; 1154 | const code = 200; 1155 | nock(host).patch(`/users/${params.id}`).reply(code, body); 1156 | const store = mockStore({}); 1157 | return store.dispatch(actionFuncs[action](context, {params})).then(() => { 1158 | const actions = store.getActions(); 1159 | actions[1].payload.receivedAt = null; 1160 | expect(actions).toMatchSnapshot(); 1161 | }); 1162 | }); 1163 | }); 1164 | describe('`headers` option', () => { 1165 | Object.assign(defaultHeaders, { 1166 | 'X-Custom-Default-Header': 'foobar' 1167 | }); 1168 | it('should support defaults override', () => { 1169 | const actionFuncs = createActions(defaultActions, { 1170 | resourceName, 1171 | url 1172 | }); 1173 | const actionId = 'fetch'; 1174 | const action = getActionName(actionId, { 1175 | resourceName, 1176 | isArray: true 1177 | }); 1178 | const context = {}; 1179 | const body = [ 1180 | { 1181 | id: 1, 1182 | firstName: 'Olivier' 1183 | } 1184 | ]; 1185 | const code = 200; 1186 | nock(host).get('/users').matchHeader('X-Custom-Default-Header', 'foobar').reply(code, body); 1187 | const store = mockStore({ 1188 | users: {} 1189 | }); 1190 | return store.dispatch(actionFuncs[action](context)).then(() => { 1191 | const actions = store.getActions(); 1192 | actions[1].payload.receivedAt = null; 1193 | expect(actions).toMatchSnapshot(); 1194 | }); 1195 | }); 1196 | it('should support action override', () => { 1197 | const headers = { 1198 | 'X-Custom-Header': 'foobar' 1199 | }; 1200 | const actionFuncs = createActions( 1201 | { 1202 | ...defaultActions, 1203 | fetch: { 1204 | ...defaultActions.fetch, 1205 | headers 1206 | } 1207 | }, 1208 | { 1209 | resourceName, 1210 | url 1211 | } 1212 | ); 1213 | const actionId = 'fetch'; 1214 | const action = getActionName(actionId, { 1215 | resourceName, 1216 | isArray: true 1217 | }); 1218 | const context = {}; 1219 | const body = [ 1220 | { 1221 | id: 1, 1222 | firstName: 'Olivier' 1223 | } 1224 | ]; 1225 | const code = 200; 1226 | 1227 | nock(host).get('/users').matchHeader('X-Custom-Header', 'foobar').reply(code, body); 1228 | const store = mockStore({ 1229 | users: {} 1230 | }); 1231 | return store.dispatch(actionFuncs[action](context)).then(() => { 1232 | const actions = store.getActions(); 1233 | actions[1].payload.receivedAt = null; 1234 | expect(actions).toMatchSnapshot(); 1235 | }); 1236 | }); 1237 | it('should support action override via function', () => { 1238 | const headers = (...args) => { 1239 | checkActionMethodSignature(...args); 1240 | return { 1241 | 'X-Custom-Header': 'foobar' 1242 | }; 1243 | }; 1244 | const actionFuncs = createActions( 1245 | { 1246 | ...defaultActions, 1247 | fetch: { 1248 | ...defaultActions.fetch, 1249 | headers 1250 | } 1251 | }, 1252 | { 1253 | resourceName, 1254 | url 1255 | } 1256 | ); 1257 | const actionId = 'fetch'; 1258 | const action = getActionName(actionId, { 1259 | resourceName, 1260 | isArray: true 1261 | }); 1262 | const context = {}; 1263 | const body = [ 1264 | { 1265 | id: 1, 1266 | firstName: 'Olivier' 1267 | } 1268 | ]; 1269 | const code = 200; 1270 | 1271 | nock(host).get('/users').matchHeader('X-Custom-Header', 'foobar').reply(code, body); 1272 | const store = mockStore({ 1273 | users: {} 1274 | }); 1275 | return store.dispatch(actionFuncs[action](context)).then(() => { 1276 | const actions = store.getActions(); 1277 | actions[1].payload.receivedAt = null; 1278 | expect(actions).toMatchSnapshot(); 1279 | }); 1280 | }); 1281 | it('should support context override', () => { 1282 | const headers = { 1283 | 'X-Custom-Header': 'foobar' 1284 | }; 1285 | const actionFuncs = createActions(defaultActions, { 1286 | resourceName, 1287 | url 1288 | }); 1289 | const actionId = 'fetch'; 1290 | const action = getActionName(actionId, { 1291 | resourceName, 1292 | isArray: true 1293 | }); 1294 | const context = {}; 1295 | const body = [ 1296 | { 1297 | id: 1, 1298 | firstName: 'Olivier' 1299 | } 1300 | ]; 1301 | const code = 200; 1302 | 1303 | nock(host).get('/users').matchHeader('X-Custom-Header', 'foobar').reply(code, body); 1304 | const store = mockStore({ 1305 | users: {} 1306 | }); 1307 | return store 1308 | .dispatch( 1309 | actionFuncs[action](context, { 1310 | headers 1311 | }) 1312 | ) 1313 | .then(() => { 1314 | const actions = store.getActions(); 1315 | actions[1].payload.receivedAt = null; 1316 | expect(actions).toMatchSnapshot(); 1317 | }); 1318 | }); 1319 | }); 1320 | describe('`credentials` option', () => { 1321 | it('should support action override', () => { 1322 | const credentials = 'include'; 1323 | const actionFuncs = createActions( 1324 | { 1325 | ...defaultActions, 1326 | fetch: { 1327 | ...defaultActions.fetch, 1328 | credentials 1329 | } 1330 | }, 1331 | { 1332 | resourceName, 1333 | url 1334 | } 1335 | ); 1336 | const actionId = 'fetch'; 1337 | const action = getActionName(actionId, { 1338 | resourceName, 1339 | isArray: true 1340 | }); 1341 | const context = {}; 1342 | const body = [ 1343 | { 1344 | id: 1, 1345 | firstName: 'Olivier' 1346 | } 1347 | ]; 1348 | const code = 200; 1349 | 1350 | nock(host) 1351 | .get('/users') 1352 | // .matchHeader('Access-Control-Allow-Origin', '*') 1353 | // .matchHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') 1354 | .reply(code, body); 1355 | const store = mockStore({ 1356 | users: {} 1357 | }); 1358 | return store.dispatch(actionFuncs[action](context)).then(() => { 1359 | const actions = store.getActions(); 1360 | actions[1].payload.receivedAt = null; 1361 | expect(actions).toMatchSnapshot(); 1362 | }); 1363 | }); 1364 | it('should support action override via function', () => { 1365 | const credentials = (...args) => { 1366 | checkActionMethodSignature(...args); 1367 | return 'include'; 1368 | }; 1369 | const actionFuncs = createActions( 1370 | { 1371 | ...defaultActions, 1372 | fetch: { 1373 | ...defaultActions.fetch, 1374 | credentials 1375 | } 1376 | }, 1377 | { 1378 | resourceName, 1379 | url 1380 | } 1381 | ); 1382 | const actionId = 'fetch'; 1383 | const action = getActionName(actionId, { 1384 | resourceName, 1385 | isArray: true 1386 | }); 1387 | const context = {}; 1388 | const body = [ 1389 | { 1390 | id: 1, 1391 | firstName: 'Olivier' 1392 | } 1393 | ]; 1394 | const code = 200; 1395 | nock(host) 1396 | .get('/users') 1397 | // .matchHeader('Access-Control-Allow-Origin', '*') 1398 | // .matchHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') 1399 | .reply(code, body); 1400 | const store = mockStore({ 1401 | users: {} 1402 | }); 1403 | return store.dispatch(actionFuncs[action](context)).then(() => { 1404 | const actions = store.getActions(); 1405 | actions[1].payload.receivedAt = null; 1406 | expect(actions).toMatchSnapshot(); 1407 | }); 1408 | }); 1409 | it('should support context override', () => { 1410 | const credentials = 'include'; 1411 | const actionFuncs = createActions(defaultActions, { 1412 | resourceName, 1413 | url 1414 | }); 1415 | const actionId = 'fetch'; 1416 | const action = getActionName(actionId, { 1417 | resourceName, 1418 | isArray: true 1419 | }); 1420 | const context = {}; 1421 | const body = [ 1422 | { 1423 | id: 1, 1424 | firstName: 'Olivier' 1425 | } 1426 | ]; 1427 | const code = 200; 1428 | nock(host) 1429 | .get('/users') 1430 | // .matchHeader('Access-Control-Allow-Origin', '*') 1431 | // .matchHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') 1432 | .reply(code, body); 1433 | const store = mockStore({ 1434 | users: {} 1435 | }); 1436 | return store 1437 | .dispatch( 1438 | actionFuncs[action](context, { 1439 | credentials 1440 | }) 1441 | ) 1442 | .then(() => { 1443 | const actions = store.getActions(); 1444 | actions[1].payload.receivedAt = null; 1445 | expect(actions).toMatchSnapshot(); 1446 | }); 1447 | }); 1448 | }); 1449 | 1450 | describe('`signal` option', () => { 1451 | it('should support action override', () => { 1452 | const controller = new AbortController(); 1453 | const {signal} = controller; 1454 | // const timeoutId = setTimeout(() => controller.abort(), 100); 1455 | const actionFuncs = createActions( 1456 | { 1457 | ...defaultActions, 1458 | fetch: { 1459 | ...defaultActions.fetch, 1460 | signal 1461 | } 1462 | }, 1463 | { 1464 | resourceName, 1465 | url 1466 | } 1467 | ); 1468 | const actionId = 'fetch'; 1469 | const action = getActionName(actionId, { 1470 | resourceName, 1471 | isArray: true 1472 | }); 1473 | const context = {}; 1474 | const body = [ 1475 | { 1476 | id: 1, 1477 | firstName: 'Olivier' 1478 | } 1479 | ]; 1480 | const code = 200; 1481 | nock(host).get('/users').delayConnection(2000).reply(code, body); 1482 | const store = mockStore({ 1483 | users: {} 1484 | }); 1485 | return store.dispatch(actionFuncs[action](context)).then(() => { 1486 | const actions = store.getActions(); 1487 | actions[1].payload.receivedAt = null; 1488 | expect(actions).toMatchSnapshot(); 1489 | }); 1490 | }); 1491 | }); 1492 | 1493 | describe('`body` option', () => { 1494 | it('should support context override', () => { 1495 | const contextBody = { 1496 | firstName: 'Olivier' 1497 | }; 1498 | const actionFuncs = createActions(defaultActions, { 1499 | resourceName, 1500 | url 1501 | }); 1502 | const actionId = 'update'; 1503 | const action = getActionName(actionId, { 1504 | resourceName 1505 | }); 1506 | const context = { 1507 | id: 1 1508 | }; 1509 | const body = { 1510 | ok: true 1511 | }; 1512 | const code = 200; 1513 | nock(host).patch(`/users/${context.id}`, contextBody).reply(code, body); 1514 | const store = mockStore({ 1515 | users: {} 1516 | }); 1517 | return store 1518 | .dispatch( 1519 | actionFuncs[action](context, { 1520 | body: contextBody 1521 | }) 1522 | ) 1523 | .then(() => { 1524 | const actions = store.getActions(); 1525 | actions[1].payload.receivedAt = null; 1526 | expect(actions).toMatchSnapshot(); 1527 | }); 1528 | }); 1529 | }); 1530 | }); 1531 | 1532 | describe('reduce options', () => { 1533 | afterEach(() => { 1534 | nock.cleanAll(); 1535 | }); 1536 | describe('`isArray` option', () => { 1537 | it('should support action override', () => { 1538 | const isArray = true; 1539 | const actionFuncs = createActions( 1540 | { 1541 | ...defaultActions, 1542 | create: { 1543 | ...defaultActions.create, 1544 | isArray 1545 | } 1546 | }, 1547 | { 1548 | resourceName, 1549 | url 1550 | } 1551 | ); 1552 | const actionId = 'create'; 1553 | const action = getActionName(actionId, { 1554 | resourceName, 1555 | isArray: true 1556 | }); 1557 | const context = { 1558 | firstName: 'Olivier' 1559 | }; 1560 | const body = { 1561 | ok: 1 1562 | }; 1563 | const code = 200; 1564 | nock(host).post('/users', context).reply(code, body); 1565 | const store = mockStore({ 1566 | users: {} 1567 | }); 1568 | return store.dispatch(actionFuncs[action](context)).then(() => { 1569 | const actions = store.getActions(); 1570 | actions[1].payload.receivedAt = null; 1571 | expect(actions).toMatchSnapshot(); 1572 | }); 1573 | }); 1574 | it('should support context override', () => { 1575 | const isArray = true; 1576 | const actionFuncs = createActions(defaultActions, { 1577 | resourceName, 1578 | url 1579 | }); 1580 | const actionId = 'update'; 1581 | const action = getActionName(actionId, { 1582 | resourceName 1583 | }); 1584 | const context = { 1585 | id: 1, 1586 | firstName: 'Olivier' 1587 | }; 1588 | const body = { 1589 | ok: 1 1590 | }; 1591 | const code = 200; 1592 | nock(host).patch(`/users/${context.id}`, context).reply(code, body); 1593 | const store = mockStore({ 1594 | users: {} 1595 | }); 1596 | return store 1597 | .dispatch( 1598 | actionFuncs[action](context, { 1599 | isArray 1600 | }) 1601 | ) 1602 | .then(() => { 1603 | const actions = store.getActions(); 1604 | actions[1].payload.receivedAt = null; 1605 | expect(actions).toMatchSnapshot(); 1606 | }); 1607 | }); 1608 | }); 1609 | describe('`assignResponse` option', () => { 1610 | it('should support action override', () => { 1611 | const assignResponse = true; 1612 | const actionFuncs = createActions( 1613 | { 1614 | ...defaultActions, 1615 | update: { 1616 | ...defaultActions.update, 1617 | assignResponse 1618 | } 1619 | }, 1620 | { 1621 | resourceName, 1622 | url 1623 | } 1624 | ); 1625 | const actionId = 'update'; 1626 | const action = getActionName(actionId, { 1627 | resourceName 1628 | }); 1629 | const context = { 1630 | id: 1, 1631 | firstName: 'Olivier' 1632 | }; 1633 | const body = { 1634 | ok: 1 1635 | }; 1636 | const code = 200; 1637 | nock(host).patch(`/users/${context.id}`, context).reply(code, body); 1638 | const store = mockStore({ 1639 | users: {} 1640 | }); 1641 | return store.dispatch(actionFuncs[action](context)).then(() => { 1642 | const actions = store.getActions(); 1643 | actions[1].payload.receivedAt = null; 1644 | expect(actions).toMatchSnapshot(); 1645 | }); 1646 | }); 1647 | it('should support context override', () => { 1648 | const assignResponse = true; 1649 | const actionFuncs = createActions(defaultActions, { 1650 | resourceName, 1651 | url 1652 | }); 1653 | const actionId = 'update'; 1654 | const action = getActionName(actionId, { 1655 | resourceName 1656 | }); 1657 | const context = { 1658 | id: 1, 1659 | firstName: 'Olivier' 1660 | }; 1661 | const body = { 1662 | ok: 1 1663 | }; 1664 | const code = 200; 1665 | nock(host).patch(`/users/${context.id}`, context).reply(code, body); 1666 | const store = mockStore({ 1667 | users: {} 1668 | }); 1669 | return store 1670 | .dispatch( 1671 | actionFuncs[action](context, { 1672 | assignResponse 1673 | }) 1674 | ) 1675 | .then(() => { 1676 | const actions = store.getActions(); 1677 | actions[1].payload.receivedAt = null; 1678 | expect(actions).toMatchSnapshot(); 1679 | }); 1680 | }); 1681 | }); 1682 | 1683 | // it('.get()', () => { 1684 | // const actionId = 'get'; 1685 | // const action = getActionName(actionId, { 1686 | // resourceName 1687 | // }); 1688 | // const context = { 1689 | // id: 1 1690 | // }; 1691 | // const body = { 1692 | // id: 1, 1693 | // firstName: 'Olivier' 1694 | // }; 1695 | // const code = 200; 1696 | // nock(host).get(`/users/${context.id}`).reply(code, body); 1697 | // const store = mockStore({ 1698 | // users: {} 1699 | // }); 1700 | // return store.dispatch(actionFuncs[action](context)).then((res) => { 1701 | // res.receivedAt = null; 1702 | // expect(res).toMatchSnapshot(); 1703 | // const actions = store.getActions(); 1704 | // actions[1].payload.receivedAt = null; 1705 | // expect(actions).toMatchSnapshot(); 1706 | // }); 1707 | // }); 1708 | describe('`mergeResponse` option', () => { 1709 | it('should support action override', () => { 1710 | const mergeResponse = true; 1711 | const actionId = 'get'; 1712 | const action = getActionName(actionId, { 1713 | resourceName 1714 | }); 1715 | const actionFuncs = createActions( 1716 | { 1717 | ...defaultActions, 1718 | [actionId]: { 1719 | ...defaultActions[actionId], 1720 | mergeResponse 1721 | } 1722 | }, 1723 | { 1724 | resourceName, 1725 | url 1726 | } 1727 | ); 1728 | const context = {id: 1}; 1729 | const body = { 1730 | id: 1, 1731 | firstName: 'John' 1732 | }; 1733 | const code = 200; 1734 | nock(host).get(`/users/${context.id}`).reply(code, body); 1735 | const store = mockStore(); 1736 | return store.dispatch(actionFuncs[action](context)).then(() => { 1737 | const actions = store.getActions(); 1738 | actions[1].payload.receivedAt = null; 1739 | expect(actions).toMatchSnapshot(); 1740 | }); 1741 | }); 1742 | it('should support context override', () => { 1743 | const mergeResponse = true; 1744 | const actionId = 'get'; 1745 | const action = getActionName(actionId, { 1746 | resourceName 1747 | }); 1748 | const actionFuncs = createActions(defaultActions, { 1749 | resourceName, 1750 | url 1751 | }); 1752 | const context = {id: 1}; 1753 | const body = { 1754 | id: 1, 1755 | firstName: 'John' 1756 | }; 1757 | const code = 200; 1758 | nock(host).get(`/users/${context.id}`).reply(code, body); 1759 | const store = mockStore({ 1760 | users: {} 1761 | }); 1762 | return store 1763 | .dispatch( 1764 | actionFuncs[action](context, { 1765 | mergeResponse 1766 | }) 1767 | ) 1768 | .then(() => { 1769 | const actions = store.getActions(); 1770 | actions[1].payload.receivedAt = null; 1771 | expect(actions).toMatchSnapshot(); 1772 | }); 1773 | }); 1774 | }); 1775 | describe('`invalidateState` option', () => { 1776 | it('should support action override', () => { 1777 | const invalidateState = true; 1778 | const actionFuncs = createActions( 1779 | { 1780 | ...defaultActions, 1781 | fetch: { 1782 | ...defaultActions.fetch, 1783 | invalidateState 1784 | } 1785 | }, 1786 | { 1787 | resourceName, 1788 | url 1789 | } 1790 | ); 1791 | const actionId = 'fetch'; 1792 | const action = getActionName(actionId, { 1793 | resourceName, 1794 | isArray: true 1795 | }); 1796 | const context = {}; 1797 | const body = [ 1798 | { 1799 | id: 1, 1800 | firstName: 'Olivier' 1801 | } 1802 | ]; 1803 | const code = 200; 1804 | nock(host).get('/users').reply(code, body); 1805 | const store = mockStore({ 1806 | users: {} 1807 | }); 1808 | return store.dispatch(actionFuncs[action](context)).then(() => { 1809 | const actions = store.getActions(); 1810 | actions[1].payload.receivedAt = null; 1811 | expect(actions).toMatchSnapshot(); 1812 | }); 1813 | }); 1814 | }); 1815 | describe('`beforeError` hook', () => { 1816 | it('should support action override', () => { 1817 | const beforeError = jest.fn((error) => { 1818 | return error; 1819 | }); 1820 | const actionFuncs = createActions( 1821 | { 1822 | ...defaultActions 1823 | }, 1824 | { 1825 | beforeError: [beforeError], 1826 | resourceName, 1827 | url 1828 | } 1829 | ); 1830 | const actionId = 'fetch'; 1831 | const action = getActionName(actionId, { 1832 | resourceName, 1833 | isArray: true 1834 | }); 1835 | const context = {}; 1836 | nock(host).get('/users').replyWithError('something awful happened'); 1837 | const store = mockStore({ 1838 | users: {} 1839 | }); 1840 | return expect(store.dispatch(actionFuncs[action](context))) 1841 | .rejects.toBeDefined() 1842 | .then(() => { 1843 | const actions = store.getActions(); 1844 | actions[1].payload.receivedAt = null; 1845 | expect(actions).toMatchSnapshot(); 1846 | expect(beforeError.mock.calls.length).toBe(1); 1847 | }); 1848 | }); 1849 | }); 1850 | }); 1851 | -------------------------------------------------------------------------------- /test/spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import {createResource, createResourceAction, fetch} from '../../src'; 3 | 4 | // Configuration 5 | const name = 'user'; 6 | const host = 'http://localhost:3000'; 7 | const url = `${host}/users/:id`; 8 | 9 | describe('lib', () => { 10 | it('should properly export fetch', () => { 11 | expect(typeof fetch).toBe('function'); 12 | }); 13 | }); 14 | 15 | describe('createResource', () => { 16 | it('should properly return an object with properly named keys', () => { 17 | const {types, actions, reducers, rootReducer} = createResource({name, url}); 18 | expect(typeof types).toBe('object'); 19 | expect(typeof actions).toBe('object'); 20 | expect(typeof reducers).toBe('function'); 21 | expect(typeof rootReducer).toBe('function'); 22 | }); 23 | it('should properly merge action opts', () => { 24 | const {types, actions, reducers, rootReducer} = createResource({ 25 | name, 26 | url, 27 | actions: {get: {foo: 'bar'}, charge: {method: 'post'}} 28 | }); 29 | expect(typeof types).toBe('object'); 30 | expect(Object.keys(types).length).toEqual(8); 31 | expect(typeof actions).toBe('object'); 32 | expect(Object.keys(actions).length).toEqual(8); 33 | expect(typeof reducers).toBe('function'); 34 | expect(typeof rootReducer).toBe('function'); 35 | }); 36 | }); 37 | 38 | describe('createResourceAction', () => { 39 | it('should properly return an object with properly named keys', () => { 40 | const {types, actions, reducers, rootReducer} = createResourceAction({name, url}); 41 | expect(typeof types).toBe('object'); 42 | expect(Object.keys(types).length).toEqual(1); 43 | expect(typeof actions).toBe('object'); 44 | expect(Object.keys(actions).length).toEqual(1); 45 | expect(typeof reducers).toBe('object'); 46 | expect(Object.keys(reducers).length).toEqual(1); 47 | expect(typeof rootReducer).toBe('function'); 48 | }); 49 | }); 50 | 51 | describe('resourceOptions', () => { 52 | describe('`pick` option', () => { 53 | it('should properly pick action', () => { 54 | const {types, actions, reducers, rootReducer} = createResource({name, url, pick: ['fetch']}); 55 | expect(typeof types).toBe('object'); 56 | expect(Object.keys(types).length).toEqual(1); 57 | expect(typeof actions).toBe('object'); 58 | expect(Object.keys(actions).length).toEqual(1); 59 | expect(typeof reducers).toBe('function'); 60 | expect(typeof rootReducer).toBe('function'); 61 | }); 62 | }); 63 | describe('`mergeDefaultActions` option', () => { 64 | it('should properly not merge defaultActions', () => { 65 | const {types, actions, reducers, rootReducer} = createResource({ 66 | name, 67 | url, 68 | actions: {charge: {method: 'post'}}, 69 | mergeDefaultActions: false 70 | }); 71 | expect(typeof types).toBe('object'); 72 | expect(Object.keys(types).length).toEqual(1); 73 | expect(typeof actions).toBe('object'); 74 | expect(Object.keys(actions).length).toEqual(1); 75 | expect(typeof reducers).toBe('function'); 76 | expect(typeof rootReducer).toBe('function'); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/spec/types.spec.ts: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import {values} from 'lodash'; 3 | import {createTypes} from '../../src/types'; 4 | import {defaultActions} from '../../src/defaults'; 5 | 6 | describe('createTypes', () => { 7 | describe('when using a resource', () => { 8 | it('should properly return an object with properly named keys', () => { 9 | const resourceName = 'user'; 10 | const types = createTypes(defaultActions, { 11 | resourceName 12 | }); 13 | const expectedKeys = [ 14 | 'CREATE_USER', 15 | 'FETCH_USERS', 16 | 'GET_USER', 17 | 'UPDATE_USER', 18 | 'UPDATE_USERS', 19 | 'DELETE_USER', 20 | 'DELETE_USERS' 21 | ]; 22 | expect(Object.keys(types)).toEqual(expectedKeys); 23 | const expectedValues = [ 24 | '@@resource/USER/CREATE', 25 | '@@resource/USER/FETCH', 26 | '@@resource/USER/GET', 27 | '@@resource/USER/UPDATE', 28 | '@@resource/USER/UPDATE_MANY', 29 | '@@resource/USER/DELETE', 30 | '@@resource/USER/DELETE_MANY' 31 | ]; 32 | expect(values(types)).toEqual(expectedValues); 33 | }); 34 | }); 35 | describe('when not using a resource', () => { 36 | it('should properly return an object with properly named keys', () => { 37 | const types = createTypes(defaultActions, {}); 38 | const expectedKeys = ['CREATE', 'FETCH', 'GET', 'UPDATE', 'UPDATE_MANY', 'DELETE', 'DELETE_MANY']; 39 | expect(Object.keys(types)).toEqual(expectedKeys); 40 | const expectedValues = ['CREATE', 'FETCH', 'GET', 'UPDATE', 'UPDATE_MANY', 'DELETE', 'DELETE_MANY']; 41 | expect(values(types)).toEqual(expectedValues); 42 | }); 43 | }); 44 | describe('when using a falsy scope', () => { 45 | it('should properly return an object with properly named keys', () => { 46 | const types = createTypes(defaultActions, { 47 | scope: false 48 | }); 49 | const expectedKeys = ['CREATE', 'FETCH', 'GET', 'UPDATE', 'UPDATE_MANY', 'DELETE', 'DELETE_MANY']; 50 | expect(Object.keys(types)).toEqual(expectedKeys); 51 | const expectedValues = ['CREATE', 'FETCH', 'GET', 'UPDATE', 'UPDATE_MANY', 'DELETE', 'DELETE_MANY']; 52 | expect(values(types)).toEqual(expectedValues); 53 | }); 54 | }); 55 | describe('when using a custom scope', () => { 56 | it('should properly return an object with properly named keys', () => { 57 | const types = createTypes(defaultActions, { 58 | scope: '@@custom/TEAM' 59 | }); 60 | const expectedKeys = ['CREATE', 'FETCH', 'GET', 'UPDATE', 'UPDATE_MANY', 'DELETE', 'DELETE_MANY']; 61 | expect(Object.keys(types)).toEqual(expectedKeys); 62 | const expectedValues = [ 63 | '@@custom/TEAM/CREATE', 64 | '@@custom/TEAM/FETCH', 65 | '@@custom/TEAM/GET', 66 | '@@custom/TEAM/UPDATE', 67 | '@@custom/TEAM/UPDATE_MANY', 68 | '@@custom/TEAM/DELETE', 69 | '@@custom/TEAM/DELETE_MANY' 70 | ]; 71 | expect(values(types)).toEqual(expectedValues); 72 | }); 73 | }); 74 | describe('when using custom actions', () => { 75 | it('should properly return an object with properly named keys', () => { 76 | const resourceName = 'team'; 77 | const customActions = { 78 | promote: { 79 | method: 'POST' 80 | }, 81 | merge: { 82 | method: 'POST', 83 | isArray: true 84 | } 85 | }; 86 | const types = createTypes(customActions, { 87 | resourceName 88 | }); 89 | const expectedKeys = ['PROMOTE_TEAM', 'MERGE_TEAMS']; 90 | expect(Object.keys(types)).toEqual(expectedKeys); 91 | const expectedValues = ['@@resource/TEAM/PROMOTE', '@@resource/TEAM/MERGE']; 92 | expect(values(types)).toEqual(expectedValues); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": true, 9 | "jsx": "react", 10 | "lib": ["dom", "dom.iterable", "esnext"], 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "emitDeclarationOnly": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strict": true /* Enable all strict type checking options */, 17 | "noUnusedLocals": true /* Report errors on unused locals. */, 18 | "noUnusedParameters": true /* Report errors on unused parameters. */, 19 | "target": "esnext", 20 | "outDir": "./lib", 21 | "rootDir": "./src", 22 | "baseUrl": "./", 23 | "paths": { 24 | "src/*": ["./src/*"] 25 | } 26 | }, 27 | "include": ["src"] 28 | } 29 | --------------------------------------------------------------------------------