diff --git a/package.json b/package.json index 3384681c9..e4d7f485f 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "test:apix-e2e": "yarn workspace @looker/api-explorer run test:e2e", "test:iphone": "xcodebuild test -project swift/looker/looker.xcodeproj -scheme looker-Package -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 11,OS=13.4.1' | xcpretty --test --color", "test:gen": "yarn jest packages/sdk-codegen", + "test:redux": "yarn jest packages/redux", "test:sdk": "yarn jest packages/sdk", "test:jest": "DOT_ENV_FILE=.env.test jest", "test:ext": "yarn jest packages/extension-sdk packages/extension-sdk-react", @@ -189,6 +190,14 @@ "testing-library/render-result-naming-convention": "off" } }, + { + "files": ["packages/redux/**/*.ts?(x)", "packages/redux/**/*.spec.ts?(x)"], + "rules": { + "testing-library/render-result-naming-convention": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/ban-ts-comment": "off" + } + }, { "files": [ "packages/sdk-codegen-scripts/**/*.ts"], "rules": { diff --git a/packages/redux/README.md b/packages/redux/README.md new file mode 100644 index 000000000..81e0c06a3 --- /dev/null +++ b/packages/redux/README.md @@ -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 ( + + ) +} +``` + +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(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 ( + + ) +} +``` + +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(slice, saga) + return ( + + ) +} +``` + +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 ( + + ) +} +``` + +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 ` + } + + const r = render( + + + + ) + + fireEvent.click(await r.findByText('click')) + expect(r.getByText('loading')).toBeInTheDocument() + expect(await r.findByText('done')).toBeInTheDocument() +}) diff --git a/packages/redux/src/createSliceHooks/index.spec.ts b/packages/redux/src/createSliceHooks/index.spec.ts new file mode 100644 index 000000000..24367388f --- /dev/null +++ b/packages/redux/src/createSliceHooks/index.spec.ts @@ -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>({ + 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) +}) diff --git a/packages/redux/src/createSliceHooks/index.ts b/packages/redux/src/createSliceHooks/index.ts new file mode 100644 index 000000000..38a5b9446 --- /dev/null +++ b/packages/redux/src/createSliceHooks/index.ts @@ -0,0 +1,47 @@ +/* + + 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 { CaseReducerActions, Slice } from '@reduxjs/toolkit' +import type { Saga } from 'redux-saga' +import { useActions } from '../useActions' +import { useStoreState } from '../useStoreState' + +type ExtractReducer = T extends Slice ? R : never +type ExtractState = T extends Slice ? S : never + +/** + * Returns hooks that are automatically bound to your typed state, slices and sagas. + * + * @param slice The slice containing reducers to register on the nearest store. + * @param saga The saga to register on the nearest store. + */ +export const createSliceHooks = (slice: T, saga?: Saga) => ({ + useActions: (): CaseReducerActions> => { + return useActions(slice) + }, + useStoreState: () => { + return useStoreState>(slice, saga) + }, +}) diff --git a/packages/redux/src/createStore/index.spec.tsx b/packages/redux/src/createStore/index.spec.tsx new file mode 100644 index 000000000..1c38fdc2f --- /dev/null +++ b/packages/redux/src/createStore/index.spec.tsx @@ -0,0 +1,101 @@ +/* + + 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 { configureStore } from '@reduxjs/toolkit' +import reduxSaga from 'redux-saga' +import { deepCombineReducers } from '../deepCombineReducers' +import { createStore } from '.' + +jest.mock('@reduxjs/toolkit', () => ({ + configureStore: jest.fn(), +})) + +jest.mock('../deepCombineReducers', () => ({ + deepCombineReducers: jest.fn(), +})) + +jest.mock('redux-saga', () => jest.fn()) + +const mockConfigureStore = { + replaceReducer: jest.fn(), + dispatch: jest.fn(), +} + +const mockReduxSaga = { + run: jest.fn(), +} + +beforeEach(() => { + // @ts-ignore + deepCombineReducers.mockReset().mockReturnValue('deepCombineReducers') + + // @ts-ignore + configureStore.mockReset().mockReturnValue(mockConfigureStore) + + // @ts-ignore + reduxSaga.mockReset().mockReturnValue(mockReduxSaga) +}) + +test('extended APIs exist', () => { + const store = createStore() + expect(typeof store.addReducer).toBe('function') + expect(typeof store.addSaga).toBe('function') +}) + +test('configureStore', () => { + const preloadedState = {} + createStore({ preloadedState }) + expect(configureStore).toHaveBeenCalledTimes(1) + expect(configureStore).toHaveBeenCalledWith( + // @ts-ignore not on type yet + expect.objectContaining({ + devTools: true, + preloadedState, + }) + ) +}) + +test('addReducer', () => { + const reducers = { + test1: () => {}, + test2: () => {}, + } + const store = createStore({ reducer: { test1: reducers.test1 } }) + store.addReducer('test2' as any, reducers.test2) + expect(deepCombineReducers).toHaveBeenCalledTimes(1) + expect(deepCombineReducers).toHaveBeenCalledWith(reducers) + expect(mockConfigureStore.replaceReducer).toHaveBeenCalledTimes(1) + expect(mockConfigureStore.replaceReducer).toHaveBeenCalledWith( + 'deepCombineReducers' + ) +}) + +test('addSaga', () => { + const store = createStore() + function* saga() {} + store.addSaga(saga) + expect(mockReduxSaga.run).toHaveBeenCalledTimes(1) + expect(mockReduxSaga.run).toHaveBeenCalledWith(saga) +}) diff --git a/packages/redux/src/createStore/index.ts b/packages/redux/src/createStore/index.ts new file mode 100644 index 000000000..8883471a0 --- /dev/null +++ b/packages/redux/src/createStore/index.ts @@ -0,0 +1,94 @@ +/* + + 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 { + ConfigureStoreOptions, + Middleware, + ReducersMapObject, +} from '@reduxjs/toolkit' +import { configureStore } from '@reduxjs/toolkit' +// @ts-ignore - this causes compile issues because it doesn't have types. +import createSagaMiddleware from 'redux-saga' +import set from 'lodash/set' +import type { Store } from '../types' +import { deepCombineReducers } from '../deepCombineReducers' + +export interface CreateStoreOptions + extends Partial> { + middleware?: Middleware[] + reducer?: ReducersMapObject +} + +/** + * Creates a store that is pre-configured for Looker usage and is enhanced to dynamically add reducers and sagas. + * + * @param devTools + * @param middleware + * @param preloadedState The initial state to preload into the store. + * @param reducer The initial reducer that goes along with initial state. + */ +export const createStore = ({ + devTools = false, + middleware = [], + preloadedState = {}, + reducer = { + // If no reducer is provided initially we + // must start with at least one reducer + _: (state: State) => state ?? null, + // cast as unknown because _ doesn't exist on State + } as unknown as ReducersMapObject, +}: CreateStoreOptions = {}): Store => { + const currentReducers = { + ...reducer, + } + const reducerSet = new WeakSet() + const sagasSet = new WeakSet() + const sagaMiddleware = createSagaMiddleware() + const store = configureStore({ + devTools: process.env.NODE_ENV !== 'production' || devTools, + middleware: [sagaMiddleware, ...middleware], + reducer: currentReducers, + preloadedState, + }) as Store + + // Dynamically adds a reducer to the store if it has not been added yet. + store.addReducer = (path, reducer) => { + if (!reducerSet.has(reducer)) { + reducerSet.add(reducer) + set(currentReducers, path, reducer) + store.replaceReducer(deepCombineReducers(currentReducers)) + } + } + + // Dynamically adds a saga to the store if it has not been added yet. + store.addSaga = (saga) => { + if (!sagasSet.has(saga)) { + sagasSet.add(saga) + sagaMiddleware.run(saga) + } + } + + return store +} diff --git a/packages/redux/src/deepCombineReducers/index.spec.tsx b/packages/redux/src/deepCombineReducers/index.spec.tsx new file mode 100644 index 000000000..80b62f3ff --- /dev/null +++ b/packages/redux/src/deepCombineReducers/index.spec.tsx @@ -0,0 +1,99 @@ +/* + + 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 { DeepReducersMapObject } from '.' +import { deepCombineReducers } from '.' + +test('should handle plain function reducers', () => { + const reducer = (state: any) => state + expect(deepCombineReducers(reducer)).toBe(reducer) +}) + +test('should handle reducer object maps', () => { + interface IState { + a?: string + b?: string + } + const reducer = { + a: () => 'a', + b: () => 'b', + } + const combined = deepCombineReducers(reducer) + expect(combined({}, { type: 'init' })).toEqual({ a: 'a', b: 'b' }) +}) + +test('should handle recursive reducer object maps', () => { + interface IState { + a?: string + b?: string + c?: { + nested?: string + } + } + const reducer = { + a: () => 'a', + b: () => 'b', + c: { + nested: () => 'nested', + }, + } + const combined = deepCombineReducers(reducer) + expect(combined({}, { type: 'init' })).toEqual({ + a: 'a', + b: 'b', + c: { nested: 'nested' }, + }) +}) + +test('should handle reducers that are both functions and nested objects', () => { + interface IState { + a?: string + b?: string + c?: { + nested?: string + notNested?: string + } + } + const reducerAndNested = ((state: Record) => { + return { ...state, notNested: 'notNested' } + }) as DeepReducersMapObject + reducerAndNested!.nested = () => 'nested' + const reducer = { + a: () => 'a', + b: () => 'b', + c: reducerAndNested, + } + const initialState = { + a: 'initiala', + b: 'initialb', + c: {}, + } + const combined = deepCombineReducers(reducer) + expect(combined(initialState, { type: 'init' })).toEqual({ + a: 'a', + b: 'b', + c: { notNested: 'notNested', nested: 'nested' }, + }) +}) diff --git a/packages/redux/src/deepCombineReducers/index.ts b/packages/redux/src/deepCombineReducers/index.ts new file mode 100644 index 000000000..1a6303f96 --- /dev/null +++ b/packages/redux/src/deepCombineReducers/index.ts @@ -0,0 +1,78 @@ +/* + + 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 { Reducer, ReducersMapObject, AnyAction, Action } from 'redux' + +export type DeepReducersMapObject = { + [K in keyof S]: Reducer | DeepReducersMapObject +} + +// We use many action types throughout helltool that are not +// all the same type. Ideally this would default to AnyAction +// rather than Any. Before trying to engineer these action types +// to be more type safe, consider looking at existing solutions such as +// the typescript-fsa library. +type HelltoolActionType = any + +/** + * Same function as combineReducers from react-redux with the additional functionality + * of being able to combine nested reducers. + * @example + * const reducer = deepCombineReducers({ + * foo: fooReducer + * bar: { + * nestedBar: nestedBarReducer + * } + * }) + */ +export const deepCombineReducers = ( + reducers: + | DeepReducersMapObject + | Reducer +): Reducer => { + // the majority of this implementation is copy pasted from redux + // https://github.com/reduxjs/redux/blob/master/src/combineReducers.ts + const reducerKeys = Object.keys(reducers) + + if (!reducerKeys.length) { + return reducers as Reducer + } + + return function combination(state: S = {} as S, action: HelltoolActionType) { + const nextState: S = { ...state } + // Most of this function is copy pasted from redux. They might be doing a loop like this + // for performance reasons. + for (let i = 0; i < reducerKeys.length; i++) { + const key = reducerKeys[i] as keyof S + const reducer = deepCombineReducers((reducers as ReducersMapObject)[key]) + const previousStateForKey = nextState[key] + const nextStateForKey = reducer(previousStateForKey, action) + nextState[key] = nextStateForKey + } + return typeof reducers === 'function' + ? reducers(nextState, action) + : nextState + } +} diff --git a/packages/redux/src/index.spec.ts b/packages/redux/src/index.spec.ts new file mode 100644 index 000000000..7b00d1f64 --- /dev/null +++ b/packages/redux/src/index.spec.ts @@ -0,0 +1,44 @@ +/* + + 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 { + createSliceHooks, + createStore, + useActions, + useSaga, + useSagas, + useSlice, + useStoreState, +} from '.' + +test('api', () => { + expect(typeof createSliceHooks).toBe('function') + expect(typeof createStore).toBe('function') + expect(typeof useActions).toBe('function') + expect(typeof useSaga).toBe('function') + expect(typeof useSagas).toBe('function') + expect(typeof useSlice).toBe('function') + expect(typeof useStoreState).toBe('function') +}) diff --git a/packages/redux/src/index.ts b/packages/redux/src/index.ts new file mode 100644 index 000000000..741164897 --- /dev/null +++ b/packages/redux/src/index.ts @@ -0,0 +1,35 @@ +/* + + 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. + + */ +export * from './createSliceHooks' +export * from './createStore' +export * from './types' +export * from './useActions' +export * from './useSaga' +export * from './useSagas' +export * from './useSlice' +export * from './useStoreState' +export * from './useStore' +export * from './deepCombineReducers' diff --git a/packages/redux/src/types/index.ts b/packages/redux/src/types/index.ts new file mode 100644 index 000000000..701ddc371 --- /dev/null +++ b/packages/redux/src/types/index.ts @@ -0,0 +1,52 @@ +/* + + 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 { + EnhancedStore, + ReducersMapObject, + Reducer, + Action, + AnyAction, +} from '@reduxjs/toolkit' +// +import type { Saga } from 'redux-saga' + +/** + * The enhanced store type that types the new methods we enhance it with. + */ +export type Store = AnyAction> = EnhancedStore< + S, + A +> & { + /** + * dynamically add reducer to a specific path. Example + * store.addReducer('scenes.admin', newReducer) + */ + addReducer: >( + path: string | string[], + reducer: ReducersMapObject | Reducer + ) => void + addSaga: (saga: Saga) => void +} diff --git a/packages/redux/src/useActions/index.spec.ts b/packages/redux/src/useActions/index.spec.ts new file mode 100644 index 000000000..3aebdd098 --- /dev/null +++ b/packages/redux/src/useActions/index.spec.ts @@ -0,0 +1,92 @@ +/* + + 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 { renderHook } from '@testing-library/react-hooks' +import { useDispatch } from 'react-redux' +import { bindActionCreators } from 'redux' +import { useActions } from '.' + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})) + +jest.mock('redux', () => ({ + bindActionCreators: jest.fn(), +})) + +const boundActionCreators = {} +const dispatch = () => {} +const sliceOptions = { + initialState: {}, + name: 'test', + reducers: { + test() {}, + }, +} + +beforeEach(() => { + // @ts-ignore + useDispatch.mockReset().mockReturnValue(dispatch) + + // @ts-ignore + bindActionCreators.mockReset().mockReturnValue(boundActionCreators) +}) + +test('calls dispatch', () => { + const slice = createSlice(sliceOptions) + renderHook(() => useActions(slice)) + expect(useDispatch).toHaveBeenCalledTimes(1) + expect(useDispatch).toHaveBeenCalledWith() +}) + +test('calls bindActionCreators only once for each slice', () => { + const slice1 = createSlice(sliceOptions) + const slice2 = createSlice(sliceOptions) + + // First call should be called. + renderHook(() => useActions(slice1)) + expect(bindActionCreators).toHaveBeenCalledTimes(1) + + // Second call should not be called. + renderHook(() => useActions(slice1)) + expect(bindActionCreators).toHaveBeenCalledTimes(1) + + // @ts-ignore we don't care about the type of dispatch in our testing. + expect(bindActionCreators).toHaveBeenCalledWith(slice1.actions, dispatch) + + // Third call, but with a different slice should be called. + renderHook(() => useActions(slice2)) + expect(bindActionCreators).toHaveBeenCalledTimes(2) + + // @ts-ignore we don't care about the type of dispatch in our testing. + expect(bindActionCreators).toHaveBeenCalledWith(slice2.actions, dispatch) +}) + +test('returns result of bindActionCreators', () => { + const slice = createSlice(sliceOptions) + const actions = renderHook(() => useActions(slice)) + expect(actions.result.current).toBe(boundActionCreators) +}) diff --git a/packages/redux/src/useActions/index.ts b/packages/redux/src/useActions/index.ts new file mode 100644 index 000000000..13c2d4602 --- /dev/null +++ b/packages/redux/src/useActions/index.ts @@ -0,0 +1,57 @@ +/* + + 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 { Slice } from '@reduxjs/toolkit' +import { bindActionCreators } from 'redux' +import { useDispatch } from 'react-redux' + +const dispatchMap = new WeakMap() + +/** + * Binds a slice's action creators to dispatch(). + * + * @param slice The slice who's actions we will pass to bindActionCreators. + */ +export const useActions = (slice: Slice) => { + const dispatch = useDispatch() + + // We must keep record of which bound action creators belong to which + // dispatch instance so that if a new store instance is created (i.e. tests) + // they get rebound and stored. + if (!dispatchMap.has(dispatch)) { + dispatchMap.set(dispatch, new WeakMap()) + } + + const boundActionCreatorsMap = dispatchMap.get(dispatch) + + if (!boundActionCreatorsMap.has(slice)) { + boundActionCreatorsMap.set( + slice, + bindActionCreators(slice.actions, dispatch) + ) + } + + return boundActionCreatorsMap.get(slice) +} diff --git a/packages/redux/src/useSaga/index.spec.ts b/packages/redux/src/useSaga/index.spec.ts new file mode 100644 index 000000000..ef914d89f --- /dev/null +++ b/packages/redux/src/useSaga/index.spec.ts @@ -0,0 +1,62 @@ +/* + + 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 { renderHook } from '@testing-library/react-hooks' +import { useStore } from 'react-redux' +import { useSaga } from '.' + +jest.mock('react-redux', () => ({ + useStore: jest.fn(), +})) + +function* saga() {} + +const mockUseStore = { + addSaga: jest.fn(), +} + +beforeEach(() => { + mockUseStore.addSaga.mockReset() + + // @ts-ignore + useStore.mockReset().mockReturnValue(mockUseStore) +}) + +test('calls useStore', () => { + renderHook(() => useSaga(saga)) + expect(useStore).toHaveBeenCalledTimes(1) + expect(useStore).toHaveBeenCalledWith() +}) + +test('calls addSaga', () => { + renderHook(() => useSaga(saga)) + expect(mockUseStore.addSaga).toHaveBeenCalledTimes(1) + expect(mockUseStore.addSaga).toHaveBeenCalledWith(saga) +}) + +test('does not call addSaga if no saga is provided', () => { + renderHook(() => useSaga()) + expect(mockUseStore.addSaga).toHaveBeenCalledTimes(0) +}) diff --git a/packages/redux/src/useSaga/index.ts b/packages/redux/src/useSaga/index.ts new file mode 100644 index 000000000..d70fd0d86 --- /dev/null +++ b/packages/redux/src/useSaga/index.ts @@ -0,0 +1,39 @@ +/* + + 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 { Saga } from 'redux-saga' +import { useStore } from '../useStore' + +/** + * Adds a saga to the nearest store. + * + * @param saga The saga to register on the nearest store. + */ +export const useSaga = (saga?: Saga) => { + const store = useStore() + if (saga) { + store.addSaga(saga) + } +} diff --git a/packages/redux/src/useSagas/index.spec.ts b/packages/redux/src/useSagas/index.spec.ts new file mode 100644 index 000000000..e9e2a52fd --- /dev/null +++ b/packages/redux/src/useSagas/index.spec.ts @@ -0,0 +1,62 @@ +/* + + 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 { renderHook } from '@testing-library/react-hooks' +import { useStore } from 'react-redux' +import { useSagas } from '.' + +jest.mock('react-redux', () => ({ + useStore: jest.fn(), +})) + +function* saga() {} + +const mockUseStore = { + addSaga: jest.fn(), +} + +beforeEach(() => { + mockUseStore.addSaga.mockReset() + + // @ts-ignore + useStore.mockReset().mockReturnValue(mockUseStore) +}) + +test('calls useStore', () => { + renderHook(() => useSagas([saga])) + expect(useStore).toHaveBeenCalledTimes(1) + expect(useStore).toHaveBeenCalledWith() +}) + +test('calls addSaga', () => { + renderHook(() => useSagas([saga])) + expect(mockUseStore.addSaga).toHaveBeenCalledTimes(1) + expect(mockUseStore.addSaga).toHaveBeenCalledWith(saga) +}) + +test('does not call addSaga if no saga is provided', () => { + renderHook(() => useSagas([])) + expect(mockUseStore.addSaga).toHaveBeenCalledTimes(0) +}) diff --git a/packages/redux/src/useSagas/index.ts b/packages/redux/src/useSagas/index.ts new file mode 100644 index 000000000..98720872f --- /dev/null +++ b/packages/redux/src/useSagas/index.ts @@ -0,0 +1,36 @@ +/* + + 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 { useStore } from '../useStore' + +/** + * Adds sagas to the nearest store. + * + * @param sagas The sagas to register on the nearest store. + */ +export const useSagas = (sagas: any[]) => { + const store = useStore() + sagas.forEach((saga) => store.addSaga(saga)) +} diff --git a/packages/redux/src/useSlice/index.spec.ts b/packages/redux/src/useSlice/index.spec.ts new file mode 100644 index 000000000..43f633d10 --- /dev/null +++ b/packages/redux/src/useSlice/index.spec.ts @@ -0,0 +1,67 @@ +/* + + 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 { renderHook } from '@testing-library/react-hooks' +import { useStore } from 'react-redux' +import { useSlice } from '.' + +jest.mock('react-redux', () => ({ + useStore: jest.fn(), +})) + +const mockUseStore = { + addReducer: jest.fn(), +} + +const slice = createSlice({ + initialState: {}, + name: 'test', + reducers: { + test() {}, + }, +}) + +beforeEach(() => { + mockUseStore.addReducer.mockReset() + + // @ts-ignore + useStore.mockReset().mockReturnValue(mockUseStore) +}) + +test('calls useStore', () => { + renderHook(() => useSlice(slice)) + expect(useStore).toHaveBeenCalledTimes(1) + expect(useStore).toHaveBeenCalledWith() +}) + +test('calls addReducer', () => { + renderHook(() => useSlice(slice)) + expect(mockUseStore.addReducer).toHaveBeenCalledTimes(1) + expect(mockUseStore.addReducer).toHaveBeenCalledWith( + slice.name, + slice.reducer + ) +}) diff --git a/packages/redux/src/useSlice/index.ts b/packages/redux/src/useSlice/index.ts new file mode 100644 index 000000000..446b9a99a --- /dev/null +++ b/packages/redux/src/useSlice/index.ts @@ -0,0 +1,38 @@ +/* + + 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 { Slice } from '@reduxjs/toolkit' +import { useStore } from 'react-redux' +import type { Store } from '../types' + +/** + * Adds a slice to the nearest store. + * + * @param slice The slice containing reducers to register on the nearest store. + */ +export const useSlice = (slice: Slice) => { + const store = useStore() as Store + store.addReducer(slice.name, slice.reducer) +} diff --git a/packages/redux/src/useStore/index.spec.tsx b/packages/redux/src/useStore/index.spec.tsx new file mode 100644 index 000000000..76c87d951 --- /dev/null +++ b/packages/redux/src/useStore/index.spec.tsx @@ -0,0 +1,52 @@ +/* + + 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 { renderHook } from '@testing-library/react-hooks' +import { useStore as useStoreReactRedux } from 'react-redux' +import { useStore } from '.' + +jest.mock('react-redux', () => ({ + useStore: jest.fn(), +})) + +const mockStore = { + addReducer: () => {}, + addSaga: () => {}, + replaceReducer: () => {}, +} + +beforeEach(() => { + ;(useStoreReactRedux as jest.Mock).mockReset().mockReturnValue(mockStore) +}) + +test('calls useStore and returns a store with correct typescript type', () => { + const res = renderHook(() => useStore()) + expect(useStoreReactRedux).toHaveBeenCalledTimes(1) + // expect redux toolkit type to compile + expect(res.result.current.replaceReducer).toBeDefined() + // expect our custom types to compile + expect(res.result.current.addReducer).toBeDefined() + expect(res.result.current.addSaga).toBeDefined() +}) diff --git a/packages/redux/src/useStore/index.ts b/packages/redux/src/useStore/index.ts new file mode 100644 index 000000000..83748e1c9 --- /dev/null +++ b/packages/redux/src/useStore/index.ts @@ -0,0 +1,31 @@ +/* + + 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 { useStore as useStoreReactRedux } from 'react-redux' +import type { Store } from '../types' + +export function useStore() { + return useStoreReactRedux() as Store +} diff --git a/packages/redux/src/useStoreState/index.spec.ts b/packages/redux/src/useStoreState/index.spec.ts new file mode 100644 index 000000000..8dc748d88 --- /dev/null +++ b/packages/redux/src/useStoreState/index.spec.ts @@ -0,0 +1,84 @@ +/* + + 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 { renderHook } from '@testing-library/react-hooks' +import { createSlice } from '@reduxjs/toolkit' +import { useSelector } from 'react-redux' +import { useSaga } from '../useSaga' +import { useSlice } from '../useSlice' +import { useStoreState } from '.' + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})) + +jest.mock('../useSaga', () => ({ + useSaga: jest.fn(), +})) + +jest.mock('../useSlice', () => ({ + useSlice: jest.fn(), +})) + +function* saga() {} + +const slice = createSlice({ + initialState: {}, + name: 'test', + reducers: {}, +}) + +beforeEach(() => { + // @ts-ignore + useSaga.mockReset() + + // @ts-ignore + useSelector.mockReset().mockImplementation((fn) => fn({ test: true })) + + // @ts-ignore + useSlice.mockReset() +}) + +test('calls useSlice', () => { + renderHook(() => useStoreState(slice)) + expect(useSlice).toHaveBeenCalledTimes(1) + expect(useSlice).toHaveBeenCalledWith(slice) +}) + +test('calls useSaga', () => { + renderHook(() => useStoreState(slice, saga)) + expect(useSaga).toHaveBeenCalledTimes(1) + expect(useSaga).toHaveBeenCalledWith(saga) +}) + +test('calls useSelector', () => { + renderHook(() => useStoreState(slice)) + expect(useSelector).toHaveReturnedTimes(1) +}) + +test('returns the store state', () => { + const state = renderHook(() => useStoreState(slice)) + expect(state.result.current).toBe(true) +}) diff --git a/packages/redux/src/useStoreState/index.ts b/packages/redux/src/useStoreState/index.ts new file mode 100644 index 000000000..38c0c2a1e --- /dev/null +++ b/packages/redux/src/useStoreState/index.ts @@ -0,0 +1,42 @@ +/* + + 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 { Slice } from '@reduxjs/toolkit' +import { useSelector } from 'react-redux' +import type { Saga } from 'redux-saga' +import { useSaga } from '../useSaga' +import { useSlice } from '../useSlice' + +/** + * Adds a saga and slice to the nearest store and returns the root state for the slice. + * + * @param slice The slice containing reducers to register on the nearest store. + * @param saga The saga to register on the nearest store. + */ +export const useStoreState = (slice: Slice, saga?: Saga) => { + useSaga(saga) + useSlice(slice) + return useSelector((state: any): State => state[slice.name]) +} diff --git a/packages/redux/tsconfig.build.json b/packages/redux/tsconfig.build.json new file mode 100644 index 000000000..418fcd887 --- /dev/null +++ b/packages/redux/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 704f8b3b4..fd514fe13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -193,6 +193,13 @@ dependencies: "@babel/types" "^7.13.12" +"@babel/helper-module-imports@^7.12.1": + version "7.15.4" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.15.4.tgz#e18007d230632dea19b47853b984476e7b4e103f" + integrity sha512-jeAHZbzUwdW/xHgHQ3QmWR4Jg6j15q4w/gCfwZvtqOxoo5DKtLHk8Bsf4c5RZRC7NmLEs+ohkdq8jFefuvIxAA== + dependencies: + "@babel/types" "^7.15.4" + "@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.13.0", "@babel/helper-module-transforms@^7.14.0": version "7.14.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.14.0.tgz#8fcf78be220156f22633ee204ea81f73f826a8ad" @@ -269,6 +276,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz#d26cad8a47c65286b15df1547319a5d0bcf27288" integrity sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A== +"@babel/helper-validator-identifier@^7.14.9": + version "7.15.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz#220df993bfe904a4a6b02ab4f3385a5ebf6e2389" + integrity sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w== + "@babel/helper-validator-option@^7.12.17": version "7.12.17" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz#d1fbf012e1a79b7eebbfdc6d270baaf8d9eb9831" @@ -1032,6 +1044,14 @@ "@babel/helper-validator-identifier" "^7.14.0" to-fast-properties "^2.0.0" +"@babel/types@^7.15.4": + version "7.15.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.6.tgz#99abdc48218b2881c058dd0a7ab05b99c9be758f" + integrity sha512-BPU+7QhqNjmWyDO0/vitH/CuhpV8ZmK1wpKva8nuyNF5MJfuRNWMc+hc14+u9xT93kvykMdncrJT19h74uB1Ig== + dependencies: + "@babel/helper-validator-identifier" "^7.14.9" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1567,6 +1587,17 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" +"@jest/types@^27.2.4": + version "27.2.4" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.2.4.tgz#2430042a66e00dc5b140c3636f4474d464c21ee8" + integrity sha512-IDO2ezTxeMvQAHxzG/ZvEyA47q0aVfzT95rGFl7bZs/Go0aIucvfDbS2rmnoEdXxlLQhcolmoG/wvL/uKx4tKA== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^16.0.0" + chalk "^4.0.0" + "@lerna/add@3.21.0": version "3.21.0" resolved "https://registry.yarnpkg.com/@lerna/add/-/add-3.21.0.tgz#27007bde71cc7b0a2969ab3c2f0ae41578b4577b" @@ -2633,6 +2664,16 @@ resolved "https://registry.yarnpkg.com/@redux-saga/types/-/types-1.1.0.tgz#0e81ce56b4883b4b2a3001ebe1ab298b84237204" integrity sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg== +"@reduxjs/toolkit@^1.6.2": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.6.2.tgz#2f2b5365df77dd6697da28fdf44f33501ed9ba37" + integrity sha512-HbfI/hOVrAcMGAYsMWxw3UJyIoAS9JTdwddsjlr5w3S50tXhWb+EMyhIw+IAvCVCLETkzdjgH91RjDSYZekVBA== + dependencies: + immer "^9.0.6" + redux "^4.1.0" + redux-thunk "^2.3.0" + reselect "^4.0.0" + "@sideway/address@^4.1.0": version "4.1.2" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.2.tgz#811b84333a335739d3969cfc434736268170cad1" @@ -2823,6 +2864,20 @@ lz-string "^1.4.4" pretty-format "^26.6.2" +"@testing-library/dom@^8.0.0": + version "8.7.2" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.7.2.tgz#234315c6971be380fc9cbf0b031ada3e9f0bfe09" + integrity sha512-2zN0Zv9dMnaMAd4c/1E1ZChu4QrICyvWtkUvHFQBPhS1oG3VYGcM7SLGLYdda7187ILRXzIUOvOsbXQm4EASjA== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^4.2.2" + chalk "^4.1.0" + dom-accessibility-api "^0.5.6" + lz-string "^1.4.4" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^5.11.6": version "5.11.9" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.11.9.tgz#e6b3cd687021f89f261bd53cbe367041fbd3e975" @@ -2837,6 +2892,17 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/react-hooks@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz#3388d07f562d91e7f2431a4a21b5186062ecfee0" + integrity sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg== + dependencies: + "@babel/runtime" "^7.12.5" + "@types/react" ">=16.9.0" + "@types/react-dom" ">=16.9.0" + "@types/react-test-renderer" ">=16.9.0" + react-error-boundary "^3.1.0" + "@testing-library/react@^11.2.2": version "11.2.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.2.5.tgz#ae1c36a66c7790ddb6662c416c27863d87818eb9" @@ -2845,6 +2911,14 @@ "@babel/runtime" "^7.12.5" "@testing-library/dom" "^7.28.1" +"@testing-library/react@^12.1.2": + version "12.1.2" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.2.tgz#f1bc9a45943461fa2a598bb4597df1ae044cfc76" + integrity sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^8.0.0" + "@testing-library/user-event@^12.6.0": version "12.8.1" resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-12.8.1.tgz#aa897d6e7f0cf2208385abc2da2ac3f5844bbd00" @@ -3073,6 +3147,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== +"@types/lodash@^4.14.175": + version "4.14.175" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.175.tgz#b78dfa959192b01fae0ad90e166478769b215f45" + integrity sha512-XmdEOrKQ8a1Y/yxQFOMbC47G/V2VDO1GvMRnl4O75M4GW/abC5tnfzadQYkqEveqRM1dEJGFFegfPNA2vvx2iw== + "@types/mdast@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb" @@ -3152,6 +3231,13 @@ dependencies: "@types/node" "*" +"@types/react-dom@>=16.9.0": + version "17.0.9" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add" + integrity sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg== + dependencies: + "@types/react" "*" + "@types/react-dom@^16.9.6": version "16.9.11" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.11.tgz#752e223a1592a2c10f2668b215a0e0667f4faab1" @@ -3169,6 +3255,16 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" +"@types/react-redux@^7.1.18": + version "7.1.18" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.18.tgz#2bf8fd56ebaae679a90ebffe48ff73717c438e04" + integrity sha512-9iwAsPyJ9DLTRH+OFeIrm9cAbIj1i2ANL3sKQFATqnPWRbg+jEFXyZOKHiQK/N86pNRXbb4HRxAxo0SIX1XwzQ== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + "@types/react-router-dom@^5.1.5": version "5.1.7" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.7.tgz#a126d9ea76079ffbbdb0d9225073eb5797ab7271" @@ -3186,6 +3282,13 @@ "@types/history" "*" "@types/react" "*" +"@types/react-test-renderer@>=16.9.0", "@types/react-test-renderer@^17.0.0": + version "17.0.1" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz#3120f7d1c157fba9df0118dae20cb0297ee0e06b" + integrity sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw== + dependencies: + "@types/react" "*" + "@types/react-test-renderer@^16.9.3": version "16.9.5" resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-16.9.5.tgz#edab67da470f7c3e997f58d55dcfe2643cc30a68" @@ -3193,13 +3296,6 @@ dependencies: "@types/react" "^16" -"@types/react-test-renderer@^17.0.0": - version "17.0.1" - resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz#3120f7d1c157fba9df0118dae20cb0297ee0e06b" - integrity sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw== - dependencies: - "@types/react" "*" - "@types/react@*", "@types/react@^16", "@types/react@^16.14.2": version "16.14.4" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.4.tgz#365f6a1e117d1eec960ba792c7e1e91ecad38e6f" @@ -3208,6 +3304,15 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/react@>=16.9.0", "@types/react@^17.0.27": + version "17.0.27" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.27.tgz#6498ed9b3ad117e818deb5525fa1946c09f2e0e6" + integrity sha512-zgiJwtsggVGtr53MndV7jfiUESTqrbxOcBvwfe6KS/9bzaVPCTDieTWnFNecVNx6EAaapg5xsLLWFfHHR437AA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/readable-stream@^2.3.5": version "2.3.9" resolved "https://registry.yarnpkg.com/@types/readable-stream/-/readable-stream-2.3.9.tgz#40a8349e6ace3afd2dd1b6d8e9b02945de4566a9" @@ -3240,6 +3345,11 @@ "@types/tough-cookie" "*" form-data "^2.5.0" +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + "@types/semver@^7.3.4": version "7.3.4" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.4.tgz#43d7168fec6fa0988bb1a513a697b29296721afb" @@ -3762,6 +3872,11 @@ ansi-regex@^5.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -4144,7 +4259,7 @@ babel-plugin-jest-hoist@^26.6.2: "@types/babel__core" "^7.0.0" "@types/babel__traverse" "^7.0.6" -babel-plugin-macros@^2.0.0: +babel-plugin-macros@^2.0.0, babel-plugin-macros@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz#0f958a7cc6556b1e65344465d99111a1e5e10138" integrity sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg== @@ -5818,6 +5933,11 @@ dom-accessibility-api@^0.5.4: resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz#b06d059cdd4a4ad9a79275f9d414a5c126241166" integrity sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ== +dom-accessibility-api@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.7.tgz#8c2aa6325968f2933160a0b7dbb380893ddf3e7d" + integrity sha512-ml3lJIq9YjUfM9TUnEPvEYWFSwivwIGBPKpewX7tii7fwCazA8yCioGdqQcNsItPpfFvSJ3VIdMQPj60LJhcQA== + dom-serializer@^1.0.1, dom-serializer@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.2.0.tgz#3433d9136aeb3c627981daa385fc7f32d27c48f1" @@ -8025,6 +8145,11 @@ ignore@^5.1.1, ignore@^5.1.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== +immer@^9.0.6: + version "9.0.6" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" + integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ== + import-fresh@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" @@ -11826,6 +11951,16 @@ pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" +pretty-format@^27.0.2: + version "27.2.4" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.2.4.tgz#08ea39c5eab41b082852d7093059a091f6ddc748" + integrity sha512-NUjw22WJHldzxyps2YjLZkUj6q1HvjqFezkB9Y2cklN8NtVZN/kZEXGZdFw4uny3oENzV5EEMESrkI0YDUH8vg== + dependencies: + "@jest/types" "^27.2.4" + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + pretty-format@^27.0.6: version "27.0.6" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.0.6.tgz#ab770c47b2c6f893a21aefc57b75da63ef49a11f" @@ -12138,6 +12273,13 @@ react-dom@^16.13.1: prop-types "^15.6.2" scheduler "^0.19.1" +react-error-boundary@^3.1.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.3.tgz#276bfa05de8ac17b863587c9e0647522c25e2a0b" + integrity sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" @@ -12312,6 +12454,14 @@ react@^16.13.1: object-assign "^4.1.1" prop-types "^15.6.2" +react@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + read-cmd-shim@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-1.0.5.tgz#87e43eba50098ba5a32d0ceb583ab8e43b961c16" @@ -12498,6 +12648,11 @@ redux-saga@^1.1.3: dependencies: "@redux-saga/core" "^1.1.3" +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + redux@*, redux@^4.0.0, redux@^4.0.4, redux@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" @@ -12506,6 +12661,13 @@ redux@*, redux@^4.0.0, redux@^4.0.4, redux@^4.0.5: loose-envify "^1.4.0" symbol-observable "^1.2.0" +redux@^4.1.0, redux@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.1.tgz#76f1c439bb42043f985fbd9bf21990e60bd67f47" + integrity sha512-hZQZdDEM25UY2P493kPYuKqviVwZ58lEmGQNeQ+gXa+U0gYPUBf7NKYazbe3m+bs/DzM/ahN12DbF+NG8i0CWw== + dependencies: + "@babel/runtime" "^7.9.2" + reflect-metadata@0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" @@ -12769,6 +12931,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= +reselect@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" + integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== + resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" @@ -14401,6 +14568,14 @@ type-is@~1.6.17, type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +typed-redux-saga@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/typed-redux-saga/-/typed-redux-saga-1.3.1.tgz#92b01db41e3510102f87eb9ff261ec73d38a2e44" + integrity sha512-nUj1/1/SAesEsZrr7o24ID+++CqZ6QfPVDcwhY2rVmm4vEBr/vbDHJ6j/w6SomOcooLwnh3sdaWVhNEIy7VgNA== + optionalDependencies: + "@babel/helper-module-imports" "^7.12.1" + babel-plugin-macros "^2.8.0" + typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"