Commit 989f98ef by Hugo Häggmark Committed by GitHub

Feature: Adds connectWithCleanup HOC (#19392)

* Feature: Adds connectWithCleanup HOC

* Refactor: Small typings

* Refactor: Makes UseEffect run on Mount and UnMount only

* Refactor: Adds tests and rootReducer
parent 81dd5752
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 { recursiveCleanState, rootReducer } 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', () => {
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 { StoreState } from '../../types';
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,
};
export function addRootReducer(reducers: any) {
Object.assign(rootReducers, ...reducers);
}
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;
};
const appReducer = combineReducers(rootReducers);
export const rootReducer = (state: StoreState, action: ActionOf<any>): StoreState => {
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);
};
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import Page from 'app/core/components/Page/Page';
import { DeleteButton } from '@grafana/ui';
import { NavModel } from '@grafana/data';
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 { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors';
import { getNavModel } from 'app/core/selectors/navModel';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { config } from 'app/core/config';
import { contextSrv, User } from 'app/core/services/context_srv';
import { connectWithCleanUp } from '../../core/components/connectWithCleanUp';
export interface Props {
navModel: NavModel;
......@@ -152,7 +152,7 @@ export class TeamList extends PureComponent<Props, any> {
}
}
function mapStateToProps(state: any) {
function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'teams'),
teams: getTeams(state.teams),
......@@ -170,9 +170,4 @@ const mapDispatchToProps = {
setSearchQuery,
};
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(TeamList)
);
export default hot(module)(connectWithCleanUp(mapStateToProps, mapDispatchToProps, state => state.teams)(TeamList));
import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import { applyMiddleware, compose, createStore } from 'redux';
import thunk from 'redux-thunk';
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 { StoreState } from 'app/types/store';
import { toggleLogActionsMiddleware } from 'app/core/middlewares/application';
const rootReducers = {
...sharedReducers,
...alertingReducers,
...teamsReducers,
...apiKeysReducers,
...foldersReducers,
...dashboardReducers,
...exploreReducers,
...pluginReducers,
...dataSourcesReducers,
...usersReducers,
...userReducers,
...organizationReducers,
...ldapReducers,
};
export function addRootReducer(reducers: any) {
Object.assign(rootReducers, ...reducers);
}
import { rootReducer } from '../core/reducers/root';
export function configureStore() {
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const rootReducer = combineReducers(rootReducers);
const logger = createLogger({
predicate: (getState: () => StoreState) => {
return getState().application.logActions;
......
......@@ -16,6 +16,7 @@ import { NavIndex } from '@grafana/data';
import { ApplicationState } from './application';
import { LdapState, LdapUserState } from './ldap';
import { PanelEditorState } from '../features/dashboard/panel_editor/state/reducers';
import { ApiKeysState } from './apiKeys';
export interface StoreState {
navIndex: NavIndex;
......@@ -36,6 +37,7 @@ export interface StoreState {
application: ApplicationState;
ldap: LdapState;
ldapUser: LdapUserState;
apiKeys: ApiKeysState;
}
/*
......
......@@ -3,7 +3,7 @@ import { Reducer } from 'redux';
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
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> {
......@@ -12,6 +12,7 @@ export interface When<State> {
export interface Then<State> {
thenStateShouldEqual: (state: State) => When<State>;
thenStatePredicateShouldEqual: (predicate: (resultingState: State) => boolean) => When<State>;
}
interface ObjectType extends Object {
......@@ -53,10 +54,16 @@ export const reducerTester = <State>(): Given<State> => {
let resultingState: 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;
initialState = { ...state };
initialState = deepFreeze(initialState);
if (!disableDeepFreeze) {
initialState = deepFreeze(initialState);
}
return instance;
};
......@@ -73,7 +80,18 @@ export const reducerTester = <State>(): Given<State> => {
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;
};
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