From 3a8023c80b81632fbe2549bb1dd6407c9f8dc6f1 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Sun, 20 Jun 2021 00:49:22 -0400 Subject: [PATCH] :sparkles: :boom: new feature `prepareStore` (#4) * :sparkles: :boom: adding `prepareStore` to setup `redux-batched-actions` and `redux-saga` and some built-in reducers to make dev easier * revamped middleware --- README.md | 493 ++++++++++++++--------------------------- package.json | 7 +- src/api.test.ts | 20 +- src/api.ts | 2 +- src/fetch.ts | 11 +- src/index.ts | 3 + src/middleware.test.ts | 125 +++++++++-- src/middleware.ts | 72 +++++- src/pipe.test.ts | 9 +- src/slice.ts | 39 ++++ src/store.ts | 75 +++++++ src/types.ts | 25 ++- src/util.ts | 20 ++ yarn.lock | 13 +- 14 files changed, 523 insertions(+), 391 deletions(-) create mode 100644 src/slice.ts create mode 100644 src/store.ts diff --git a/README.md b/README.md index 014e4b6..b4ded23 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [![ci](https://github.com/neurosnap/saga-query/actions/workflows/test.yml/badge.svg)](https://github.com/neurosnap/saga-query/actions/workflows/test.yml) -Data fetching and caching using redux-saga. Use our saga middleware system to -quickly build data loading within your redux application. +Data fetching and caching using a robust middleware system. +Quickly build data loading within your redux application and reduce boilderplate. **This library is undergoing active development. Consider this in a beta state.** @@ -31,17 +31,11 @@ state.** - Write middleware to handle fetching, synchronizing, and caching API requests on the front-end - A familiar middleware system that node.js developers are familiar with - (e.g. express, koa) -- Unleash the power of redux-saga to handle any async flow control use-cases -- Full control over the data fetching and caching layers in your application -- Fine tune queries and caching for your specific needs -- Pre-built middleware to cut out boilerplate for interacting with redux and - redux-saga + (e.g. koa) - Simple recipes to handle complex use-cases like cancellation, polling, optimistic updates, loading states, undo, react -- Progressively add it to your codebase: all we do is add sagas and action - creators to your system -- Use it with any other redux libraries +- Full control over the data fetching and caching layers in your application +- Fine tune selectors for your specific needs ## Why? @@ -51,14 +45,13 @@ Libraries like [react-query](https://react-query.tanstack.com/), easier than ever to fetch and cache data from an API server. All of them have their unique attributes and I encourage everyone to check them out. -I find that the async flow control of `redux-saga` is one of the most robust -and powerful declaractive side-effect systems I have used. Treating +There's no better async flow control system than `redux-saga`. Treating side-effects as data makes testing dead simple and provides a powerful effect -handling system to accomodate any use-case. Features like polling, data -loading states, cancellation, racing, parallelization, optimistic updates, -and undo are at your disposal when using `redux-saga`. Other -libraries and paradigms can also accomplish the same tasks, but I think nothing -rivals the readability and maintainability of redux/redux-saga. +handling system to accomodate any use-case. Features like polling, data loading +states, cancellation, racing, parallelization, optimistic updates, and undo are +at your disposal when using `redux-saga`. Other libraries and paradigms can +also accomplish the same tasks, but I think nothing rivals the readability and +maintainability of redux/redux-saga. All three libraries above are reinventing async flow control and hiding them from the end-developer. For the happy path, this works beautifully. Why learn @@ -132,66 +125,86 @@ import { put, call } from 'redux-saga/effects'; import { createTable, createReducerMap } from 'robodux'; import { createApi, - queryCtx, - urlParser, - FetchCtx + requestMonitor, + requestParser, + prepareStore, + FetchCtx, } from 'saga-query'; +export interface AppState extends QueryState { + users: MapEntity; +} + interface User { id: string; email: string; } const users = createTable({ name: 'users' }); -const selectors = users.getSelectors((s) => s[users.name]); +const selectors = users.getSelectors((s: AppState) => s[users.name]); export const { selectTableAsList: selectUsersAsList } = selectors; const api = createApi(); +api.use(requestMonitor()); api.use(api.routes()); -api.use(queryCtx); -api.use(urlParser); +api.use(requestParser()); api.use(function* onFetch(ctx, next) { const { url = '', ...options } = ctx.request; const resp = yield call(fetch, url, options); const data = yield call([resp, 'json']); - ctx.response = { status: resp.status, ok: resp.ok, data }; yield next(); }); -const fetchUsers = api.get( +export const fetchUsers = api.get( `/users`, function* processUsers(ctx: FetchCtx<{ users: User[] }>, next) { yield next(); - if (!ctx.response.ok) return; + const { ok, data } = ctx.response; + if (!ok) return; - const { data } = ctx.response; const curUsers = data.users.reduce>((acc, u) => { acc[u.id] = u; return acc; }, {}); - yield put(users.actions.add(curUsers)); + ctx.actions.push(users.actions.add(curUsers)); }, ); const reducers = createReducerMap(users); -const store = setupStore(reducers, api.saga()); +const prepared = prepareStore({ + reducers, + sagas: { api: api.saga() }, +}); +const store = createStore( + prepared.reducer, + undefined, + applyMiddleware(...prepared.middleware), +); +prepared.run(); ``` ```tsx // app.tsx import React from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import { selectLoaderById } from 'saga-query'; import { fetchUsers, selectUsersAsList } from './api'; const App = () => { const dispatch = useDispatch(); const users = useSelector(selectUsersAsList); + const { isInitialLoading, isError, message } = useSelector( + (s: AppState) => selectLoaderById(s, { id: `${fetchUsers}` }) + ); useEffect(() => { dispatch(fetchUsers()); }, []); + if (isInitialLoading) return
Loading ...
+ if (isError) return
{message}
+ return (
{users.map((user) =>
{user.email}
)}
); @@ -266,8 +279,8 @@ import { put, call } from 'redux-saga/effects'; import { createTable, createReducerMap } from 'robodux'; import { createApi, - queryCtx, - urlParser, + requestMonitor, + requestParser, // FetchCtx is an interface that's built around using window.fetch // You don't have to use it if you don't want to. FetchCtx @@ -283,21 +296,34 @@ const users = createTable({ name: 'users' }); // The generic passed to `createApi` must extend `ApiCtx` to be accepted. const api = createApi(); +// This middleware monitors the lifecycle of the request. It needs to be +// loaded before `.routes()` because it needs to be around after everything +// else. It is composed of other middleware: dispatchActions and loadingMonitor. +// [dispatchActions] This middleware leverages `redux-batched-actions` to +// dispatch all the actions stored within `ctx.actions` which get added by +// other middleware during the lifecycle of the request. +// [loadingMonitor] This middleware will monitor the lifecycle of a request and +// attach the appropriate loading states to the loader associated with the +// endpoint. +api.use(requestMonitor()); + // This is where all the endpoints (e.g. `.get()`, `.put()`, etc.) you created // get added to the middleware stack. It is recommended to put this as close to // the beginning of the stack so everything after `yield next()` // happens at the end of the effect. api.use(api.routes()); -// queryCtx sets up the ctx object with `ctx.request` and `ctx.response` -// required for `createApi` to function properly. -api.use(queryCtx); - -// urlParser is a middleware that will take the name of `api.create(name)` and -// replace it with the values passed into the action -api.use(urlParser); - -// this is where you defined your core fetching logic +// This middleware is composed of other middleware: queryCtx, urlParser, and +// simpleCache +// [queryCtx] sets up the ctx object with `ctx.request` and `ctx.response` +// required for `createApi` to function properly. +// [urlParser] is a middleware that will take the name of `api.create(name)` and +// replace it with the values passed into the action. +// [simpleCache] is a middleware that will automatically store the response of +// endpoints if the endpoint has `request.simpleCache = true` +api.use(requestParser()); + +// this is where you define your core fetching logic api.use(function* onFetch(ctx, next) { // ctx.request is the object used to make a fetch request when using // `queryCtx` and `urlParser` @@ -331,12 +357,13 @@ const fetchUsers = api.get( // anything after the above line happens *after* the middleware gets called and // and a fetch has been made. + const { ok, data } = ctx.response; + // using FetchCtx `ctx.response` is a discriminated union based on the // boolean `ctx.response.ok`. - if (!ctx.response.ok) return; + if (!ok) return; // data = { users: User[] }; - const { data } = ctx.response; const curUsers = data.users.reduce>((acc, u) => { acc[u.id] = u; return acc; @@ -347,34 +374,28 @@ const fetchUsers = api.get( }, ); -// BONUS: POST request to create a user -const createUser = api.post<{ email: string }>( - `/users`, - function* createUser(ctx: FetchCtx, next) { - // since this middleware is the first one that gets called after the action - // is dispatched, we can set the `ctx.request` to whatever we want. The - // middleware we setup for `createApi` will then use the `ctx` to fill in - // the other details like `url` and `method` - ctx.request = { - body: JSON.stringify({ email: ctx.payload.email }), - }; - yield next(); - if (!ctx.response.ok) return; - - const curUser = ctx.response.data; - const curUsers = { [curUser.id]: curUser }; - - yield put(users.actions.add(curUsers)); - }, -); - +// This is a helper function, all id does is iterate through all the objects +// looking for a `.reducer` property and create a big object containing all +// the reducers which will then have `combineReducers` applied to it. const reducers = createReducerMap(users); -// this is a fake function `setupStore` -// pretend that it sets up your redux store and runs the saga middleware -const store = setupStore(reducers, api.saga()); +// This is a helper function that does a bunch of stuff to prepare redux for +// saga-query. In particular, it will: +// - Setup redux-saga +// - Setup redux-batched-actions +// - Setup a couple of reducers that saga-query will use: loaders and data +const prepared = prepareStore({ + reducers, + sagas: { api: api.saga() } +}); +const store = createStore( + prepared.reducer, + undefined, + applyMiddleware(...prepared.middleware), +); +// This runs the sagas +prepared.run(); store.dispatch(fetchUsers()); -store.dispatch(createUser({ email: 'change.me@saga.com' })); ``` ## Recipes @@ -420,9 +441,9 @@ each endpoint. The following code will mimick what a library like `react-query` is doing behind-the-scenes. I want to make it clear that `react-query` is doing a lot -more than this so I don't want to understate its usefuless. However, you can +more than this so I don't want to understate its usefuless. However, you can see that not only can we get a core chunk of the functionality `react-query` -provides with a little over 100 lines of code but we also have full control +provides with a little over 100 lines of code but we also have full control over fetching, querying, and caching data with the ability to customize it using middleware. This provides the end-developer with the tools to customize their experience without submitting PRs to add configuration options to an @@ -430,59 +451,42 @@ upstream library. ```ts // api.ts -import { put } from 'redux-saga/effects'; -import { - createLoaderTable, - createTable, - createReducerMap, - LoadingItemState, -} from 'robodux'; -import { - FetchApiOpts, +import { createApi, - urlParser, - queryCtx, - loadingTracker, + requestMonitor, + requestParser, timer, + prepareStore, } from 'saga-query'; -export interface AppState { - data: MapEntity; - loaders: { [key: string]: LoadingItemState }; -} - -const name = 'data'; -const data = createTable({ name }); -export const { selectById: selectDataById } = data.getSelectors((s) => s[name]); - -const LOADING_NAME = 'loaders'; -const loaders = createLoaderTable({ name: LOADING_NAME }); -const { selectById: selectLoaderById } = data.getSelectors( - (s) => s[LOADING_NAME] -); -export const selectLoader = (id: string) => (state: AppState) => - selectLoaderById(state, { id }); - -export const reducers = createReducerMap(data, loaders); - const api = createApi(); -api.use(loadingTracker(loaders)); +api.use(requestMonitor()); api.use(api.routes()); -api.use(queryCtx); -api.use(urlParser); -// this middleware will automatically save data for you keyed by the action. -api.use(function* (ctx, next) { - yield next(); - if (!ctx.response.ok) return; - const key = JSON.stringify(ctx.action); - yield put(data.actions.add({ [key]: ctx.response.data })); -}); +api.use(requestParser()); + // made up api fetch api.use(apiFetch); // this will only activate the endpoint at most once every 5 minutes. const cacheTimer = timer(5 * 60 * 1000); -export const fetchUsers = api.get('/users', { saga: cacheTimer }); +export const fetchUsers = api.get( + '/users', + { saga: cacheTimer }, + // set `save=true` to have quickSave middleware cache response data + // automatically + api.request({ save: true }), +); + +const prepared = prepareStore({ + sagas: { api: api.saga() }, +}); +const store = createStore( + prepared.reducer, + undefined, + applyMiddleware(...prepared.middleware), +); +// This runs the sagas +prepared.run(); ``` ```tsx @@ -490,8 +494,7 @@ export const fetchUsers = api.get('/users', { saga: cacheTimer }); import { useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { LoadingState } from 'robodux'; - -import { AppState, selectLoader, selectDataById } from './api'; +import { QueryState, selectLoaderById, selectDataById } from 'saga-query'; type Data = LoadingState & { data: D }; @@ -503,8 +506,10 @@ export function useQuery( const { name } = action.payload; const id = JSON.stringify(action); const dispatch = useDispatch(); - const loader = useSelector(selectLoader(name)); - const data = useSelector((s: AppState) => selectDataById(s, { id })); + const loader = useSelector( + (s: QueryState) => selectLoaderById(s, { id: name }), + ); + const data = useSelector((s: QueryState) => selectDataById(s, { id })); useEffect(() => { if (!name) return; @@ -535,9 +540,9 @@ const useUsers = () => { } export const App = () => { - const { users, isLoading, isError, message } = useUsers(); + const { users, isInitialLoading, isError, message } = useUsers(); - if (isLoading) return
Loading ...
; + if (isInitialLoading) return
Loading ...
; if (isError) return
Error: {message}
; return ( @@ -552,126 +557,10 @@ export const App = () => { Sometimes we need to dispatch a bunch of actions for an endpoint. From loading states to making multiple requests in a single saga, there can be a lot of -actions being dispatched. My recommendation is: - -- Use a library like `redux-batched-actions` to dispatch many actions at once - and only have the reducers hit once. -- Leverage `ctx.actions` (which is a required property to use `createApi`) in - your middleware. -- Build a middleware that will use `redux-batched-actions` with `ctx.actions` - and put it at the beginning of the middleware stack. - -```ts -// api.ts -import { batchActions } from 'redux-batched-actions'; -import { createApi, urlParser, queryCtx } from 'saga-query'; - -export const api = createApi(); -// we want to put this at the beginning of the middleware stack so all -// middleware is done once `yield next()` gets called. -api.use(function* (ctx, next) { - // activate all middleware after this middleware in the stack - yield next(); - if (ctx.actions.length === 0) return; - yield put(batchActions(ctx.actions)); -}); - -// this creates `ctx.actions` for us -api.use(queryCtx); -api.use(urlParser); - -// example loader -api.use(function* loader(ctx, next) { - yield put(setLoaderStart()); - yield next(); - - if (!ctx.response.ok) { - ctx.actions.push(setLoaderError({ message: ctx.response.data.message })); - return; - } - - ctx.actions.push(setLoaderSucces()); -}); - -api.use(function* onFetch(ctx, next) { - const { url = '', options } = ctx.request; - const resp = yield call(fetch, url, options); - const data = yield call([resp, 'json']); - - ctx.response = { status: resp.status, ok: resp.ok, data }; - yield next(); -}); - -api.get<{ id: string }>('/user/:id', function* (ctx, next) { - yield next(); - const { id } = ctx.payload; - if (!ctx.response.ok) { - yield next(); - return; - } - - ctx.actions.push(addUsers({ [id]: ctx.repsonse.data })); - yield next(); -}); -``` - -```ts -// store.ts -import { - createStore, - applyMiddleware, - Middleware, - Store, - Reducer, - AnyAction, -} from 'redux'; -import createSagaMiddleware, { Saga, stdChannel } from 'redux-saga'; -import { enableBatching, BATCH } from 'redux-batched-actions'; - -interface AppState {} - -interface Props { - initState?: Partial; - rootReducer: Reducer; - rootSaga: Saga; -} - -interface AppStore { - store: Store; -} - -export function setupStore({ - initState, - rootReducer, - rootSaga, -}: Props): AppStore { - const middleware: Middleware[] = []; - - // this is important for redux-batched-actions to work with redux-saga - const channel = stdChannel(); - const rawPut = channel.put; - channel.put = (action: { type: string, payload: any }) => { - if (action.type === BATCH) { - action.payload.forEach(rawPut); - return; - } - rawPut(action); - }; - - const sagaMiddleware = createSagaMiddleware({ channel } as any); - middleware.push(sagaMiddleware); - - const store = createStore( - enableBatching(rootReducer), - initState as AppState, - applyMiddleware(...middleware), - ); - - sagaMiddleware.run(rootSaga); - - return { store }; -} -``` +actions being dispatched. When using `prepareStore` we automatically setup +`redux-batched-actions` so you don't have to. Anything that gets added to +`ctx.actions` will be automatically dispatched by the `dispatchActions` +middleware. ### Dependent queries @@ -804,97 +693,33 @@ store.dispatch(action()); ### Loading state -```ts -// api.ts -import { put, call } from 'redux-saga/effects'; -import { - createTable, - createLoaderTable, - createReducerMap, -} from 'robodux'; -import { - createApi, - FetchCtx, - queryCtx, - urlParser, - loadingTracker, - leading, -} from 'saga-query'; - -interface User { - id: string; - email: string; -} - -export const loaders = createLoaderTable({ name: 'loaders' }); -export const { - selectById: selectLoaderById -} = loaders.getSelectors((s) => s[loaders.name]); - -export const users = createTable({ name: 'users' }); -export const { - selectTableAsList: selectUsersAsList -} = users.getSelectors((s) => s[users.name]); - -export const api = createApi(); -// This is one case where we want the middleware to be before the router -// middleware. Since with `robodux` loaders are decoupled from the data that -// it is tracking we can get into weird race conditions where the success or -// error action for the loader gets saved before the data gets saved. So here -// we ensure that all the other middleware get called before setting the -// loading state to success or failure. -api.use(loadingTracker(loaders)); -api.use(api.routes()); -api.use(queryCtx); -api.use(urlParser); - -api.use(function* onFetch(ctx, next) { - const { url = '', ...options } = ctx.request; - const resp = yield call(fetch, url, options); - const data = yield call([resp, 'json']); - - ctx.response = { status: resp.status, ok: true, data }; - yield next(); -}); - -const fetchUsers = api.get( - `/users`, - { saga: leading }, - function* processUsers(ctx: FetchCtx<{ users: User[] }>, next) { - yield next(); - if (!ctx.response.ok) return; - - const { data } = ctx.response; - const curUsers = data.users.reduce>((acc, u) => { - acc[u.id] = u; - return acc; - }, {}); - - yield put(users.actions.add(curUsers)); - }, -); - -const reducers = createReducerMap(users, loaders); -const store = setupStore(reducers, api.saga()); -``` +When using `prepareStore` in conjunction with `dispatchActions`, +`loadingMonitor`, and +`requestParser` the loading state will automatically be +added to all of your endpoints. We also export `QueryState` which is the +interface that contains all the state types that `saga-query` provides. ```tsx // app.tsx import React, { useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import { selectLoaderById, QueryState } from 'saga-query'; +import { MapEntity } from 'robodux'; + import { - loaders, - users, fetchUsers, selectUsersAsList, - selectLoaderById } from './api'; +interface AppState extends QueryState { + users: MapEntity; +} + const App = () => { const dispatch = useDispatch(); const users = useSelector(selectUsersAsList); const loader = useSelector( - (s) => selectLoaderById(s, { id: `${fetchUsers}` }) + (s: AppState) => selectLoaderById(s, { id: `${fetchUsers}` }) ); useEffect(() => { dispatch(fetchUsers()); @@ -939,11 +764,12 @@ state](#loading-state)) // use-query.ts import { useEffect } from 'react'; import { Action } from 'redux'; +import { LoadingItemState } from 'robodux'; import { useSelector, useDispatch } from 'react-redux'; +import { selectLoaderById } from 'saga-query'; import { fetchUsers, - selectLoaderById, selectUsersAsList, } from './api'; import { AppState } from './types'; @@ -977,9 +803,9 @@ import React from 'react'; import { useQueryUsers } from './use-query'; const App = () => { - const { data, isLoading, isError, message } = useQueryUsers(); + const { data, isInitialLoading, isError, message } = useQueryUsers(); - if (isLoading) { + if (isInitialLoading) { return
Loading ...
} @@ -1161,7 +987,7 @@ The middleware accepts three properties: - `doItType` (default: `${doIt}`) => action type - `undoType` (default: `${undo}`) => action type -- `timeout` (default: 30 * 1000) => time in milliseconds before the endpoint +- `timeout` (default: 30 * 1000) => time in milliseconds before the endpoint get cancelled automatically ```ts @@ -1169,8 +995,7 @@ import { delay, put, race } from 'redux-saga/effects'; import { createAction } from 'robodux'; import { createApi, - queryCtx, - urlParser, + requestParser, undoer, undo, doIt, @@ -1185,8 +1010,7 @@ interface Message { const messages = createTable({ name: 'messages' }); const api = createApi(); api.use(api.routes()); -api.use(queryCtx); -api.use(urlParser); +api.use(requestParser()); api.use(undoer()); const archiveMessage = api.patch<{ id: string; }>( @@ -1249,6 +1073,14 @@ for is setting up the redux slice where we want to cache the API endpoint response data. ```ts +import { createStore } from 'redux'; +import { createReducerMap } from 'robodux'; +import { + prepareStore, + createApi, + requestMonitor, + requestParser, +} from 'saga-query'; import { createSlice } from 'redux-toolkit'; const users = createSlice({ @@ -1264,9 +1096,9 @@ const users = createSlice({ }); const api = createApi(); +api.use(requestMonitor()); api.use(api.routes()); -api.use(queryCtx); -api.use(urlParser); +api.use(requestParser()); // made up window.fetch logic api.use(apiFetch); @@ -1277,7 +1109,16 @@ const fetchUsers = api.get<{ users: User[] }>('/users', function* (ctx, next) { }); const reducers = createReducerMap(users); -const store = setupStore(api.saga(), reducers); +const prepared = prepareStore({ + reducers, + sagas: { api: api.saga() }, +}); +const store = createStore( + prepared.reducer, + {}, + applyMiddleware(...prepared.middleware), +); +prepared.run(); store.dispatch(fetchUsers()); ``` diff --git a/package.json b/package.json index 359b229..d5d69b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "saga-query", "version": "0.0.0", - "description": "Data synchronization using redux-saga", + "description": "Data fetching and caching using a middleware system", "main": "./dist/index.js", "types": "./dist/index.d.ts", "repository": "https://github.com/neurosnap/saga-query.git", @@ -24,7 +24,6 @@ "redux": "^4.1.0", "redux-saga": "^1.1.3", "reselect": "^4.0.0", - "robodux": "^11.0.1", "ts-node": "^9.1.1", "typescript": "^4.2.4" }, @@ -32,7 +31,9 @@ "redux-saga": "^1.1.3" }, "dependencies": { - "redux-saga-creator": "^2.0.1" + "redux-batched-actions": "^0.5.0", + "redux-saga-creator": "^2.0.1", + "robodux": "^11.0.2" }, "ava": { "extensions": [ diff --git a/src/api.test.ts b/src/api.test.ts index 4a9d7f6..40249b8 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,6 +1,6 @@ import test from 'ava'; import createSagaMiddleware, { SagaIterator } from 'redux-saga'; -import { put, call } from 'redux-saga/effects'; +import { takeEvery, put, call } from 'redux-saga/effects'; import { createAction, createReducerMap, @@ -13,6 +13,7 @@ import sagaCreator from 'redux-saga-creator'; import { urlParser, queryCtx } from './middleware'; import { FetchCtx } from './fetch'; import { createApi } from './api'; +import { setupStore } from './util'; interface User { id: string; @@ -23,17 +24,6 @@ interface User { const mockUser: User = { id: '1', name: 'test', email: 'test@test.com' }; const mockUser2: User = { id: '2', name: 'two', email: 'two@test.com' }; -function setupStore( - saga: any, - reducers: any = { users: (state: any = {}) => state }, -) { - const sagaMiddleware = createSagaMiddleware(); - const reducer = combineReducers(reducers); - const store: any = createStore(reducer, applyMiddleware(sagaMiddleware)); - sagaMiddleware.run(saga); - return store; -} - test('createApi - POST', (t) => { t.plan(1); const name = 'users'; @@ -138,6 +128,10 @@ test('run() from a normal saga', (t) => { t.assert(acc === 'ab'); } - const store = setupStore(sagaCreator({ api: api.saga(), action: onAction })); + function* watchAction() { + yield takeEvery(`${action2}`, onAction); + } + + const store = setupStore({ api: api.saga(), watchAction }); store.dispatch(action2()); }); diff --git a/src/api.ts b/src/api.ts index 0bf1c35..b642ca6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -202,7 +202,7 @@ export function createApi( patch: (name: string, ...args: any[]) => (api.create as any)(`${name} [PATCH]`, ...args), delete: (name: string, ...args: any[]) => - (api.create as any)(`${name} [PATCH]`, ...args), + (api.create as any)(`${name} [DELETE]`, ...args), options: (name: string, ...args: any[]) => (api.create as any)(`${name} [OPTIONS]`, ...args), head: (name: string, ...args: any[]) => diff --git a/src/fetch.ts b/src/fetch.ts index faf9a63..459539c 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,10 +1,9 @@ -import { call } from 'redux-saga/effects'; - -import { ApiCtx, CreateActionPayload, Next } from './types'; -import { queryCtx, urlParser } from './middleware'; +import { ApiCtx, RequestData, LoadingCtx } from './types'; export interface FetchApiOpts extends RequestInit { url: string; + data: RequestData; + simpleCache: boolean; } export interface ApiFetchSuccess { @@ -23,7 +22,9 @@ export type ApiFetchResponse = | ApiFetchSuccess | ApiFetchError; -export interface FetchCtx extends ApiCtx { +export interface FetchCtx + extends ApiCtx, + LoadingCtx { payload: P; request: Partial; response: ApiFetchResponse; diff --git a/src/index.ts b/src/index.ts index 987d431..31f8306 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ +export { BATCH, batchActions } from 'redux-batched-actions'; export * from './pipe'; export * from './api'; export * from './types'; export * from './fetch'; export * from './middleware'; export * from './constants'; +export * from './store'; +export * from './slice'; diff --git a/src/middleware.test.ts b/src/middleware.test.ts index ba98467..7cb7225 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -7,12 +7,18 @@ import { createTable, createLoaderTable, } from 'robodux'; -import { createStore, combineReducers, applyMiddleware } from 'redux'; import { Next } from './types'; import { createApi } from './api'; -import { urlParser, loadingTracker, queryCtx } from './middleware'; +import { + urlParser, + queryCtx, + requestParser, + requestMonitor, +} from './middleware'; import { FetchCtx } from './fetch'; +import { setupStore } from './util'; +import { DATA_NAME, LOADERS_NAME, createQueryState } from './slice'; interface User { id: string; @@ -23,14 +29,6 @@ interface User { const mockUser: User = { id: '1', name: 'test', email: 'test@test.com' }; const mockUser2: User = { id: '2', name: 'two', email: 'two@test.com' }; -function setupStore(saga: any, reducers: any) { - const sagaMiddleware = createSagaMiddleware(); - const reducer = combineReducers(reducers); - const store: any = createStore(reducer, applyMiddleware(sagaMiddleware)); - sagaMiddleware.run(saga); - return store; -} - function* latest(action: string, saga: any, ...args: any[]) { yield takeLatest(`${action}`, saga, ...args); } @@ -92,30 +90,23 @@ test('middleware - basic', (t) => { const store = setupStore(query.saga(), reducers); store.dispatch(fetchUsers()); t.deepEqual(store.getState(), { + ...createQueryState(), users: { [mockUser.id]: mockUser }, }); store.dispatch(fetchUser({ id: '2' })); t.deepEqual(store.getState(), { + ...createQueryState(), users: { [mockUser.id]: mockUser, [mockUser2.id]: mockUser2 }, }); }); test('middleware - with loader', (t) => { const users = createTable({ name: 'users' }); - const loaders = createLoaderTable({ name: 'loaders' }); const api = createApi(); - api.use(function* (ctx, next) { - yield next(); - for (let i = 0; i < ctx.actions.length; i += 1) { - const action = ctx.actions[i]; - yield put(action); - } - }); - api.use(loadingTracker(loaders)); + api.use(requestMonitor()); api.use(api.routes()); - api.use(queryCtx); - api.use(urlParser); + api.use(requestParser()); api.use(function* fetchApi(ctx, next) { ctx.response = { status: 200, @@ -142,13 +133,13 @@ test('middleware - with loader', (t) => { }, ); - const reducers = createReducerMap(loaders, users); + const reducers = createReducerMap(users); const store = setupStore(api.saga(), reducers); store.dispatch(fetchUsers()); t.like(store.getState(), { [users.name]: { [mockUser.id]: mockUser }, - [loaders.name]: { + [LOADERS_NAME]: { '/users': { status: 'success', }, @@ -200,3 +191,91 @@ test('middleware - with POST', (t) => { const store = setupStore(query.saga(), reducers); store.dispatch(createUser({ email: mockUser.email })); }); + +test('overriding default loader behavior', (t) => { + const users = createTable({ name: 'users' }); + + const api = createApi(); + api.use(requestMonitor()); + api.use(api.routes()); + api.use(requestParser()); + + api.use(function* fetchApi(ctx, next) { + ctx.response = { + status: 200, + ok: true, + data: { + users: [mockUser], + }, + }; + yield next(); + }); + + const fetchUsers = api.create( + `/users`, + function* processUsers(ctx: FetchCtx<{ users: User[] }>, next) { + const id = ctx.name; + yield next(); + if (!ctx.response.ok) { + ctx.loader.error = { id, message: 'boo' }; + return; + } + const { data } = ctx.response; + const curUsers = data.users.reduce>((acc, u) => { + acc[u.id] = u; + return acc; + }, {}); + + ctx.loader.success = { id, message: 'yes', meta: { wow: true } }; + ctx.actions.push(users.actions.add(curUsers)); + }, + ); + + const reducers = createReducerMap(users); + const store = setupStore(api.saga(), reducers); + + store.dispatch(fetchUsers()); + t.like(store.getState(), { + [users.name]: { [mockUser.id]: mockUser }, + [LOADERS_NAME]: { + [`${fetchUsers}`]: { + status: 'success', + message: 'yes', + meta: { wow: true }, + }, + }, + }); +}); + +test('quickSave', (t) => { + const api = createApi(); + api.use(requestMonitor()); + api.use(api.routes()); + api.use(requestParser()); + api.use(function* fetchApi(ctx, next) { + ctx.response = { + status: 200, + ok: true, + data: { + users: [mockUser], + }, + }; + yield next(); + }); + + const fetchUsers = api.get('/users', api.request({ simpleCache: true })); + const store = setupStore(api.saga()); + + const action = fetchUsers(); + store.dispatch(action); + t.like(store.getState(), { + [DATA_NAME]: { + [JSON.stringify(action)]: { users: [mockUser] }, + }, + [LOADERS_NAME]: { + [`${fetchUsers}`]: { + status: 'success', + }, + }, + }); +}); diff --git a/src/middleware.ts b/src/middleware.ts index 4f517da..79998a0 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,3 +1,4 @@ +import { batchActions } from 'redux-batched-actions'; import { put, takeLatest, @@ -16,8 +17,15 @@ import { CreateActionPayload, ApiCtx, Next, + LoaderCtxPayload, } from './types'; import { isObject, createAction } from './util'; +import { + setLoaderStart, + setLoaderError, + setLoaderSuccess, + addData, +} from './slice'; export function* queryCtx(ctx: Ctx, next: Next) { if (!ctx.request) ctx.request = { url: '', method: 'GET' }; @@ -62,6 +70,13 @@ export function* urlParser(ctx: Ctx, next: Next) { ctx.request.url = url; } + // TODO: should this be a separate middleware? + if (!ctx.request.body && ctx.request.data) { + ctx.request.body = { + body: JSON.stringify(ctx.request.data), + }; + } + if (!ctx.request.method) { httpMethods.forEach((method) => { const url = ctx.request.url || ''; @@ -76,28 +91,42 @@ export function* urlParser(ctx: Ctx, next: Next) { yield next(); } -export function loadingTracker( - loaders: { - actions: { - loading: (l: { id: string }) => any; - error: (l: { id: string; message: string }) => any; - success: (l: { id: string }) => any; - }; - }, +export function* dispatchActions( + ctx: Ctx, + next: Next, +) { + yield next(); + if (ctx.actions.length === 0) return; + yield put(batchActions(ctx.actions)); +} + +export function loadingMonitor( errorFn: (ctx: Ctx) => string = (ctx) => ctx.response.data.message, ) { return function* trackLoading(ctx: Ctx, next: Next) { const id = ctx.name; - yield put(loaders.actions.loading({ id })); + if (!ctx.loader) ctx.loader = {} as LoaderCtxPayload; + const { loading = {} } = ctx.loader; + ctx.loader.loading = { id, ...loading }; + yield put(setLoaderStart(ctx.loader.loading)); yield next(); + const { success = {}, error = {} } = ctx.loader; if (!ctx.response.ok) { - yield put(loaders.actions.error({ id, message: errorFn(ctx) })); + ctx.loader.error = { + id, + message: errorFn(ctx), + ...error, + }; + ctx.actions.push(setLoaderError(ctx.loader.error as any)); return; } - yield put(loaders.actions.success({ id })); + if (!ctx.loader.success) { + ctx.loader.success = { id, ...success }; + } + ctx.actions.push(setLoaderSuccess(ctx.loader.success as any)); }; } @@ -202,3 +231,24 @@ export function* optimistic< yield put(revert); } } + +export function* simpleCache( + ctx: Ctx, + next: Next, +) { + yield next(); + if (!ctx.request.simpleCache) return; + const { ok, data } = ctx.response; + if (!ok) return; + + const key = JSON.stringify(ctx.action); + ctx.actions.push(addData({ [key]: data })); +} + +export function requestParser() { + return compose([queryCtx, urlParser, simpleCache]); +} + +export function requestMonitor() { + return compose([dispatchActions, loadingMonitor()]); +} diff --git a/src/pipe.test.ts b/src/pipe.test.ts index 99ff82f..c2646d6 100644 --- a/src/pipe.test.ts +++ b/src/pipe.test.ts @@ -6,6 +6,8 @@ import { createTable, Action, MapEntity, createReducerMap } from 'robodux'; import { createPipe } from './pipe'; import { CreateActionPayload, PipeCtx, Middleware, Next } from './types'; +import { setupStore as prepStore } from './util'; +import { createQueryState } from './slice'; interface RoboCtx extends PipeCtx

{ url: string; @@ -135,10 +137,7 @@ function* saveToRedux(ctx: RoboCtx, next: Next) { } function setupStore(saga: any) { - const sagaMiddleware = createSagaMiddleware(); - const reducer = combineReducers(reducers as any); - const store: any = createStore(reducer, applyMiddleware(sagaMiddleware)); - sagaMiddleware.run(saga); + const store = prepStore(saga, reducers); return store; } @@ -156,6 +155,7 @@ test('createPipe: when create a query fetch pipeline - execute all middleware an const store = setupStore(api.saga()); store.dispatch(fetchUsers()); t.deepEqual(store.getState(), { + ...createQueryState(), [users.name]: { [mockUser.id]: deserializeUser(mockUser) }, [tickets.name]: {}, }); @@ -191,6 +191,7 @@ test('createPipe: when providing a generator the to api.create function - should const store = setupStore(api.saga()); store.dispatch(fetchTickets()); t.deepEqual(store.getState(), { + ...createQueryState(), [users.name]: { [mockUser.id]: deserializeUser(mockUser) }, [tickets.name]: { [mockTicket.id]: deserializeTicket(mockTicket) }, }); diff --git a/src/slice.ts b/src/slice.ts new file mode 100644 index 0000000..d70bfe7 --- /dev/null +++ b/src/slice.ts @@ -0,0 +1,39 @@ +import { Reducer } from 'redux'; +import { + createTable, + createLoaderTable, + createReducerMap, + LoadingItemState, +} from 'robodux'; + +export interface QueryState { + '@@saga-query/loaders': { [key: string]: LoadingItemState }; + '@@saga-query/data': { [key: string]: any }; +} + +export const LOADERS_NAME = `@@saga-query/loaders`; +export const loaders = createLoaderTable({ name: LOADERS_NAME }); +export const { + loading: setLoaderStart, + error: setLoaderError, + success: setLoaderSuccess, + resetById: resetLoaderById, +} = loaders.actions; +export const { selectTable: selectLoaders, selectById: selectLoaderById } = + loaders.getSelectors((state: any) => state[LOADERS_NAME] || {}); + +export const DATA_NAME = `@@saga-query/data`; +export const data = createTable({ name: DATA_NAME }); +export const { selectTable: selectData, selectById: selectDataById } = + data.getSelectors((s: any) => s[DATA_NAME] || {}); +export const { add: addData } = data.actions; + +export const reducers = createReducerMap(loaders, data); + +export const createQueryState = (s: Partial = {}): QueryState => { + return { + [LOADERS_NAME]: {}, + [DATA_NAME]: {}, + ...s, + }; +}; diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..f2c0805 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,75 @@ +import { + createStore as createReduxStore, + applyMiddleware, + Reducer, + Middleware, + combineReducers, +} from 'redux'; +import sagaCreator from 'redux-saga-creator'; +import createSagaMiddleware, { + stdChannel, + Saga, + Task, + SagaIterator, +} from 'redux-saga'; +import { enableBatching, BATCH } from 'redux-batched-actions'; +import { ActionWithPayload } from './types'; +import { reducers as sagaQueryReducers, QueryState } from './slice'; + +export interface PrepareStore< + S extends { [key: string]: any } = { [key: string]: any }, +> { + reducer: Reducer; + middleware: Middleware[]; + run: (...args: any[]) => Task; +} + +interface Props { + reducers: { [key in keyof S]: Reducer }; + sagas: { [key: string]: Saga }; + onError?: (err: Error) => void; +} + +/** + * prepareStore will setup redux-batched-actions to work with redux-saga. + * It will also add some reducers to your redux store for decoupled loaders + * and a simple data cache. + * + * const { middleware, reducer, run } = prepareStore({ + * reducers: { users: (state, action) => state }, + * sagas: { api: api.saga() }, + * onError: (err) => console.error(err), + * }); + * const store = createStore(reducer, {}, applyMiddleware(...middleware)); + * // you must call `.run(...args: any[])` in order for the sagas to bootup. + * run(); + */ +export function prepareStore< + S extends { [key: string]: any } = { [key: string]: any }, +>({ + reducers = {} as any, + sagas, + onError = console.error, +}: Props): PrepareStore { + const middleware: Middleware[] = []; + + const channel = stdChannel(); + const rawPut = channel.put; + channel.put = (action: ActionWithPayload) => { + if (action.type === BATCH) { + action.payload.forEach(rawPut); + return; + } + rawPut(action); + }; + + const sagaMiddleware = createSagaMiddleware({ channel } as any); + middleware.push(sagaMiddleware); + + const reducer = combineReducers({ ...sagaQueryReducers, ...reducers }); + const rootReducer: any = enableBatching(reducer); + const rootSaga = sagaCreator(sagas, onError); + const run = (...args: any[]) => sagaMiddleware.run(rootSaga, ...args); + + return { middleware, reducer: rootReducer, run }; +} diff --git a/src/types.ts b/src/types.ts index 19c3211..709cf02 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,12 +31,35 @@ export interface CreateActionWithPayload { run: (a: ActionWithPayload>) => SagaIterator; } +interface LoaderPayload { + id: string; + message?: string; + meta?: { [key: string]: any }; +} + +export interface LoaderCtxPayload { + loading: LoaderPayload; + success: LoaderPayload; + error: LoaderPayload; +} + +export interface LoadingCtx { + loader: LoaderCtxPayload; +} + +export interface RequestData { + [key: string]: any; +} + export interface RequestCtx { url: string; method: string; + body: any; + data: RequestData; + simpleCache: boolean; } -export interface ApiCtx

extends PipeCtx

{ +export interface ApiCtx

extends PipeCtx

, LoadingCtx { request: Partial; response: R; actions: Action[]; diff --git a/src/util.ts b/src/util.ts index ca97388..26be727 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,3 +1,6 @@ +import { Reducer, combineReducers, createStore, applyMiddleware } from 'redux'; +import { prepareStore } from './store'; + import { API_ACTION_PREFIX } from './constants'; export const isFn = (fn?: any) => fn && typeof fn === 'function'; export const isObject = (obj?: any) => typeof obj === 'object' && obj !== null; @@ -8,3 +11,20 @@ export const createAction = (curType: string) => { action.toString = () => type; return action; }; + +export function setupStore( + saga: any, + reducers: { [key: string]: Reducer } = {}, +) { + const sagas: any = typeof saga === 'function' ? { saga } : saga; + const prepared = prepareStore({ + reducers, + sagas, + }); + const store: any = createStore( + prepared.reducer, + applyMiddleware(...prepared.middleware), + ); + prepared.run(); + return store; +} diff --git a/yarn.lock b/yarn.lock index 2a8ae0c..81f90ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1761,6 +1761,11 @@ readdirp@~3.5.0: dependencies: picomatch "^2.2.1" +redux-batched-actions@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/redux-batched-actions/-/redux-batched-actions-0.5.0.tgz#d3f0e359b2a95c7d80bab442df450bfafd57d122" + integrity sha512-6orZWyCnIQXMGY4DUGM0oj0L7oYnwTACsfsru/J7r94RM3P9eS7SORGpr3LCeRCMoIMQcpfKZ7X4NdyFHBS8Eg== + redux-saga-creator@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/redux-saga-creator/-/redux-saga-creator-2.0.1.tgz#7433cea4c5ec2d06159c307de7008ba490bc0061" @@ -1861,10 +1866,10 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" -robodux@^11.0.1: - version "11.0.1" - resolved "https://registry.yarnpkg.com/robodux/-/robodux-11.0.1.tgz#7b4284031b049908bf1c28522afd04229701b898" - integrity sha512-qxMQoz2cufZC8o58MwDGBQVlg2WouzvTxlS1Ahwe+wsgV8D9vPDldRISj+UF3v0eK8cMDREiSU7osdumw0Jyzw== +robodux@^11.0.2: + version "11.0.2" + resolved "https://registry.yarnpkg.com/robodux/-/robodux-11.0.2.tgz#91b4407a8881be102641f65a6038537adb963c16" + integrity sha512-bZYTlCCCz8CHeczBg13vqiz7pjnINp5PdoUMORLlndxXZzS6xVatrzLat+v0amFWp0eERs1k1yCg9lkQjRLZRQ== dependencies: immer "^8.0.1"