mirror of
https://github.com/grafana/grafana.git
synced 2025-08-03 04:22:13 +08: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:
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 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> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = {
|
|||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default hot(module)(
|
export default hot(module)(connectWithCleanUp(mapStateToProps, mapDispatchToProps, state => state.teams)(TeamList));
|
||||||
connect(
|
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(TeamList)
|
|
||||||
);
|
|
||||||
|
@ -1,46 +1,15 @@
|
|||||||
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 { rootReducer } from '../core/reducers/root';
|
||||||
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 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;
|
||||||
|
@ -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 {
|
|||||||
application: ApplicationState;
|
application: ApplicationState;
|
||||||
ldap: LdapState;
|
ldap: LdapState;
|
||||||
ldapUser: LdapUserState;
|
ldapUser: LdapUserState;
|
||||||
|
apiKeys: ApiKeysState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -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> {
|
|||||||
|
|
||||||
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> => {
|
|||||||
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 };
|
||||||
|
if (!disableDeepFreeze) {
|
||||||
initialState = deepFreeze(initialState);
|
initialState = deepFreeze(initialState);
|
||||||
|
}
|
||||||
|
|
||||||
return instance;
|
return instance;
|
||||||
};
|
};
|
||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user