mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge pull request #15158 from grafana/hugoh/redux-poc
WIP: Reducing boilerplate code for Redux
This commit is contained in:
commit
3d78cb4f8c
83
public/app/core/redux/actionCreatorFactory.test.ts
Normal file
83
public/app/core/redux/actionCreatorFactory.test.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
actionCreatorFactory,
|
||||||
|
resetAllActionCreatorTypes,
|
||||||
|
noPayloadActionCreatorFactory,
|
||||||
|
} from './actionCreatorFactory';
|
||||||
|
|
||||||
|
interface Dummy {
|
||||||
|
n: number;
|
||||||
|
s: string;
|
||||||
|
o: {
|
||||||
|
n: number;
|
||||||
|
s: string;
|
||||||
|
b: boolean;
|
||||||
|
};
|
||||||
|
b: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setup = (payload?: Dummy) => {
|
||||||
|
resetAllActionCreatorTypes();
|
||||||
|
const actionCreator = actionCreatorFactory<Dummy>('dummy').create();
|
||||||
|
const noPayloadactionCreator = noPayloadActionCreatorFactory('NoPayload').create();
|
||||||
|
const result = actionCreator(payload);
|
||||||
|
const noPayloadResult = noPayloadactionCreator();
|
||||||
|
|
||||||
|
return { actionCreator, noPayloadactionCreator, result, noPayloadResult };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('actionCreatorFactory', () => {
|
||||||
|
describe('when calling create', () => {
|
||||||
|
it('then it should create correct type string', () => {
|
||||||
|
const payload = { n: 1, b: true, s: 'dummy', o: { n: 1, b: true, s: 'dummy' } };
|
||||||
|
const { actionCreator, result } = setup(payload);
|
||||||
|
|
||||||
|
expect(actionCreator.type).toEqual('dummy');
|
||||||
|
expect(result.type).toEqual('dummy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('then it should create correct payload', () => {
|
||||||
|
const payload = { n: 1, b: true, s: 'dummy', o: { n: 1, b: true, s: 'dummy' } };
|
||||||
|
const { result } = setup(payload);
|
||||||
|
|
||||||
|
expect(result.payload).toEqual(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when calling create with existing type', () => {
|
||||||
|
it('then it should throw error', () => {
|
||||||
|
const payload = { n: 1, b: true, s: 'dummy', o: { n: 1, b: true, s: 'dummy' } };
|
||||||
|
setup(payload);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
noPayloadActionCreatorFactory('DuMmY').create();
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noPayloadActionCreatorFactory', () => {
|
||||||
|
describe('when calling create', () => {
|
||||||
|
it('then it should create correct type string', () => {
|
||||||
|
const { noPayloadResult, noPayloadactionCreator } = setup();
|
||||||
|
|
||||||
|
expect(noPayloadactionCreator.type).toEqual('NoPayload');
|
||||||
|
expect(noPayloadResult.type).toEqual('NoPayload');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('then it should create correct payload', () => {
|
||||||
|
const { noPayloadResult } = setup();
|
||||||
|
|
||||||
|
expect(noPayloadResult.payload).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when calling create with existing type', () => {
|
||||||
|
it('then it should throw error', () => {
|
||||||
|
setup();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
actionCreatorFactory<Dummy>('nOpAyLoAd').create();
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
57
public/app/core/redux/actionCreatorFactory.ts
Normal file
57
public/app/core/redux/actionCreatorFactory.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Action } from 'redux';
|
||||||
|
|
||||||
|
const allActionCreators: string[] = [];
|
||||||
|
|
||||||
|
export interface ActionOf<Payload> extends Action {
|
||||||
|
readonly type: string;
|
||||||
|
readonly payload: Payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionCreator<Payload> {
|
||||||
|
readonly type: string;
|
||||||
|
(payload: Payload): ActionOf<Payload>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoPayloadActionCreator {
|
||||||
|
readonly type: string;
|
||||||
|
(): ActionOf<undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionCreatorFactory<Payload> {
|
||||||
|
create: () => ActionCreator<Payload>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoPayloadActionCreatorFactory {
|
||||||
|
create: () => NoPayloadActionCreator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionCreatorFactory = <Payload>(type: string): ActionCreatorFactory<Payload> => {
|
||||||
|
const create = (): ActionCreator<Payload> => {
|
||||||
|
return Object.assign((payload: Payload): ActionOf<Payload> => ({ type, payload }), { type });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (allActionCreators.some(t => (t && type ? t.toLocaleUpperCase() === type.toLocaleUpperCase() : false))) {
|
||||||
|
throw new Error(`There is already an actionCreator defined with the type ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
allActionCreators.push(type);
|
||||||
|
|
||||||
|
return { create };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const noPayloadActionCreatorFactory = (type: string): NoPayloadActionCreatorFactory => {
|
||||||
|
const create = (): NoPayloadActionCreator => {
|
||||||
|
return Object.assign((): ActionOf<undefined> => ({ type, payload: undefined }), { type });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (allActionCreators.some(t => (t && type ? t.toLocaleUpperCase() === type.toLocaleUpperCase() : false))) {
|
||||||
|
throw new Error(`There is already an actionCreator defined with the type ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
allActionCreators.push(type);
|
||||||
|
|
||||||
|
return { create };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should only be used by tests
|
||||||
|
export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0);
|
4
public/app/core/redux/index.ts
Normal file
4
public/app/core/redux/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { actionCreatorFactory } from './actionCreatorFactory';
|
||||||
|
import { reducerFactory } from './reducerFactory';
|
||||||
|
|
||||||
|
export { actionCreatorFactory, reducerFactory };
|
97
public/app/core/redux/reducerFactory.test.ts
Normal file
97
public/app/core/redux/reducerFactory.test.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { reducerFactory } from './reducerFactory';
|
||||||
|
import { actionCreatorFactory, ActionOf } from './actionCreatorFactory';
|
||||||
|
|
||||||
|
interface DummyReducerState {
|
||||||
|
n: number;
|
||||||
|
s: string;
|
||||||
|
b: boolean;
|
||||||
|
o: {
|
||||||
|
n: number;
|
||||||
|
s: string;
|
||||||
|
b: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyReducerIntialState: DummyReducerState = {
|
||||||
|
n: 1,
|
||||||
|
s: 'One',
|
||||||
|
b: true,
|
||||||
|
o: {
|
||||||
|
n: 2,
|
||||||
|
s: 'two',
|
||||||
|
b: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const dummyActionCreator = actionCreatorFactory<DummyReducerState>('dummy').create();
|
||||||
|
|
||||||
|
const dummyReducer = reducerFactory(dummyReducerIntialState)
|
||||||
|
.addMapper({
|
||||||
|
filter: dummyActionCreator,
|
||||||
|
mapper: (state, action) => ({ ...state, ...action.payload }),
|
||||||
|
})
|
||||||
|
.create();
|
||||||
|
|
||||||
|
describe('reducerFactory', () => {
|
||||||
|
describe('given it is created with a defined handler', () => {
|
||||||
|
describe('when reducer is called with no state', () => {
|
||||||
|
describe('and with an action that the handler can not handle', () => {
|
||||||
|
it('then the resulting state should be intial state', () => {
|
||||||
|
const result = dummyReducer(undefined as DummyReducerState, {} as ActionOf<any>);
|
||||||
|
|
||||||
|
expect(result).toEqual(dummyReducerIntialState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and with an action that the handler can handle', () => {
|
||||||
|
it('then the resulting state should correct', () => {
|
||||||
|
const payload = { n: 10, s: 'ten', b: false, o: { n: 20, s: 'twenty', b: true } };
|
||||||
|
const result = dummyReducer(undefined as DummyReducerState, dummyActionCreator(payload));
|
||||||
|
|
||||||
|
expect(result).toEqual(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when reducer is called with a state', () => {
|
||||||
|
describe('and with an action that the handler can not handle', () => {
|
||||||
|
it('then the resulting state should be intial state', () => {
|
||||||
|
const result = dummyReducer(dummyReducerIntialState, {} as ActionOf<any>);
|
||||||
|
|
||||||
|
expect(result).toEqual(dummyReducerIntialState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and with an action that the handler can handle', () => {
|
||||||
|
it('then the resulting state should correct', () => {
|
||||||
|
const payload = { n: 10, s: 'ten', b: false, o: { n: 20, s: 'twenty', b: true } };
|
||||||
|
const result = dummyReducer(dummyReducerIntialState, dummyActionCreator(payload));
|
||||||
|
|
||||||
|
expect(result).toEqual(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('given a handler is added', () => {
|
||||||
|
describe('when a handler with the same creator is added', () => {
|
||||||
|
it('then is should throw', () => {
|
||||||
|
const faultyReducer = reducerFactory(dummyReducerIntialState).addMapper({
|
||||||
|
filter: dummyActionCreator,
|
||||||
|
mapper: (state, action) => {
|
||||||
|
return { ...state, ...action.payload };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
faultyReducer.addMapper({
|
||||||
|
filter: dummyActionCreator,
|
||||||
|
mapper: state => {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
45
public/app/core/redux/reducerFactory.ts
Normal file
45
public/app/core/redux/reducerFactory.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { ActionOf, ActionCreator } from './actionCreatorFactory';
|
||||||
|
import { Reducer } from 'redux';
|
||||||
|
|
||||||
|
export type Mapper<State, Payload> = (state: State, action: ActionOf<Payload>) => State;
|
||||||
|
|
||||||
|
export interface MapperConfig<State, Payload> {
|
||||||
|
filter: ActionCreator<Payload>;
|
||||||
|
mapper: Mapper<State, Payload>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddMapper<State> {
|
||||||
|
addMapper: <Payload>(config: MapperConfig<State, Payload>) => CreateReducer<State>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReducer<State> extends AddMapper<State> {
|
||||||
|
create: () => Reducer<State, ActionOf<any>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducerFactory = <State>(initialState: State): AddMapper<State> => {
|
||||||
|
const allMappers: { [key: string]: Mapper<State, any> } = {};
|
||||||
|
|
||||||
|
const addMapper = <Payload>(config: MapperConfig<State, Payload>): CreateReducer<State> => {
|
||||||
|
if (allMappers[config.filter.type]) {
|
||||||
|
throw new Error(`There is already a mapper defined with the type ${config.filter.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
allMappers[config.filter.type] = config.mapper;
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = (): Reducer<State, ActionOf<any>> => (state: State = initialState, action: ActionOf<any>): State => {
|
||||||
|
const mapper = allMappers[action.type];
|
||||||
|
|
||||||
|
if (mapper) {
|
||||||
|
return mapper(state, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const instance: CreateReducer<State> = { addMapper, create };
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
};
|
@ -5,6 +5,7 @@ import { NavModel } from 'app/types';
|
|||||||
import { DataSourceSettings } from '@grafana/ui/src/types';
|
import { DataSourceSettings } from '@grafana/ui/src/types';
|
||||||
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
|
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||||
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
|
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
|
||||||
|
import { setDataSourcesSearchQuery, setDataSourcesLayoutMode } from './state/actions';
|
||||||
|
|
||||||
const setup = (propOverrides?: object) => {
|
const setup = (propOverrides?: object) => {
|
||||||
const props: Props = {
|
const props: Props = {
|
||||||
@ -13,16 +14,16 @@ const setup = (propOverrides?: object) => {
|
|||||||
loadDataSources: jest.fn(),
|
loadDataSources: jest.fn(),
|
||||||
navModel: {
|
navModel: {
|
||||||
main: {
|
main: {
|
||||||
text: 'Configuration'
|
text: 'Configuration',
|
||||||
},
|
},
|
||||||
node: {
|
node: {
|
||||||
text: 'Data Sources'
|
text: 'Data Sources',
|
||||||
}
|
},
|
||||||
} as NavModel,
|
} as NavModel,
|
||||||
dataSourcesCount: 0,
|
dataSourcesCount: 0,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
setDataSourcesSearchQuery: jest.fn(),
|
setDataSourcesSearchQuery,
|
||||||
setDataSourcesLayoutMode: jest.fn(),
|
setDataSourcesLayoutMode,
|
||||||
hasFetched: false,
|
hasFetched: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import { NavModel } from 'app/types';
|
|||||||
import { DataSourceSettings } from '@grafana/ui';
|
import { DataSourceSettings } from '@grafana/ui';
|
||||||
import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
|
import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
|
||||||
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
|
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
|
||||||
|
import { setDataSourceName, setIsDefault } from '../state/actions';
|
||||||
|
|
||||||
const setup = (propOverrides?: object) => {
|
const setup = (propOverrides?: object) => {
|
||||||
const props: Props = {
|
const props: Props = {
|
||||||
@ -14,9 +15,9 @@ const setup = (propOverrides?: object) => {
|
|||||||
pageId: 1,
|
pageId: 1,
|
||||||
deleteDataSource: jest.fn(),
|
deleteDataSource: jest.fn(),
|
||||||
loadDataSource: jest.fn(),
|
loadDataSource: jest.fn(),
|
||||||
setDataSourceName: jest.fn(),
|
setDataSourceName,
|
||||||
updateDataSource: jest.fn(),
|
updateDataSource: jest.fn(),
|
||||||
setIsDefault: jest.fn(),
|
setIsDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(props, propOverrides);
|
Object.assign(props, propOverrides);
|
||||||
|
@ -8,131 +8,36 @@ import { UpdateLocationAction } from 'app/core/actions/location';
|
|||||||
import { buildNavModel } from './navModel';
|
import { buildNavModel } from './navModel';
|
||||||
import { DataSourceSettings } from '@grafana/ui/src/types';
|
import { DataSourceSettings } from '@grafana/ui/src/types';
|
||||||
import { Plugin, StoreState } from 'app/types';
|
import { Plugin, StoreState } from 'app/types';
|
||||||
|
import { actionCreatorFactory } from 'app/core/redux';
|
||||||
|
import { ActionOf, noPayloadActionCreatorFactory } from 'app/core/redux/actionCreatorFactory';
|
||||||
|
|
||||||
export enum ActionTypes {
|
export const dataSourceLoaded = actionCreatorFactory<DataSourceSettings>('LOAD_DATA_SOURCE').create();
|
||||||
LoadDataSources = 'LOAD_DATA_SOURCES',
|
|
||||||
LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
|
|
||||||
LoadedDataSourceTypes = 'LOADED_DATA_SOURCE_TYPES',
|
|
||||||
LoadDataSource = 'LOAD_DATA_SOURCE',
|
|
||||||
LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
|
|
||||||
SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
|
|
||||||
SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
|
|
||||||
SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
|
|
||||||
SetDataSourceName = 'SET_DATA_SOURCE_NAME',
|
|
||||||
SetIsDefault = 'SET_IS_DEFAULT',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadDataSourcesAction {
|
export const dataSourcesLoaded = actionCreatorFactory<DataSourceSettings[]>('LOAD_DATA_SOURCES').create();
|
||||||
type: ActionTypes.LoadDataSources;
|
|
||||||
payload: DataSourceSettings[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetDataSourcesSearchQueryAction {
|
export const dataSourceMetaLoaded = actionCreatorFactory<Plugin>('LOAD_DATA_SOURCE_META').create();
|
||||||
type: ActionTypes.SetDataSourcesSearchQuery;
|
|
||||||
payload: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetDataSourcesLayoutModeAction {
|
export const dataSourceTypesLoad = noPayloadActionCreatorFactory('LOAD_DATA_SOURCE_TYPES').create();
|
||||||
type: ActionTypes.SetDataSourcesLayoutMode;
|
|
||||||
payload: LayoutMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadDataSourceTypesAction {
|
export const dataSourceTypesLoaded = actionCreatorFactory<Plugin[]>('LOADED_DATA_SOURCE_TYPES').create();
|
||||||
type: ActionTypes.LoadDataSourceTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadedDataSourceTypesAction {
|
export const setDataSourcesSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCES_SEARCH_QUERY').create();
|
||||||
type: ActionTypes.LoadedDataSourceTypes;
|
|
||||||
payload: Plugin[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetDataSourceTypeSearchQueryAction {
|
export const setDataSourcesLayoutMode = actionCreatorFactory<LayoutMode>('SET_DATA_SOURCES_LAYOUT_MODE').create();
|
||||||
type: ActionTypes.SetDataSourceTypeSearchQuery;
|
|
||||||
payload: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadDataSourceAction {
|
export const setDataSourceTypeSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCE_TYPE_SEARCH_QUERY').create();
|
||||||
type: ActionTypes.LoadDataSource;
|
|
||||||
payload: DataSourceSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadDataSourceMetaAction {
|
export const setDataSourceName = actionCreatorFactory<string>('SET_DATA_SOURCE_NAME').create();
|
||||||
type: ActionTypes.LoadDataSourceMeta;
|
|
||||||
payload: Plugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetDataSourceNameAction {
|
export const setIsDefault = actionCreatorFactory<boolean>('SET_IS_DEFAULT').create();
|
||||||
type: ActionTypes.SetDataSourceName;
|
|
||||||
payload: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetIsDefaultAction {
|
|
||||||
type: ActionTypes.SetIsDefault;
|
|
||||||
payload: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataSourcesLoaded = (dataSources: DataSourceSettings[]): LoadDataSourcesAction => ({
|
|
||||||
type: ActionTypes.LoadDataSources,
|
|
||||||
payload: dataSources,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataSourceLoaded = (dataSource: DataSourceSettings): LoadDataSourceAction => ({
|
|
||||||
type: ActionTypes.LoadDataSource,
|
|
||||||
payload: dataSource,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction => ({
|
|
||||||
type: ActionTypes.LoadDataSourceMeta,
|
|
||||||
payload: dataSourceMeta,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataSourceTypesLoad = (): LoadDataSourceTypesAction => ({
|
|
||||||
type: ActionTypes.LoadDataSourceTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadedDataSourceTypesAction => ({
|
|
||||||
type: ActionTypes.LoadedDataSourceTypes,
|
|
||||||
payload: dataSourceTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setDataSourcesSearchQuery = (searchQuery: string): SetDataSourcesSearchQueryAction => ({
|
|
||||||
type: ActionTypes.SetDataSourcesSearchQuery,
|
|
||||||
payload: searchQuery,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setDataSourcesLayoutMode = (layoutMode: LayoutMode): SetDataSourcesLayoutModeAction => ({
|
|
||||||
type: ActionTypes.SetDataSourcesLayoutMode,
|
|
||||||
payload: layoutMode,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setDataSourceTypeSearchQuery = (query: string): SetDataSourceTypeSearchQueryAction => ({
|
|
||||||
type: ActionTypes.SetDataSourceTypeSearchQuery,
|
|
||||||
payload: query,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setDataSourceName = (name: string) => ({
|
|
||||||
type: ActionTypes.SetDataSourceName,
|
|
||||||
payload: name,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setIsDefault = (state: boolean) => ({
|
|
||||||
type: ActionTypes.SetIsDefault,
|
|
||||||
payload: state,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
| LoadDataSourcesAction
|
|
||||||
| SetDataSourcesSearchQueryAction
|
|
||||||
| SetDataSourcesLayoutModeAction
|
|
||||||
| UpdateLocationAction
|
| UpdateLocationAction
|
||||||
| LoadDataSourceTypesAction
|
|
||||||
| LoadedDataSourceTypesAction
|
|
||||||
| SetDataSourceTypeSearchQueryAction
|
|
||||||
| LoadDataSourceAction
|
|
||||||
| UpdateNavIndexAction
|
| UpdateNavIndexAction
|
||||||
| LoadDataSourceMetaAction
|
| ActionOf<DataSourceSettings>
|
||||||
| SetDataSourceNameAction
|
| ActionOf<DataSourceSettings[]>
|
||||||
| SetIsDefaultAction;
|
| ActionOf<Plugin>
|
||||||
|
| ActionOf<Plugin[]>;
|
||||||
|
|
||||||
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
|
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
|
||||||
|
|
||||||
|
137
public/app/features/datasources/state/reducers.test.ts
Normal file
137
public/app/features/datasources/state/reducers.test.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { reducerTester } from 'test/core/redux/reducerTester';
|
||||||
|
import { dataSourcesReducer, initialState } from './reducers';
|
||||||
|
import {
|
||||||
|
dataSourcesLoaded,
|
||||||
|
dataSourceLoaded,
|
||||||
|
setDataSourcesSearchQuery,
|
||||||
|
setDataSourcesLayoutMode,
|
||||||
|
dataSourceTypesLoad,
|
||||||
|
dataSourceTypesLoaded,
|
||||||
|
setDataSourceTypeSearchQuery,
|
||||||
|
dataSourceMetaLoaded,
|
||||||
|
setDataSourceName,
|
||||||
|
setIsDefault,
|
||||||
|
} from './actions';
|
||||||
|
import { getMockDataSources, getMockDataSource } from '../__mocks__/dataSourcesMocks';
|
||||||
|
import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
|
||||||
|
import { DataSourcesState } from 'app/types';
|
||||||
|
import { PluginMetaInfo } from '@grafana/ui';
|
||||||
|
|
||||||
|
const mockPlugin = () => ({
|
||||||
|
defaultNavUrl: 'defaultNavUrl',
|
||||||
|
enabled: true,
|
||||||
|
hasUpdate: true,
|
||||||
|
id: 'id',
|
||||||
|
info: {} as PluginMetaInfo,
|
||||||
|
latestVersion: 'latestVersion',
|
||||||
|
name: 'name',
|
||||||
|
pinned: true,
|
||||||
|
state: 'state',
|
||||||
|
type: 'type',
|
||||||
|
module: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dataSourcesReducer', () => {
|
||||||
|
describe('when dataSourcesLoaded is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const dataSources = getMockDataSources(0);
|
||||||
|
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, initialState)
|
||||||
|
.whenActionIsDispatched(dataSourcesLoaded(dataSources))
|
||||||
|
.thenStateShouldEqual({ ...initialState, hasFetched: true, dataSources, dataSourcesCount: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when dataSourceLoaded is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const dataSource = getMockDataSource();
|
||||||
|
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, initialState)
|
||||||
|
.whenActionIsDispatched(dataSourceLoaded(dataSource))
|
||||||
|
.thenStateShouldEqual({ ...initialState, dataSource });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when setDataSourcesSearchQuery is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, initialState)
|
||||||
|
.whenActionIsDispatched(setDataSourcesSearchQuery('some query'))
|
||||||
|
.thenStateShouldEqual({ ...initialState, searchQuery: 'some query' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when setDataSourcesLayoutMode is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const layoutMode: LayoutModes = LayoutModes.Grid;
|
||||||
|
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, initialState)
|
||||||
|
.whenActionIsDispatched(setDataSourcesLayoutMode(layoutMode))
|
||||||
|
.thenStateShouldEqual({ ...initialState, layoutMode: LayoutModes.Grid });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when dataSourceTypesLoad is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const state: DataSourcesState = { ...initialState, dataSourceTypes: [mockPlugin()] };
|
||||||
|
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, state)
|
||||||
|
.whenActionIsDispatched(dataSourceTypesLoad())
|
||||||
|
.thenStateShouldEqual({ ...initialState, dataSourceTypes: [], isLoadingDataSources: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when dataSourceTypesLoaded is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const dataSourceTypes = [mockPlugin()];
|
||||||
|
const state: DataSourcesState = { ...initialState, isLoadingDataSources: true };
|
||||||
|
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, state)
|
||||||
|
.whenActionIsDispatched(dataSourceTypesLoaded(dataSourceTypes))
|
||||||
|
.thenStateShouldEqual({ ...initialState, dataSourceTypes, isLoadingDataSources: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when setDataSourceTypeSearchQuery is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, initialState)
|
||||||
|
.whenActionIsDispatched(setDataSourceTypeSearchQuery('type search query'))
|
||||||
|
.thenStateShouldEqual({ ...initialState, dataSourceTypeSearchQuery: 'type search query' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when dataSourceMetaLoaded is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const dataSourceMeta = mockPlugin();
|
||||||
|
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, initialState)
|
||||||
|
.whenActionIsDispatched(dataSourceMetaLoaded(dataSourceMeta))
|
||||||
|
.thenStateShouldEqual({ ...initialState, dataSourceMeta });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when setDataSourceName is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, initialState)
|
||||||
|
.whenActionIsDispatched(setDataSourceName('some name'))
|
||||||
|
.thenStateShouldEqual({ ...initialState, dataSource: { name: 'some name' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when setIsDefault is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(dataSourcesReducer, initialState)
|
||||||
|
.whenActionIsDispatched(setIsDefault(true))
|
||||||
|
.thenStateShouldEqual({ ...initialState, dataSource: { isDefault: true } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,56 +1,87 @@
|
|||||||
import { DataSourcesState, Plugin } from 'app/types';
|
import { DataSourcesState, Plugin } from 'app/types';
|
||||||
import { DataSourceSettings } from '@grafana/ui/src/types';
|
import { DataSourceSettings } from '@grafana/ui/src/types';
|
||||||
import { Action, ActionTypes } from './actions';
|
import {
|
||||||
|
dataSourceLoaded,
|
||||||
|
dataSourcesLoaded,
|
||||||
|
setDataSourcesSearchQuery,
|
||||||
|
setDataSourcesLayoutMode,
|
||||||
|
dataSourceTypesLoad,
|
||||||
|
dataSourceTypesLoaded,
|
||||||
|
setDataSourceTypeSearchQuery,
|
||||||
|
dataSourceMetaLoaded,
|
||||||
|
setDataSourceName,
|
||||||
|
setIsDefault,
|
||||||
|
} from './actions';
|
||||||
import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
|
import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
|
||||||
|
import { reducerFactory } from 'app/core/redux';
|
||||||
|
|
||||||
const initialState: DataSourcesState = {
|
export const initialState: DataSourcesState = {
|
||||||
dataSources: [] as DataSourceSettings[],
|
dataSources: [],
|
||||||
dataSource: {} as DataSourceSettings,
|
dataSource: {} as DataSourceSettings,
|
||||||
layoutMode: LayoutModes.List,
|
layoutMode: LayoutModes.List,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
dataSourcesCount: 0,
|
dataSourcesCount: 0,
|
||||||
dataSourceTypes: [] as Plugin[],
|
dataSourceTypes: [],
|
||||||
dataSourceTypeSearchQuery: '',
|
dataSourceTypeSearchQuery: '',
|
||||||
hasFetched: false,
|
hasFetched: false,
|
||||||
isLoadingDataSources: false,
|
isLoadingDataSources: false,
|
||||||
dataSourceMeta: {} as Plugin,
|
dataSourceMeta: {} as Plugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
|
export const dataSourcesReducer = reducerFactory(initialState)
|
||||||
switch (action.type) {
|
.addMapper({
|
||||||
case ActionTypes.LoadDataSources:
|
filter: dataSourcesLoaded,
|
||||||
return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
|
mapper: (state, action) => ({
|
||||||
|
...state,
|
||||||
case ActionTypes.LoadDataSource:
|
hasFetched: true,
|
||||||
return { ...state, dataSource: action.payload };
|
dataSources: action.payload,
|
||||||
|
dataSourcesCount: action.payload.length,
|
||||||
case ActionTypes.SetDataSourcesSearchQuery:
|
}),
|
||||||
return { ...state, searchQuery: action.payload };
|
})
|
||||||
|
.addMapper({
|
||||||
case ActionTypes.SetDataSourcesLayoutMode:
|
filter: dataSourceLoaded,
|
||||||
return { ...state, layoutMode: action.payload };
|
mapper: (state, action) => ({ ...state, dataSource: action.payload }),
|
||||||
|
})
|
||||||
case ActionTypes.LoadDataSourceTypes:
|
.addMapper({
|
||||||
return { ...state, dataSourceTypes: [], isLoadingDataSources: true };
|
filter: setDataSourcesSearchQuery,
|
||||||
|
mapper: (state, action) => ({ ...state, searchQuery: action.payload }),
|
||||||
case ActionTypes.LoadedDataSourceTypes:
|
})
|
||||||
return { ...state, dataSourceTypes: action.payload, isLoadingDataSources: false };
|
.addMapper({
|
||||||
|
filter: setDataSourcesLayoutMode,
|
||||||
case ActionTypes.SetDataSourceTypeSearchQuery:
|
mapper: (state, action) => ({ ...state, layoutMode: action.payload }),
|
||||||
return { ...state, dataSourceTypeSearchQuery: action.payload };
|
})
|
||||||
|
.addMapper({
|
||||||
case ActionTypes.LoadDataSourceMeta:
|
filter: dataSourceTypesLoad,
|
||||||
return { ...state, dataSourceMeta: action.payload };
|
mapper: state => ({ ...state, dataSourceTypes: [], isLoadingDataSources: true }),
|
||||||
|
})
|
||||||
case ActionTypes.SetDataSourceName:
|
.addMapper({
|
||||||
return { ...state, dataSource: { ...state.dataSource, name: action.payload } };
|
filter: dataSourceTypesLoaded,
|
||||||
|
mapper: (state, action) => ({
|
||||||
case ActionTypes.SetIsDefault:
|
...state,
|
||||||
return { ...state, dataSource: { ...state.dataSource, isDefault: action.payload } };
|
dataSourceTypes: action.payload,
|
||||||
}
|
isLoadingDataSources: false,
|
||||||
|
}),
|
||||||
return state;
|
})
|
||||||
};
|
.addMapper({
|
||||||
|
filter: setDataSourceTypeSearchQuery,
|
||||||
|
mapper: (state, action) => ({ ...state, dataSourceTypeSearchQuery: action.payload }),
|
||||||
|
})
|
||||||
|
.addMapper({
|
||||||
|
filter: dataSourceMetaLoaded,
|
||||||
|
mapper: (state, action) => ({ ...state, dataSourceMeta: action.payload }),
|
||||||
|
})
|
||||||
|
.addMapper({
|
||||||
|
filter: setDataSourceName,
|
||||||
|
mapper: (state, action) => ({ ...state, dataSource: { ...state.dataSource, name: action.payload } }),
|
||||||
|
})
|
||||||
|
.addMapper({
|
||||||
|
filter: setIsDefault,
|
||||||
|
mapper: (state, action) => ({
|
||||||
|
...state,
|
||||||
|
dataSource: { ...state.dataSource, isDefault: action.payload },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.create();
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
dataSources: dataSourcesReducer,
|
dataSources: dataSourcesReducer,
|
||||||
|
56
public/test/core/redux/reducerTester.test.ts
Normal file
56
public/test/core/redux/reducerTester.test.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { reducerFactory, actionCreatorFactory } from 'app/core/redux';
|
||||||
|
import { reducerTester } from './reducerTester';
|
||||||
|
|
||||||
|
interface DummyState {
|
||||||
|
data: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: DummyState = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dummyAction = actionCreatorFactory<string>('dummyAction').create();
|
||||||
|
|
||||||
|
const mutatingReducer = reducerFactory(initialState)
|
||||||
|
.addMapper({
|
||||||
|
filter: dummyAction,
|
||||||
|
mapper: (state, action) => {
|
||||||
|
state.data.push(action.payload);
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.create();
|
||||||
|
|
||||||
|
const okReducer = reducerFactory(initialState)
|
||||||
|
.addMapper({
|
||||||
|
filter: dummyAction,
|
||||||
|
mapper: (state, action) => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
data: state.data.concat(action.payload),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.create();
|
||||||
|
|
||||||
|
describe('reducerTester', () => {
|
||||||
|
describe('when reducer mutates state', () => {
|
||||||
|
it('then it should throw', () => {
|
||||||
|
expect(() => {
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(mutatingReducer, initialState)
|
||||||
|
.whenActionIsDispatched(dummyAction('some string'));
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when reducer does not mutate state', () => {
|
||||||
|
it('then it should not throw', () => {
|
||||||
|
expect(() => {
|
||||||
|
reducerTester()
|
||||||
|
.givenReducer(okReducer, initialState)
|
||||||
|
.whenActionIsDispatched(dummyAction('some string'));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
79
public/test/core/redux/reducerTester.ts
Normal file
79
public/test/core/redux/reducerTester.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface When<State> {
|
||||||
|
whenActionIsDispatched: (action: ActionOf<any>) => Then<State>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Then<State> {
|
||||||
|
thenStateShouldEqual: (state: State) => Then<State>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectType extends Object {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepFreeze = <T>(obj: T): T => {
|
||||||
|
Object.freeze(obj);
|
||||||
|
|
||||||
|
const isNotException = (object: any, propertyName: any) =>
|
||||||
|
typeof object === 'function'
|
||||||
|
? propertyName !== 'caller' && propertyName !== 'callee' && propertyName !== 'arguments'
|
||||||
|
: true;
|
||||||
|
const hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
|
||||||
|
if (obj && obj instanceof Object) {
|
||||||
|
const object: ObjectType = obj;
|
||||||
|
Object.getOwnPropertyNames(object).forEach(propertyName => {
|
||||||
|
const objectProperty: any = object[propertyName];
|
||||||
|
if (
|
||||||
|
hasOwnProp.call(object, propertyName) &&
|
||||||
|
isNotException(object, propertyName) &&
|
||||||
|
objectProperty &&
|
||||||
|
(typeof objectProperty === 'object' || typeof objectProperty === 'function') &&
|
||||||
|
Object.isFrozen(objectProperty) === false
|
||||||
|
) {
|
||||||
|
deepFreeze(objectProperty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReducerTester<State> extends Given<State>, When<State>, Then<State> {}
|
||||||
|
|
||||||
|
export const reducerTester = <State>(): Given<State> => {
|
||||||
|
let reducerUnderTest: Reducer<State, ActionOf<any>> = null;
|
||||||
|
let resultingState: State = null;
|
||||||
|
let initialState: State = null;
|
||||||
|
|
||||||
|
const givenReducer = (reducer: Reducer<State, ActionOf<any>>, state: State): When<State> => {
|
||||||
|
reducerUnderTest = reducer;
|
||||||
|
initialState = { ...state };
|
||||||
|
initialState = deepFreeze(initialState);
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const whenActionIsDispatched = (action: ActionOf<any>): Then<State> => {
|
||||||
|
resultingState = reducerUnderTest(initialState, action);
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const thenStateShouldEqual = (state: State): Then<State> => {
|
||||||
|
expect(state).toEqual(resultingState);
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const instance: ReducerTester<State> = { thenStateShouldEqual, givenReducer, whenActionIsDispatched };
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user