Commit 133ddc9a by Hugo Häggmark Committed by GitHub

Feature: Adds connectWithCleanup HOC (#19629)

* Feature: Adds connectWithCleanup HOC

* Refactor: Small typings

* Refactor: Makes UseEffect run on Mount and UnMount only

* Refactor: Adds tests and rootReducer

* Refactor: Fixes adding of reducers on startup
parent b0bf2ea0
import { StoreState } from '../../types';
import { actionCreatorFactory } from '../redux';
export type StateSelector<T extends object> = (state: StoreState) => T;
export interface CleanUp<T extends object> {
stateSelector: StateSelector<T>;
}
export const cleanUpAction = actionCreatorFactory<CleanUp<{}>>('CORE_CLEAN_UP_STATE').create();
import { MapStateToPropsParam, MapDispatchToPropsParam, connect, useDispatch } from 'react-redux';
import { StateSelector, cleanUpAction } from '../actions/cleanUp';
import React, { ComponentType, FunctionComponent, useEffect } from 'react';
import hoistNonReactStatics from 'hoist-non-react-statics';
export const connectWithCleanUp = <
TStateProps extends {} = {},
TDispatchProps = {},
TOwnProps = {},
State = {},
TSelector extends object = {},
Statics = {}
>(
mapStateToProps: MapStateToPropsParam<TStateProps, TOwnProps, State>,
mapDispatchToProps: MapDispatchToPropsParam<TDispatchProps, TOwnProps>,
stateSelector: StateSelector<TSelector>
) => (Component: ComponentType<any>) => {
const ConnectedComponent = connect(
mapStateToProps,
mapDispatchToProps
)(Component);
const ConnectedComponentWithCleanUp: FunctionComponent = props => {
const dispatch = useDispatch();
useEffect(() => {
return function cleanUp() {
dispatch(cleanUpAction({ stateSelector }));
};
}, []);
// @ts-ignore
return <ConnectedComponent {...props} />;
};
ConnectedComponentWithCleanUp.displayName = `ConnectWithCleanUp(${ConnectedComponent.displayName})`;
hoistNonReactStatics(ConnectedComponentWithCleanUp, Component);
type Hoisted = typeof ConnectedComponentWithCleanUp & Statics;
return ConnectedComponentWithCleanUp as Hoisted;
};
import { createRootReducer, recursiveCleanState } from './root';
import { describe, expect } from '../../../test/lib/common';
import { NavModelItem } from '@grafana/data';
import { reducerTester } from '../../../test/core/redux/reducerTester';
import { StoreState } from '../../types/store';
import { ActionTypes } from '../../features/teams/state/actions';
import { Team } from '../../types';
import { cleanUpAction } from '../actions/cleanUp';
import { initialTeamsState } from '../../features/teams/state/reducers';
jest.mock('@grafana/runtime', () => ({
config: {
bootData: {
navTree: [] as NavModelItem[],
user: {},
},
},
}));
describe('recursiveCleanState', () => {
describe('when called with an existing state selector', () => {
it('then it should clear that state slice in state', () => {
const state = {
teams: { teams: [{ id: 1 }, { id: 2 }] },
};
// Choosing a deeper state selector here just to test recursive behaviour
// This should be same state slice that matches the state slice of a reducer like state.teams
const stateSelector = state.teams.teams[0];
recursiveCleanState(state, stateSelector);
expect(state.teams.teams[0]).not.toBeDefined();
expect(state.teams.teams[1]).toBeDefined();
});
});
describe('when called with a non existing state selector', () => {
it('then it should not clear that state slice in state', () => {
const state = {
teams: { teams: [{ id: 1 }, { id: 2 }] },
};
// Choosing a deeper state selector here just to test recursive behaviour
// This should be same state slice that matches the state slice of a reducer like state.teams
const stateSelector = state.teams.teams[2];
recursiveCleanState(state, stateSelector);
expect(state.teams.teams[0]).toBeDefined();
expect(state.teams.teams[1]).toBeDefined();
});
});
});
describe('rootReducer', () => {
const rootReducer = createRootReducer();
describe('when called with any action except cleanUpAction', () => {
it('then it should not clean state', () => {
const teams = [{ id: 1 }];
const state = {
teams: { ...initialTeamsState },
} as StoreState;
reducerTester<StoreState>()
.givenReducer(rootReducer, state)
.whenActionIsDispatched({
type: ActionTypes.LoadTeams,
payload: teams,
})
.thenStatePredicateShouldEqual(resultingState => {
expect(resultingState.teams).toEqual({
hasFetched: true,
searchQuery: '',
teams,
});
return true;
});
});
});
describe('when called with cleanUpAction', () => {
it('then it should clean state', () => {
const teams = [{ id: 1 }] as Team[];
const state: StoreState = {
teams: {
hasFetched: true,
searchQuery: '',
teams,
},
} as StoreState;
reducerTester<StoreState>()
.givenReducer(rootReducer, state, true)
.whenActionIsDispatched(cleanUpAction({ stateSelector: storeState => storeState.teams }))
.thenStatePredicateShouldEqual(resultingState => {
expect(resultingState.teams).toEqual({ ...initialTeamsState });
return true;
});
});
});
});
import { combineReducers } from 'redux';
import { ActionOf } from '../redux';
import { CleanUp, cleanUpAction } from '../actions/cleanUp';
import sharedReducers from 'app/core/reducers';
import alertingReducers from 'app/features/alerting/state/reducers';
import teamsReducers from 'app/features/teams/state/reducers';
import apiKeysReducers from 'app/features/api-keys/state/reducers';
import foldersReducers from 'app/features/folders/state/reducers';
import dashboardReducers from 'app/features/dashboard/state/reducers';
import exploreReducers from 'app/features/explore/state/reducers';
import pluginReducers from 'app/features/plugins/state/reducers';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
import userReducers from 'app/features/profile/state/reducers';
import organizationReducers from 'app/features/org/state/reducers';
import ldapReducers from 'app/features/admin/state/reducers';
const rootReducers = {
...sharedReducers,
...alertingReducers,
...teamsReducers,
...apiKeysReducers,
...foldersReducers,
...dashboardReducers,
...exploreReducers,
...pluginReducers,
...dataSourcesReducers,
...usersReducers,
...userReducers,
...organizationReducers,
...ldapReducers,
};
const addedReducers = {};
export const addReducer = (addedReducer: any) => {
Object.assign(addedReducers, ...addedReducer);
};
export const createRootReducer = () => {
const appReducer = combineReducers({
...rootReducers,
...addedReducers,
});
return (state: any, action: ActionOf<any>): any => {
if (action.type !== cleanUpAction.type) {
return appReducer(state, action);
}
const { stateSelector } = action.payload as CleanUp<any>;
const stateSlice = stateSelector(state);
recursiveCleanState(state, stateSlice);
return appReducer(state, action);
};
};
export const recursiveCleanState = (state: any, stateSlice: any): boolean => {
for (const stateKey in state) {
if (state[stateKey] === stateSlice) {
state[stateKey] = undefined;
return true;
}
if (typeof state[stateKey] === 'object') {
const cleaned = recursiveCleanState(state[stateKey], stateSlice);
if (cleaned) {
return true;
}
}
}
return false;
};
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { DeleteButton } from '@grafana/ui'; import { DeleteButton } from '@grafana/ui';
import { NavModel } from '@grafana/data'; import { NavModel } from '@grafana/data';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { Team, OrgRole } from 'app/types'; import { Team, OrgRole, StoreState } from 'app/types';
import { loadTeams, deleteTeam, setSearchQuery } from './state/actions'; import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors'; import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors';
import { getNavModel } from 'app/core/selectors/navModel'; import { getNavModel } from 'app/core/selectors/navModel';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import { contextSrv, User } from 'app/core/services/context_srv'; import { contextSrv, User } from 'app/core/services/context_srv';
import { connectWithCleanUp } from '../../core/components/connectWithCleanUp';
export interface Props { export interface Props {
navModel: NavModel; navModel: NavModel;
...@@ -152,7 +152,7 @@ export class TeamList extends PureComponent<Props, any> { ...@@ -152,7 +152,7 @@ export class TeamList extends PureComponent<Props, any> {
} }
} }
function mapStateToProps(state: any) { function mapStateToProps(state: StoreState) {
return { return {
navModel: getNavModel(state.navIndex, 'teams'), navModel: getNavModel(state.navIndex, 'teams'),
teams: getTeams(state.teams), teams: getTeams(state.teams),
...@@ -170,9 +170,4 @@ const mapDispatchToProps = { ...@@ -170,9 +170,4 @@ const mapDispatchToProps = {
setSearchQuery, setSearchQuery,
}; };
export default hot(module)( export default hot(module)(connectWithCleanUp(mapStateToProps, mapDispatchToProps, state => state.teams)(TeamList));
connect(
mapStateToProps,
mapDispatchToProps
)(TeamList)
);
import { createStore, applyMiddleware, compose, combineReducers } from 'redux'; import { applyMiddleware, compose, createStore } from 'redux';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { createLogger } from 'redux-logger'; import { createLogger } from 'redux-logger';
import sharedReducers from 'app/core/reducers';
import alertingReducers from 'app/features/alerting/state/reducers';
import teamsReducers from 'app/features/teams/state/reducers';
import apiKeysReducers from 'app/features/api-keys/state/reducers';
import foldersReducers from 'app/features/folders/state/reducers';
import dashboardReducers from 'app/features/dashboard/state/reducers';
import exploreReducers from 'app/features/explore/state/reducers';
import pluginReducers from 'app/features/plugins/state/reducers';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
import userReducers from 'app/features/profile/state/reducers';
import organizationReducers from 'app/features/org/state/reducers';
import ldapReducers from 'app/features/admin/state/reducers';
import { setStore } from './store'; import { setStore } from './store';
import { StoreState } from 'app/types/store'; import { StoreState } from 'app/types/store';
import { toggleLogActionsMiddleware } from 'app/core/middlewares/application'; import { toggleLogActionsMiddleware } from 'app/core/middlewares/application';
import { addReducer, createRootReducer } from '../core/reducers/root';
const rootReducers = {
...sharedReducers,
...alertingReducers,
...teamsReducers,
...apiKeysReducers,
...foldersReducers,
...dashboardReducers,
...exploreReducers,
...pluginReducers,
...dataSourcesReducers,
...usersReducers,
...userReducers,
...organizationReducers,
...ldapReducers,
};
export function addRootReducer(reducers: any) { export function addRootReducer(reducers: any) {
Object.assign(rootReducers, ...reducers); // this is ok now because we add reducers before configureStore is called
// in the future if we want to add reducers during runtime
// we'll have to solve this in a more dynamic way
addReducer(reducers);
} }
export function configureStore() { export function configureStore() {
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const rootReducer = combineReducers(rootReducers);
const logger = createLogger({ const logger = createLogger({
predicate: (getState: () => StoreState) => { predicate: (getState: () => StoreState) => {
return getState().application.logActions; return getState().application.logActions;
...@@ -51,7 +27,7 @@ export function configureStore() { ...@@ -51,7 +27,7 @@ export function configureStore() {
? applyMiddleware(toggleLogActionsMiddleware, thunk, logger) ? applyMiddleware(toggleLogActionsMiddleware, thunk, logger)
: applyMiddleware(thunk); : applyMiddleware(thunk);
const store = createStore(rootReducer, {}, composeEnhancers(storeEnhancers)); const store: any = createStore(createRootReducer(), {}, composeEnhancers(storeEnhancers));
setStore(store); setStore(store);
return store; return store;
} }
...@@ -16,6 +16,7 @@ import { NavIndex } from '@grafana/data'; ...@@ -16,6 +16,7 @@ import { NavIndex } from '@grafana/data';
import { ApplicationState } from './application'; import { ApplicationState } from './application';
import { LdapState, LdapUserState } from './ldap'; import { LdapState, LdapUserState } from './ldap';
import { PanelEditorState } from '../features/dashboard/panel_editor/state/reducers'; import { PanelEditorState } from '../features/dashboard/panel_editor/state/reducers';
import { ApiKeysState } from './apiKeys';
export interface StoreState { export interface StoreState {
navIndex: NavIndex; navIndex: NavIndex;
...@@ -36,6 +37,7 @@ export interface StoreState { ...@@ -36,6 +37,7 @@ export interface StoreState {
application: ApplicationState; application: ApplicationState;
ldap: LdapState; ldap: LdapState;
ldapUser: LdapUserState; ldapUser: LdapUserState;
apiKeys: ApiKeysState;
} }
/* /*
......
...@@ -3,7 +3,7 @@ import { Reducer } from 'redux'; ...@@ -3,7 +3,7 @@ import { Reducer } from 'redux';
import { ActionOf } from 'app/core/redux/actionCreatorFactory'; import { ActionOf } from 'app/core/redux/actionCreatorFactory';
export interface Given<State> { export interface Given<State> {
givenReducer: (reducer: Reducer<State, ActionOf<any>>, state: State) => When<State>; givenReducer: (reducer: Reducer<State, ActionOf<any>>, state: State, disableDeepFreeze?: boolean) => When<State>;
} }
export interface When<State> { export interface When<State> {
...@@ -12,6 +12,7 @@ export interface When<State> { ...@@ -12,6 +12,7 @@ export interface When<State> {
export interface Then<State> { export interface Then<State> {
thenStateShouldEqual: (state: State) => When<State>; thenStateShouldEqual: (state: State) => When<State>;
thenStatePredicateShouldEqual: (predicate: (resultingState: State) => boolean) => When<State>;
} }
interface ObjectType extends Object { interface ObjectType extends Object {
...@@ -53,10 +54,16 @@ export const reducerTester = <State>(): Given<State> => { ...@@ -53,10 +54,16 @@ export const reducerTester = <State>(): Given<State> => {
let resultingState: State; let resultingState: State;
let initialState: State; let initialState: State;
const givenReducer = (reducer: Reducer<State, ActionOf<any>>, state: State): When<State> => { const givenReducer = (
reducer: Reducer<State, ActionOf<any>>,
state: State,
disableDeepFreeze = false
): When<State> => {
reducerUnderTest = reducer; reducerUnderTest = reducer;
initialState = { ...state }; initialState = { ...state };
initialState = deepFreeze(initialState); if (!disableDeepFreeze) {
initialState = deepFreeze(initialState);
}
return instance; return instance;
}; };
...@@ -73,7 +80,18 @@ export const reducerTester = <State>(): Given<State> => { ...@@ -73,7 +80,18 @@ export const reducerTester = <State>(): Given<State> => {
return instance; return instance;
}; };
const instance: ReducerTester<State> = { thenStateShouldEqual, givenReducer, whenActionIsDispatched }; const thenStatePredicateShouldEqual = (predicate: (resultingState: State) => boolean): When<State> => {
expect(predicate(resultingState)).toBe(true);
return instance;
};
const instance: ReducerTester<State> = {
thenStateShouldEqual,
thenStatePredicateShouldEqual,
givenReducer,
whenActionIsDispatched,
};
return instance; return instance;
}; };
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment