-
Notifications
You must be signed in to change notification settings - Fork 192
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* @looker/redux copy * add test:redux script Co-authored-by: John Kaster <kaster@google.com>
- Loading branch information
1 parent
0da2523
commit b2267a0
Showing
27 changed files
with
1,780 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,189 @@ | ||
## @looker/redux | ||
|
||
> Our own abstractions to make how we use Redux simpler. | ||
## Notes | ||
|
||
Our usage of Redux is moving more towards slices and sagas and the API contained in this package is geared towards guiding us down that path. | ||
|
||
## Utilities | ||
|
||
### createSliceHooks | ||
|
||
> Returns hooks that are automatically bound to your typed state, slices and sagas. | ||
`createSliceHooks` takes a `slice` and `saga` initializer and returns hooks - composed of other hooks in this API - that are automatically bound to your typed state, slices and sagas (optional) so you can focus on the important parts of your data and less about implementation details. It assumes that your using `@reduxjs/toolkit` as this produces information that `createSliceHooks` uses internally. | ||
|
||
`createSliceHooks` also ensures your reducers and sagas are registered with the store (and registered only once), so you don't need to worry about doing this as side effects of an import or the component lifecycle. Dynamically registering them also ensures your code can be properly code-split. | ||
|
||
`createSliceHooks` returns the following hooks: | ||
|
||
- `useActions` - returns the actions from your slice bound to `dispatch` using `bindActionActionCreators`. | ||
- `useStoreState` - ensures your reducers and sagas have been registered and returns the top-level state from your slice. | ||
|
||
#### Your data file | ||
|
||
You use `createSliceHooks` in the file that exports the data layer for your connected components. You can structure this any way you want as long as you pass it a slice and saga. The following file is a minimal version of what you might start with: | ||
|
||
```ts | ||
import { createSlice } from '@reduxjs/toolkit' | ||
import { createSliceHooks } from '@looker/redux' | ||
|
||
interface State { | ||
count: number | ||
} | ||
|
||
const slice = createSlice({ | ||
name: 'some/data', | ||
initialState: { | ||
count: 0, | ||
}, | ||
reducers: { | ||
increment(state) { | ||
state.count++ | ||
}, | ||
}, | ||
}) | ||
|
||
export const { useActions, useStoreState } = createSliceHooks(slice) | ||
``` | ||
|
||
Notice that nothing besides `useActions` and `useStoreState` is exported because your component generally won't need to know about anything else. | ||
|
||
#### Your connected component file | ||
|
||
Your component file might look like the following. It assumes that there is a store being provided in the context tree. | ||
|
||
```tsx | ||
import React from 'react' | ||
import { useActions, useStoreState } from './data' | ||
|
||
export const MyComponent = () => { | ||
const actions = useActions() | ||
const state = useStoreState() | ||
return ( | ||
<button onClick={() => actions.increment()}>Clicked: {state.count}</button> | ||
) | ||
} | ||
``` | ||
|
||
Notice, first, that you don't need to pass anything to these hooks. They're bound to your slice, and optionally sagas, so your data layer can be a black-box API (in a good way). Also, notice how actions are pre-bound; you don't need to worry about calling `useDispatch`. | ||
|
||
Most of the time, you'll probably only be using these APIs in your connected component. However, you there may also be cases where you want to export them as part of your API to share state and actions. In either case, the usage is similar because you don't need to know about slices or sagas and how they're registered. | ||
|
||
### createStore | ||
|
||
> Creates a store that is pre-configured for Looker usage and is enhanced to dynamically add reducers and sagas. | ||
```ts | ||
import { createStore } from '@looker/redux' | ||
|
||
const store = createStore() | ||
``` | ||
|
||
The `createStore()` function accepts all of the options that `configureStore()` from `@reduxjs/toolkit` does, except that `middleware` is required to be an array of middleware as `createStore` preloads middleware. | ||
|
||
_We create several, very similar stores across the codebase. Currently both `web/` and `web/scenes/admin` each have their own stores, and many tests also use a store. This function sets up a store so that it can be used anywhere in the codebase, and eventually, hopefully, only use the single configuration provided by this function._ | ||
|
||
## Hooks | ||
|
||
The hooks here are all composed into the hooks that `createSliceHooks` returns. They are: | ||
|
||
- `useActions(slice: Slice)` - Binds a slice's action creators to dispatch(). | ||
- `useSaga(saga: any)` - Adds a saga to the nearest store. | ||
- `useSagas(saga: any[])` - Adds an array of sagas to the nearest store. Generally used for backward compatibility where `registerSagas` was previously used. | ||
- `useSlice(slice: Slice)` - Adds a slice to the nearest store. | ||
- `useStoreState<State>(slice: Slice, saga: any): State` - Adds a saga and slice to the nearest store and returns the root state for the slice. | ||
|
||
These hooks generally require you pass some form of a `slice` or `saga` into them, exposing more of your data layer's implementation details, but it does mean that you can adopt this API incrementally, for whatever reason. | ||
|
||
Each of the following examples assumes a `./data` file with the following: | ||
|
||
```ts | ||
import { createSlice } from '@reduxjs/toolkit' | ||
|
||
// Exported to show full API. | ||
export interface State { | ||
count: number | ||
} | ||
|
||
// Exported to show full API. | ||
// Empty to show how to use sagas. | ||
export function* initSagas() {} | ||
|
||
// Exported to show full API. | ||
export const slice = createSlice({ | ||
name: 'some/data', | ||
initialState: { | ||
count: 0, | ||
}, | ||
reducers: { | ||
increment(state) { | ||
state.count++ | ||
}, | ||
}, | ||
}) | ||
|
||
// If you use these hooks, you don't need to export the above items. | ||
export const { useActions, useStoreState } = createSliceHooks(slice, initSagas) | ||
``` | ||
|
||
### Composing hooks individually | ||
|
||
```tsx | ||
import { useActions, useSaga, useSlice } from '@looker/redux' | ||
import React from 'react' | ||
import { useSelector } from 'react-redux' | ||
import { saga, slice, State } from './data' | ||
|
||
function selectState(store: any): State { | ||
return store?.data?.[slice.name] | ||
} | ||
|
||
export const MyComponent = () => { | ||
useSaga(saga) | ||
useSlice(slice) | ||
const actions = useActions(slice) | ||
const state = useSelector(selectState) | ||
return ( | ||
<button onClick={() => actions.increment()}>Clicked: {state.count}</button> | ||
) | ||
} | ||
``` | ||
|
||
This is the most long-winded approach, but might be necessary if you can only use certain parts of the API. For example, you may only have time to refactor to dynamically register sagas, and might still have globally registered reducers which you will refactor at a later time. Maybe vice versa, or you might still be using thunks. | ||
|
||
### Composing hooks with useStoreState | ||
|
||
```tsx | ||
import { useActions, useStoreState } from '@looker/redux' | ||
import React from 'react' | ||
import { saga, slice, State } from './data' | ||
|
||
export const MyComponent = () => { | ||
const actions = useActions(slice) | ||
const state = useStoreState<State>(slice, saga) | ||
return ( | ||
<button onClick={() => actions.increment()}>Clicked: {state.count}</button> | ||
) | ||
} | ||
``` | ||
|
||
The major difference between this example and the one above is that this one hides the implementation detail of having to register slices and sagas. You must still pass them in, however. This usage is the most likely scenario for adopting incrementally if you are already using `@reduxjs/tookit` and sagas. | ||
|
||
### Compared to createSliceHooks | ||
|
||
```tsx | ||
import React from 'react' | ||
import { useActions, useStoreState } from './data' | ||
|
||
export const MyComponent = () => { | ||
const actions = useActions() | ||
const state = useStoreState() | ||
return ( | ||
<button onClick={() => actions.increment()}>Clicked: {state.count}</button> | ||
) | ||
} | ||
``` | ||
|
||
This example shows the ideal scenario. Your component doesn't have to know about, slices, sagas, state types or manually dispatching. `MyComponent` acts much like `connect()` normally would, mapping state and props onto `<button />`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
{ | ||
"private": true, | ||
"name": "@looker/redux", | ||
"license": "MIT", | ||
"author": "Looker", | ||
"version": "0.0.0", | ||
"main": "src/index.ts", | ||
"devDependencies": { | ||
"@testing-library/react-hooks": "^7.0.2", | ||
"@types/lodash": "^4.14.175", | ||
"@types/react": "^17.0.27", | ||
"@types/react-redux": "^7.1.18", | ||
"typed-redux-saga": "^1.3.1" | ||
}, | ||
"peerDependencies": { | ||
"@reduxjs/toolkit": "^1.1.0", | ||
"redux-saga": "^1.1.3", | ||
"typed-redux-saga": "^1.3.1" | ||
}, | ||
"dependencies": { | ||
"@reduxjs/toolkit": "^1.6.2", | ||
"@testing-library/react": "^12.1.2", | ||
"react": "^17.0.2", | ||
"redux": "^4.1.1" | ||
} | ||
} |
79 changes: 79 additions & 0 deletions
79
packages/redux/src/createSliceHooks/index-integration.spec.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
/* | ||
MIT License | ||
Copyright (c) 2021 Looker Data Sciences, Inc. | ||
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: | ||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
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. | ||
*/ | ||
import { createSlice } from '@reduxjs/toolkit' | ||
import { fireEvent, render } from '@testing-library/react' | ||
import React from 'react' | ||
import { Provider } from 'react-redux' | ||
import { call, put, takeEvery } from 'typed-redux-saga/macro' | ||
import { createStore } from '../createStore' | ||
import { createSliceHooks } from '.' | ||
|
||
test('reducers and sagas', async () => { | ||
interface State { | ||
text: string | ||
} | ||
|
||
const slice = createSlice({ | ||
name: 'test', | ||
initialState: { | ||
text: 'click', | ||
} as State, | ||
reducers: { | ||
getText(state) { | ||
state.text = 'loading' | ||
}, | ||
setText(state) { | ||
state.text = 'done' | ||
}, | ||
}, | ||
}) | ||
|
||
function* sagas() { | ||
yield* takeEvery(slice.actions.getText, function* () { | ||
yield* call(() => Promise.resolve()) | ||
yield* put(slice.actions.setText()) | ||
}) | ||
} | ||
|
||
const hooks = createSliceHooks(slice, sagas) | ||
const store = createStore() | ||
|
||
const Component = () => { | ||
const actions = hooks.useActions() | ||
const state = hooks.useStoreState() | ||
return <button onClick={actions.getText}>{state.text}</button> | ||
} | ||
|
||
const r = render( | ||
<Provider store={store}> | ||
<Component /> | ||
</Provider> | ||
) | ||
|
||
fireEvent.click(await r.findByText('click')) | ||
expect(r.getByText('loading')).toBeInTheDocument() | ||
expect(await r.findByText('done')).toBeInTheDocument() | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
/* | ||
MIT License | ||
Copyright (c) 2021 Looker Data Sciences, Inc. | ||
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: | ||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
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. | ||
*/ | ||
import type { SliceCaseReducers } from '@reduxjs/toolkit' | ||
import { createSlice } from '@reduxjs/toolkit' | ||
import { renderHook } from '@testing-library/react-hooks' | ||
import { useActions } from '../useActions' | ||
import { useStoreState } from '../useStoreState' | ||
import { createSliceHooks } from '.' | ||
|
||
jest.mock('../useActions', () => ({ | ||
useActions: jest.fn(), | ||
})) | ||
|
||
jest.mock('../useStoreState', () => ({ | ||
useStoreState: jest.fn(), | ||
})) | ||
|
||
interface State { | ||
test: boolean | ||
} | ||
|
||
function* saga() {} | ||
|
||
const slice = createSlice<State, SliceCaseReducers<State>>({ | ||
initialState: { test: true }, | ||
name: 'test', | ||
reducers: { | ||
test() {}, | ||
}, | ||
}) | ||
|
||
test('creates slice hooks', () => { | ||
const hooks = createSliceHooks(slice, saga) | ||
expect(typeof hooks.useActions).toBe('function') | ||
expect(typeof hooks.useStoreState).toBe('function') | ||
}) | ||
|
||
test('hooks.useActions calls the useActions core hook ', () => { | ||
const hooks = createSliceHooks(slice, saga) | ||
renderHook(() => hooks.useActions()) | ||
expect(useActions).toHaveBeenCalledTimes(1) | ||
expect(useActions).toHaveBeenCalledWith(slice) | ||
}) | ||
|
||
test('hooks.useStateState calls the useStoreState core hook', () => { | ||
const hooks = createSliceHooks(slice, saga) | ||
renderHook(() => hooks.useStoreState()) | ||
expect(useStoreState).toHaveBeenCalledTimes(1) | ||
expect(useStoreState).toHaveBeenCalledWith(slice, saga) | ||
}) |
Oops, something went wrong.