Jelajahi Sumber

Merge pull request #15158 from grafana/hugoh/redux-poc

WIP: Reducing boilerplate code for Redux
Torkel Ödegaard 6 tahun lalu
induk
melakukan
3d78cb4f8c

+ 83 - 0
public/app/core/redux/actionCreatorFactory.test.ts

@@ -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 - 0
public/app/core/redux/actionCreatorFactory.ts

@@ -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 - 0
public/app/core/redux/index.ts

@@ -0,0 +1,4 @@
+import { actionCreatorFactory } from './actionCreatorFactory';
+import { reducerFactory } from './reducerFactory';
+
+export { actionCreatorFactory, reducerFactory };

+ 97 - 0
public/app/core/redux/reducerFactory.test.ts

@@ -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 - 0
public/app/core/redux/reducerFactory.ts

@@ -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;
+};

+ 6 - 5
public/app/features/datasources/DataSourcesListPage.test.tsx

@@ -5,6 +5,7 @@ import { NavModel } from 'app/types';
 import { DataSourceSettings } from '@grafana/ui/src/types';
 import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
 import { getMockDataSources } from './__mocks__/dataSourcesMocks';
+import { setDataSourcesSearchQuery, setDataSourcesLayoutMode } from './state/actions';
 
 const setup = (propOverrides?: object) => {
   const props: Props = {
@@ -13,16 +14,16 @@ const setup = (propOverrides?: object) => {
     loadDataSources: jest.fn(),
     navModel: {
       main: {
-        text: 'Configuration'
+        text: 'Configuration',
       },
       node: {
-        text: 'Data Sources'
-      }
+        text: 'Data Sources',
+      },
     } as NavModel,
     dataSourcesCount: 0,
     searchQuery: '',
-    setDataSourcesSearchQuery: jest.fn(),
-    setDataSourcesLayoutMode: jest.fn(),
+    setDataSourcesSearchQuery,
+    setDataSourcesLayoutMode,
     hasFetched: false,
   };
 

+ 3 - 2
public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx

@@ -5,6 +5,7 @@ import { NavModel } from 'app/types';
 import { DataSourceSettings } from '@grafana/ui';
 import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
 import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
+import { setDataSourceName, setIsDefault } from '../state/actions';
 
 const setup = (propOverrides?: object) => {
   const props: Props = {
@@ -14,9 +15,9 @@ const setup = (propOverrides?: object) => {
     pageId: 1,
     deleteDataSource: jest.fn(),
     loadDataSource: jest.fn(),
-    setDataSourceName: jest.fn(),
+    setDataSourceName,
     updateDataSource: jest.fn(),
-    setIsDefault: jest.fn(),
+    setIsDefault,
   };
 
   Object.assign(props, propOverrides);

+ 16 - 111
public/app/features/datasources/state/actions.ts

@@ -8,131 +8,36 @@ import { UpdateLocationAction } from 'app/core/actions/location';
 import { buildNavModel } from './navModel';
 import { DataSourceSettings } from '@grafana/ui/src/types';
 import { Plugin, StoreState } from 'app/types';
+import { actionCreatorFactory } from 'app/core/redux';
+import { ActionOf, noPayloadActionCreatorFactory } from 'app/core/redux/actionCreatorFactory';
 
-export enum ActionTypes {
-  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',
-}
+export const dataSourceLoaded = actionCreatorFactory<DataSourceSettings>('LOAD_DATA_SOURCE').create();
 
-interface LoadDataSourcesAction {
-  type: ActionTypes.LoadDataSources;
-  payload: DataSourceSettings[];
-}
+export const dataSourcesLoaded = actionCreatorFactory<DataSourceSettings[]>('LOAD_DATA_SOURCES').create();
 
-interface SetDataSourcesSearchQueryAction {
-  type: ActionTypes.SetDataSourcesSearchQuery;
-  payload: string;
-}
+export const dataSourceMetaLoaded = actionCreatorFactory<Plugin>('LOAD_DATA_SOURCE_META').create();
 
-interface SetDataSourcesLayoutModeAction {
-  type: ActionTypes.SetDataSourcesLayoutMode;
-  payload: LayoutMode;
-}
+export const dataSourceTypesLoad = noPayloadActionCreatorFactory('LOAD_DATA_SOURCE_TYPES').create();
 
-interface LoadDataSourceTypesAction {
-  type: ActionTypes.LoadDataSourceTypes;
-}
+export const dataSourceTypesLoaded = actionCreatorFactory<Plugin[]>('LOADED_DATA_SOURCE_TYPES').create();
 
-interface LoadedDataSourceTypesAction {
-  type: ActionTypes.LoadedDataSourceTypes;
-  payload: Plugin[];
-}
+export const setDataSourcesSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCES_SEARCH_QUERY').create();
 
-interface SetDataSourceTypeSearchQueryAction {
-  type: ActionTypes.SetDataSourceTypeSearchQuery;
-  payload: string;
-}
+export const setDataSourcesLayoutMode = actionCreatorFactory<LayoutMode>('SET_DATA_SOURCES_LAYOUT_MODE').create();
 
-interface LoadDataSourceAction {
-  type: ActionTypes.LoadDataSource;
-  payload: DataSourceSettings;
-}
-
-interface LoadDataSourceMetaAction {
-  type: ActionTypes.LoadDataSourceMeta;
-  payload: Plugin;
-}
+export const setDataSourceTypeSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCE_TYPE_SEARCH_QUERY').create();
 
-interface SetDataSourceNameAction {
-  type: ActionTypes.SetDataSourceName;
-  payload: string;
-}
-
-interface SetIsDefaultAction {
-  type: ActionTypes.SetIsDefault;
-  payload: boolean;
-}
+export const setDataSourceName = actionCreatorFactory<string>('SET_DATA_SOURCE_NAME').create();
 
-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 const setIsDefault = actionCreatorFactory<boolean>('SET_IS_DEFAULT').create();
 
 export type Action =
-  | LoadDataSourcesAction
-  | SetDataSourcesSearchQueryAction
-  | SetDataSourcesLayoutModeAction
   | UpdateLocationAction
-  | LoadDataSourceTypesAction
-  | LoadedDataSourceTypesAction
-  | SetDataSourceTypeSearchQueryAction
-  | LoadDataSourceAction
   | UpdateNavIndexAction
-  | LoadDataSourceMetaAction
-  | SetDataSourceNameAction
-  | SetIsDefaultAction;
+  | ActionOf<DataSourceSettings>
+  | ActionOf<DataSourceSettings[]>
+  | ActionOf<Plugin>
+  | ActionOf<Plugin[]>;
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 

+ 137 - 0
public/app/features/datasources/state/reducers.test.ts

@@ -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 } });
+    });
+  });
+});

+ 70 - 39
public/app/features/datasources/state/reducers.ts

@@ -1,56 +1,87 @@
 import { DataSourcesState, Plugin } from 'app/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 { reducerFactory } from 'app/core/redux';
 
-const initialState: DataSourcesState = {
-  dataSources: [] as DataSourceSettings[],
+export const initialState: DataSourcesState = {
+  dataSources: [],
   dataSource: {} as DataSourceSettings,
   layoutMode: LayoutModes.List,
   searchQuery: '',
   dataSourcesCount: 0,
-  dataSourceTypes: [] as Plugin[],
+  dataSourceTypes: [],
   dataSourceTypeSearchQuery: '',
   hasFetched: false,
   isLoadingDataSources: false,
   dataSourceMeta: {} as Plugin,
 };
 
-export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
-  switch (action.type) {
-    case ActionTypes.LoadDataSources:
-      return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
-
-    case ActionTypes.LoadDataSource:
-      return { ...state, dataSource: action.payload };
-
-    case ActionTypes.SetDataSourcesSearchQuery:
-      return { ...state, searchQuery: action.payload };
-
-    case ActionTypes.SetDataSourcesLayoutMode:
-      return { ...state, layoutMode: action.payload };
-
-    case ActionTypes.LoadDataSourceTypes:
-      return { ...state, dataSourceTypes: [], isLoadingDataSources: true };
-
-    case ActionTypes.LoadedDataSourceTypes:
-      return { ...state, dataSourceTypes: action.payload, isLoadingDataSources: false };
-
-    case ActionTypes.SetDataSourceTypeSearchQuery:
-      return { ...state, dataSourceTypeSearchQuery: action.payload };
-
-    case ActionTypes.LoadDataSourceMeta:
-      return { ...state, dataSourceMeta: action.payload };
-
-    case ActionTypes.SetDataSourceName:
-      return { ...state, dataSource: { ...state.dataSource, name: action.payload } };
-
-    case ActionTypes.SetIsDefault:
-      return { ...state, dataSource: { ...state.dataSource, isDefault: action.payload } };
-  }
-
-  return state;
-};
+export const dataSourcesReducer = reducerFactory(initialState)
+  .addMapper({
+    filter: dataSourcesLoaded,
+    mapper: (state, action) => ({
+      ...state,
+      hasFetched: true,
+      dataSources: action.payload,
+      dataSourcesCount: action.payload.length,
+    }),
+  })
+  .addMapper({
+    filter: dataSourceLoaded,
+    mapper: (state, action) => ({ ...state, dataSource: action.payload }),
+  })
+  .addMapper({
+    filter: setDataSourcesSearchQuery,
+    mapper: (state, action) => ({ ...state, searchQuery: action.payload }),
+  })
+  .addMapper({
+    filter: setDataSourcesLayoutMode,
+    mapper: (state, action) => ({ ...state, layoutMode: action.payload }),
+  })
+  .addMapper({
+    filter: dataSourceTypesLoad,
+    mapper: state => ({ ...state, dataSourceTypes: [], isLoadingDataSources: true }),
+  })
+  .addMapper({
+    filter: dataSourceTypesLoaded,
+    mapper: (state, action) => ({
+      ...state,
+      dataSourceTypes: action.payload,
+      isLoadingDataSources: false,
+    }),
+  })
+  .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 {
   dataSources: dataSourcesReducer,

+ 56 - 0
public/test/core/redux/reducerTester.test.ts

@@ -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 - 0
public/test/core/redux/reducerTester.ts

@@ -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;
+};