mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
81dd57524d
commit
989f98efda
10
public/app/core/actions/cleanUp.ts
Normal file
10
public/app/core/actions/cleanUp.ts
Normal file
@ -0,0 +1,10 @@
|
||||
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();
|
39
public/app/core/components/connectWithCleanUp.tsx
Normal file
39
public/app/core/components/connectWithCleanUp.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
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;
|
||||
};
|
99
public/app/core/reducers/root.test.ts
Normal file
99
public/app/core/reducers/root.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
70
public/app/core/reducers/root.ts
Normal file
70
public/app/core/reducers/root.ts
Normal file
@ -0,0 +1,70 @@
|
||||
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);
|
||||
};
|
@ -1,17 +1,17 @@
|
||||
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));
|
||||
|
@ -1,46 +1,15 @@
|
||||
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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user