diff --git a/public/app/core/redux/actionCreatorFactory.test.ts b/public/app/core/redux/actionCreatorFactory.test.ts new file mode 100644 index 00000000000..274079311b3 --- /dev/null +++ b/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').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('nOpAyLoAd').create(); + }).toThrow(); + }); + }); +}); diff --git a/public/app/core/redux/actionCreatorFactory.ts b/public/app/core/redux/actionCreatorFactory.ts new file mode 100644 index 00000000000..d6477144df4 --- /dev/null +++ b/public/app/core/redux/actionCreatorFactory.ts @@ -0,0 +1,57 @@ +import { Action } from 'redux'; + +const allActionCreators: string[] = []; + +export interface ActionOf extends Action { + readonly type: string; + readonly payload: Payload; +} + +export interface ActionCreator { + readonly type: string; + (payload: Payload): ActionOf; +} + +export interface NoPayloadActionCreator { + readonly type: string; + (): ActionOf; +} + +export interface ActionCreatorFactory { + create: () => ActionCreator; +} + +export interface NoPayloadActionCreatorFactory { + create: () => NoPayloadActionCreator; +} + +export const actionCreatorFactory = (type: string): ActionCreatorFactory => { + const create = (): ActionCreator => { + return Object.assign((payload: Payload): ActionOf => ({ 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 => ({ 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); diff --git a/public/app/core/redux/index.ts b/public/app/core/redux/index.ts new file mode 100644 index 00000000000..359f160b9ce --- /dev/null +++ b/public/app/core/redux/index.ts @@ -0,0 +1,4 @@ +import { actionCreatorFactory } from './actionCreatorFactory'; +import { reducerFactory } from './reducerFactory'; + +export { actionCreatorFactory, reducerFactory }; diff --git a/public/app/core/redux/reducerFactory.test.ts b/public/app/core/redux/reducerFactory.test.ts new file mode 100644 index 00000000000..48cffc1ca7a --- /dev/null +++ b/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('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); + + 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); + + 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(); + }); + }); + }); +}); diff --git a/public/app/core/redux/reducerFactory.ts b/public/app/core/redux/reducerFactory.ts new file mode 100644 index 00000000000..bfa8e67bd4c --- /dev/null +++ b/public/app/core/redux/reducerFactory.ts @@ -0,0 +1,45 @@ +import { ActionOf, ActionCreator } from './actionCreatorFactory'; +import { Reducer } from 'redux'; + +export type Mapper = (state: State, action: ActionOf) => State; + +export interface MapperConfig { + filter: ActionCreator; + mapper: Mapper; +} + +export interface AddMapper { + addMapper: (config: MapperConfig) => CreateReducer; +} + +export interface CreateReducer extends AddMapper { + create: () => Reducer>; +} + +export const reducerFactory = (initialState: State): AddMapper => { + const allMappers: { [key: string]: Mapper } = {}; + + const addMapper = (config: MapperConfig): CreateReducer => { + 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: State = initialState, action: ActionOf): State => { + const mapper = allMappers[action.type]; + + if (mapper) { + return mapper(state, action); + } + + return state; + }; + + const instance: CreateReducer = { addMapper, create }; + + return instance; +}; diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx index 44ef7a1cc49..65077201f65 100644 --- a/public/app/features/datasources/DataSourcesListPage.test.tsx +++ b/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, }; diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx index 8efc92be5be..204eeb8b1e9 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx +++ b/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); diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 0fa260ffafa..2e21b3066d1 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/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('LOAD_DATA_SOURCE').create(); -interface LoadDataSourcesAction { - type: ActionTypes.LoadDataSources; - payload: DataSourceSettings[]; -} +export const dataSourcesLoaded = actionCreatorFactory('LOAD_DATA_SOURCES').create(); -interface SetDataSourcesSearchQueryAction { - type: ActionTypes.SetDataSourcesSearchQuery; - payload: string; -} +export const dataSourceMetaLoaded = actionCreatorFactory('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('LOADED_DATA_SOURCE_TYPES').create(); -interface LoadedDataSourceTypesAction { - type: ActionTypes.LoadedDataSourceTypes; - payload: Plugin[]; -} +export const setDataSourcesSearchQuery = actionCreatorFactory('SET_DATA_SOURCES_SEARCH_QUERY').create(); -interface SetDataSourceTypeSearchQueryAction { - type: ActionTypes.SetDataSourceTypeSearchQuery; - payload: string; -} +export const setDataSourcesLayoutMode = actionCreatorFactory('SET_DATA_SOURCES_LAYOUT_MODE').create(); -interface LoadDataSourceAction { - type: ActionTypes.LoadDataSource; - payload: DataSourceSettings; -} +export const setDataSourceTypeSearchQuery = actionCreatorFactory('SET_DATA_SOURCE_TYPE_SEARCH_QUERY').create(); -interface LoadDataSourceMetaAction { - type: ActionTypes.LoadDataSourceMeta; - payload: Plugin; -} +export const setDataSourceName = actionCreatorFactory('SET_DATA_SOURCE_NAME').create(); -interface SetDataSourceNameAction { - 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 const setIsDefault = actionCreatorFactory('SET_IS_DEFAULT').create(); export type Action = - | LoadDataSourcesAction - | SetDataSourcesSearchQueryAction - | SetDataSourcesLayoutModeAction | UpdateLocationAction - | LoadDataSourceTypesAction - | LoadedDataSourceTypesAction - | SetDataSourceTypeSearchQueryAction - | LoadDataSourceAction | UpdateNavIndexAction - | LoadDataSourceMetaAction - | SetDataSourceNameAction - | SetIsDefaultAction; + | ActionOf + | ActionOf + | ActionOf + | ActionOf; type ThunkResult = ThunkAction; diff --git a/public/app/features/datasources/state/reducers.test.ts b/public/app/features/datasources/state/reducers.test.ts new file mode 100644 index 00000000000..540089f3e65 --- /dev/null +++ b/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 } }); + }); + }); +}); diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 66151990aea..451e2d4650c 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/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, diff --git a/public/test/core/redux/reducerTester.test.ts b/public/test/core/redux/reducerTester.test.ts new file mode 100644 index 00000000000..63d163647fc --- /dev/null +++ b/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('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(); + }); + }); +}); diff --git a/public/test/core/redux/reducerTester.ts b/public/test/core/redux/reducerTester.ts new file mode 100644 index 00000000000..f8d8c921767 --- /dev/null +++ b/public/test/core/redux/reducerTester.ts @@ -0,0 +1,79 @@ +import { Reducer } from 'redux'; + +import { ActionOf } from 'app/core/redux/actionCreatorFactory'; + +export interface Given { + givenReducer: (reducer: Reducer>, state: State) => When; +} + +export interface When { + whenActionIsDispatched: (action: ActionOf) => Then; +} + +export interface Then { + thenStateShouldEqual: (state: State) => Then; +} + +interface ObjectType extends Object { + [key: string]: any; +} + +const deepFreeze = (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 extends Given, When, Then {} + +export const reducerTester = (): Given => { + let reducerUnderTest: Reducer> = null; + let resultingState: State = null; + let initialState: State = null; + + const givenReducer = (reducer: Reducer>, state: State): When => { + reducerUnderTest = reducer; + initialState = { ...state }; + initialState = deepFreeze(initialState); + + return instance; + }; + + const whenActionIsDispatched = (action: ActionOf): Then => { + resultingState = reducerUnderTest(initialState, action); + + return instance; + }; + + const thenStateShouldEqual = (state: State): Then => { + expect(state).toEqual(resultingState); + + return instance; + }; + + const instance: ReducerTester = { thenStateShouldEqual, givenReducer, whenActionIsDispatched }; + + return instance; +};