From 4f0fa776be32beda9715fc98ef3721c302743495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 13 Jan 2020 08:03:22 +0100 Subject: [PATCH] Chore: Migrates reducers and actions to Redux Toolkit (#21287) * Refactor: Adds Redux Toolkit package * Refactor: Uses configureStore from Redux Toolkit * Refactor: Migrates applicationReducer * Refactor: Migrates appNotificationsReducer * Refactor: Migrates locationReducer * Refactor: Migrates navModelReducer * Refactor: Migrates teamsReducer and teamReducer * Refactor: Migrates cleanUpAction * Refactor: Migrates alertRulesReducer * Refactor: Cleans up recursiveCleanState * Refactor: Switched to Angular compatible reducers * Refactor: Migrates folderReducer * Refactor: Migrates dashboardReducer * Migrates panelEditorReducer * Refactor: Migrates dataSourcesReducer * Refactor: Migrates usersReducer * Refactor: Migrates organizationReducer * Refactor: Migrates pluginsReducer * Refactor: Migrates ldapReducer and ldapUserReducer * Refactor: Migrates apiKeysReducer * Refactor: Migrates exploreReducer and itemReducer * Refactor: Removes actionCreatorFactory and reducerFactory * Refactor: Moves mocks to test section * Docs: Removes sections about home grown framework * Update contribute/style-guides/redux.md Co-Authored-By: Diana Payton <52059945+oddlittlebird@users.noreply.github.com> * Refactor: Cleans up some code * Refactor: Adds state typings * Refactor: Cleans up typings * Refactor: Adds comment about ImmerJs autoFreeze Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com> --- contribute/style-guides/redux.md | 118 +-- package.json | 1 + public/app/core/actions/appNotification.ts | 28 - public/app/core/actions/application.ts | 3 - public/app/core/actions/cleanUp.ts | 11 +- public/app/core/actions/index.ts | 8 +- public/app/core/actions/location.ts | 4 - public/app/core/actions/navModel.ts | 17 - public/app/core/middlewares/application.ts | 10 +- .../app/core/reducers/appNotification.test.ts | 80 +- public/app/core/reducers/appNotification.ts | 32 +- public/app/core/reducers/application.test.ts | 16 + public/app/core/reducers/application.ts | 22 +- public/app/core/reducers/location.test.ts | 154 +++ public/app/core/reducers/location.ts | 56 +- public/app/core/reducers/navModel.test.ts | 40 + public/app/core/reducers/navModel.ts | 36 +- public/app/core/reducers/root.test.ts | 12 +- public/app/core/reducers/root.ts | 16 +- .../core/redux/actionCreatorFactory.test.ts | 79 -- public/app/core/redux/actionCreatorFactory.ts | 64 -- public/app/core/redux/index.ts | 2 - public/app/core/redux/reducerFactory.test.ts | 97 -- public/app/core/redux/reducerFactory.ts | 45 - public/app/features/admin/state/actions.ts | 45 +- .../app/features/admin/state/reducers.test.ts | 185 +++- public/app/features/admin/state/reducers.ts | 160 ++- .../features/alerting/AlertRuleList.test.tsx | 9 +- .../app/features/alerting/AlertRuleList.tsx | 5 +- public/app/features/alerting/state/actions.ts | 42 +- .../features/alerting/state/reducers.test.ts | 145 ++- .../app/features/alerting/state/reducers.ts | 31 +- .../features/api-keys/ApiKeysPage.test.tsx | 8 +- public/app/features/api-keys/ApiKeysPage.tsx | 13 +- public/app/features/api-keys/state/actions.ts | 35 +- .../features/api-keys/state/reducers.test.ts | 40 +- .../app/features/api-keys/state/reducers.ts | 26 +- .../containers/DashboardPage.test.tsx | 22 +- .../dashboard/panel_editor/PanelEditor.tsx | 4 +- .../panel_editor/state/actions.test.ts | 4 +- .../dashboard/panel_editor/state/actions.ts | 14 +- .../panel_editor/state/reducers.test.ts | 11 +- .../dashboard/panel_editor/state/reducers.ts | 30 +- .../app/features/dashboard/state/actions.ts | 24 +- .../dashboard/state/initDashboard.test.ts | 9 +- .../app/features/dashboard/state/reducers.ts | 116 +-- .../datasources/DataSourcesListPage.test.tsx | 6 +- .../datasources/DataSourcesListPage.tsx | 9 +- .../datasources/NewDataSourcePage.tsx | 5 +- .../settings/DataSourceSettingsPage.test.tsx | 4 +- .../settings/DataSourceSettingsPage.tsx | 10 +- .../app/features/datasources/state/actions.ts | 26 +- .../datasources/state/reducers.test.ts | 23 +- .../features/datasources/state/reducers.ts | 129 +-- .../app/features/explore/state/actionTypes.ts | 144 +-- .../features/explore/state/actions.test.ts | 11 +- public/app/features/explore/state/actions.ts | 40 +- .../features/explore/state/reducers.test.ts | 149 ++- public/app/features/explore/state/reducers.ts | 933 +++++++++--------- .../folders/FolderSettingsPage.test.tsx | 4 +- .../features/folders/FolderSettingsPage.tsx | 5 +- public/app/features/folders/state/actions.ts | 61 +- .../features/folders/state/reducers.test.ts | 192 ++-- public/app/features/folders/state/reducers.ts | 31 +- .../app/features/org/OrgDetailsPage.test.tsx | 7 +- public/app/features/org/OrgDetailsPage.tsx | 6 +- public/app/features/org/state/actions.ts | 30 +- .../app/features/org/state/reducers.test.ts | 27 + public/app/features/org/state/reducers.ts | 28 +- .../features/plugins/PluginListPage.test.tsx | 9 +- .../app/features/plugins/PluginListPage.tsx | 6 +- public/app/features/plugins/state/actions.ts | 71 +- .../features/plugins/state/reducers.test.ts | 151 +++ public/app/features/plugins/state/reducers.ts | 52 +- public/app/features/teams/CreateTeam.test.tsx | 4 +- public/app/features/teams/TeamList.test.tsx | 6 +- public/app/features/teams/TeamList.tsx | 5 +- .../app/features/teams/TeamMembers.test.tsx | 8 +- public/app/features/teams/TeamMembers.tsx | 3 +- public/app/features/teams/state/actions.ts | 86 +- .../app/features/teams/state/reducers.test.ts | 134 +-- public/app/features/teams/state/reducers.ts | 61 +- .../features/users/UsersActionBar.test.tsx | 6 +- public/app/features/users/UsersActionBar.tsx | 2 +- .../app/features/users/UsersListPage.test.tsx | 6 +- public/app/features/users/UsersListPage.tsx | 9 +- public/app/features/users/state/actions.ts | 46 +- .../app/features/users/state/reducers.test.ts | 44 + public/app/features/users/state/reducers.ts | 28 +- public/app/store/configureStore.ts | 28 +- public/app/types/alerting.ts | 4 + public/app/types/store.ts | 6 +- public/test/core/redux/mocks.ts | 12 + public/test/core/redux/reducerTester.test.ts | 44 +- public/test/core/redux/reducerTester.ts | 13 +- public/test/core/thunk/thunkTester.ts | 8 +- yarn.lock | 36 +- 97 files changed, 2392 insertions(+), 2305 deletions(-) delete mode 100644 public/app/core/actions/appNotification.ts delete mode 100644 public/app/core/actions/application.ts delete mode 100644 public/app/core/actions/location.ts delete mode 100644 public/app/core/actions/navModel.ts create mode 100644 public/app/core/reducers/application.test.ts create mode 100644 public/app/core/reducers/location.test.ts create mode 100644 public/app/core/reducers/navModel.test.ts delete mode 100644 public/app/core/redux/actionCreatorFactory.test.ts delete mode 100644 public/app/core/redux/actionCreatorFactory.ts delete mode 100644 public/app/core/redux/index.ts delete mode 100644 public/app/core/redux/reducerFactory.test.ts delete mode 100644 public/app/core/redux/reducerFactory.ts create mode 100644 public/app/features/org/state/reducers.test.ts create mode 100644 public/app/features/plugins/state/reducers.test.ts create mode 100644 public/app/features/users/state/reducers.test.ts create mode 100644 public/test/core/redux/mocks.ts diff --git a/contribute/style-guides/redux.md b/contribute/style-guides/redux.md index 27b8dba8e88..952089e6b15 100644 --- a/contribute/style-guides/redux.md +++ b/contribute/style-guides/redux.md @@ -1,121 +1,7 @@ # Redux framework -To reduce the amount of boilerplate code used to create a strongly typed redux solution with actions, action creators, reducers and tests we've introduced a small framework around Redux. - -`+` Much less boilerplate code -`-` Non Redux standard api - -## Core functionality - -### actionCreatorFactory - -Used to create an action creator with the following signature - -```typescript -{ type: string , (payload: T): {type: string; payload: T;} } -``` - -where the `type` string will be ensured to be unique and `T` is the type supplied to the factory. - -#### Example - -```typescript -export const someAction = actionCreatorFactory('SOME_ACTION').create(); - -// later when dispatched -someAction('this rocks!'); -``` - -```typescript -// best practices, always use an interface as type -interface SomeAction { - data: string; -} -export const someAction = actionCreatorFactory('SOME_ACTION').create(); - -// later when dispatched -someAction({ data: 'best practices' }); -``` - -```typescript -// declaring an action creator with a type string that has already been defined will throw -export const someAction = actionCreatorFactory('SOME_ACTION').create(); -export const theAction = actionCreatorFactory('SOME_ACTION').create(); // will throw -``` - -### reducerFactory - -Fluent API used to create a reducer. (same as implementing the standard switch statement in Redux) - -#### Example - -```typescript -interface ExampleReducerState { - data: string[]; -} - -const intialState: ExampleReducerState = { data: [] }; - -export const someAction = actionCreatorFactory('SOME_ACTION').create(); -export const otherAction = actionCreatorFactory('Other_ACTION').create(); - -export const exampleReducer = reducerFactory(intialState) - // addMapper is the function that ties an action creator to a state change - .addMapper({ - // action creator to filter out which mapper to use - filter: someAction, - // mapper function where the state change occurs - mapper: (state, action) => ({ ...state, data: state.data.concat(action.payload) }), - }) - // a developer can just chain addMapper functions until reducer is done - .addMapper({ - filter: otherAction, - mapper: (state, action) => ({ ...state, data: action.payload }), - }) - .create(); // this will return the reducer -``` - -#### Typing limitations - -There is a challenge left with the mapper function that I can not solve with TypeScript. The signature of a mapper is - -```typescript -(state: State, action: ActionOf) => State; -``` - -If you would to return an object that is not of the state type like the following mapper - -```typescript -mapper: (state, action) => ({ nonExistingProperty: ''}), -``` - -Then you would receive the following compile error - -```shell -[ts] Property 'data' is missing in type '{ nonExistingProperty: string; }' but required in type 'ExampleReducerState'. [2741] -``` - -But if you return an object that is spreading state and add a non existing property type like the following mapper - -```typescript -mapper: (state, action) => ({ ...state, nonExistingProperty: ''}), -``` - -Then you would not receive any compile error. - -If you want to make sure that never happens you can just supply the State type to the mapper callback like the following mapper: - -```typescript -mapper: (state, action): ExampleReducerState => ({ ...state, nonExistingProperty: 'kalle' }), -``` - -Then you would receive the following compile error - -```shell -[ts] -Type '{ nonExistingProperty: string; data: string[]; }' is not assignable to type 'ExampleReducerState'. - Object literal may only specify known properties, and 'nonExistingProperty' does not exist in type 'ExampleReducerState'. [2322] -``` +Grafana uses [Redux Toolkit](https://redux-toolkit.js.org/) to handle Redux boilerplate code. +> Some of our Reducers are used by Angular and therefore state is to be considered as mutable for those reducers. ## Test functionality diff --git a/package.json b/package.json index 92216e6c81e..a9ddda7a261 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ "@babel/polyfill": "7.6.0", "@braintree/sanitize-url": "4.0.0", "@grafana/slate-react": "0.22.9-grafana", + "@reduxjs/toolkit": "1.2.1", "@torkelo/react-select": "2.4.1", "@types/react-loadable": "5.5.2", "angular": "1.6.9", diff --git a/public/app/core/actions/appNotification.ts b/public/app/core/actions/appNotification.ts deleted file mode 100644 index b79b642eef1..00000000000 --- a/public/app/core/actions/appNotification.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AppNotification } from 'app/types/'; - -export enum ActionTypes { - AddAppNotification = 'ADD_APP_NOTIFICATION', - ClearAppNotification = 'CLEAR_APP_NOTIFICATION', -} - -interface AddAppNotificationAction { - type: ActionTypes.AddAppNotification; - payload: AppNotification; -} - -interface ClearAppNotificationAction { - type: ActionTypes.ClearAppNotification; - payload: number; -} - -export type Action = AddAppNotificationAction | ClearAppNotificationAction; - -export const clearAppNotification = (appNotificationId: number) => ({ - type: ActionTypes.ClearAppNotification, - payload: appNotificationId, -}); - -export const notifyApp = (appNotification: AppNotification) => ({ - type: ActionTypes.AddAppNotification, - payload: appNotification, -}); diff --git a/public/app/core/actions/application.ts b/public/app/core/actions/application.ts deleted file mode 100644 index 8345b34c0f0..00000000000 --- a/public/app/core/actions/application.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { actionCreatorFactory } from 'app/core/redux'; - -export const toggleLogActions = actionCreatorFactory('TOGGLE_LOG_ACTIONS').create(); diff --git a/public/app/core/actions/cleanUp.ts b/public/app/core/actions/cleanUp.ts index 991fd6e8d75..a1c362a6307 100644 --- a/public/app/core/actions/cleanUp.ts +++ b/public/app/core/actions/cleanUp.ts @@ -1,10 +1,11 @@ +import { createAction } from '@reduxjs/toolkit'; + import { StoreState } from '../../types'; -import { actionCreatorFactory } from '../redux'; -export type StateSelector = (state: StoreState) => T; +export type StateSelector = (state: StoreState) => T; -export interface CleanUp { - stateSelector: StateSelector; +export interface CleanUp { + stateSelector: (state: StoreState) => T; } -export const cleanUpAction = actionCreatorFactory>('CORE_CLEAN_UP_STATE').create(); +export const cleanUpAction = createAction>('core/cleanUpState'); diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts index f7ce2dda945..4ee3d55a769 100644 --- a/public/app/core/actions/index.ts +++ b/public/app/core/actions/index.ts @@ -1,5 +1,5 @@ -import { updateLocation } from './location'; -import { updateNavIndex, UpdateNavIndexAction } from './navModel'; -import { notifyApp, clearAppNotification } from './appNotification'; +import { clearAppNotification, notifyApp } from '../reducers/appNotification'; +import { updateLocation } from '../reducers/location'; +import { updateNavIndex } from '../reducers/navModel'; -export { updateLocation, updateNavIndex, UpdateNavIndexAction, notifyApp, clearAppNotification }; +export { updateLocation, updateNavIndex, notifyApp, clearAppNotification }; diff --git a/public/app/core/actions/location.ts b/public/app/core/actions/location.ts deleted file mode 100644 index 567934e12cb..00000000000 --- a/public/app/core/actions/location.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { LocationUpdate } from '@grafana/runtime'; -import { actionCreatorFactory } from 'app/core/redux'; - -export const updateLocation = actionCreatorFactory('UPDATE_LOCATION').create(); diff --git a/public/app/core/actions/navModel.ts b/public/app/core/actions/navModel.ts deleted file mode 100644 index f402355515b..00000000000 --- a/public/app/core/actions/navModel.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NavModelItem } from '@grafana/data'; - -export enum ActionTypes { - UpdateNavIndex = 'UPDATE_NAV_INDEX', -} - -export type Action = UpdateNavIndexAction; - -export interface UpdateNavIndexAction { - type: ActionTypes.UpdateNavIndex; - payload: NavModelItem; -} - -export const updateNavIndex = (item: NavModelItem): UpdateNavIndexAction => ({ - type: ActionTypes.UpdateNavIndex, - payload: item, -}); diff --git a/public/app/core/middlewares/application.ts b/public/app/core/middlewares/application.ts index 3ca9768d626..4041058466a 100644 --- a/public/app/core/middlewares/application.ts +++ b/public/app/core/middlewares/application.ts @@ -1,9 +1,9 @@ -import { Store, Dispatch } from 'redux'; -import { StoreState } from 'app/types/store'; -import { ActionOf } from '../redux/actionCreatorFactory'; -import { toggleLogActions } from '../actions/application'; +import { AnyAction, Dispatch, Store } from 'redux'; -export const toggleLogActionsMiddleware = (store: Store) => (next: Dispatch) => (action: ActionOf) => { +import { StoreState } from 'app/types/store'; +import { toggleLogActions } from '../reducers/application'; + +export const toggleLogActionsMiddleware = (store: Store) => (next: Dispatch) => (action: AnyAction) => { const isLogActionsAction = action.type === toggleLogActions.type; if (isLogActionsAction) { return next(action); diff --git a/public/app/core/reducers/appNotification.test.ts b/public/app/core/reducers/appNotification.test.ts index 183b699f5fc..dae91d55b9d 100644 --- a/public/app/core/reducers/appNotification.test.ts +++ b/public/app/core/reducers/appNotification.test.ts @@ -1,5 +1,4 @@ -import { appNotificationsReducer } from './appNotification'; -import { ActionTypes } from '../actions/appNotification'; +import { appNotificationsReducer, clearAppNotification, notifyApp } from './appNotification'; import { AppNotificationSeverity, AppNotificationTimeout } from 'app/types/'; describe('clear alert', () => { @@ -28,10 +27,7 @@ describe('clear alert', () => { ], }; - const result = appNotificationsReducer(initialState, { - type: ActionTypes.ClearAppNotification, - payload: id2, - }); + const result = appNotificationsReducer(initialState, clearAppNotification(id2)); const expectedResult = { appNotifications: [ @@ -49,3 +45,75 @@ describe('clear alert', () => { expect(result).toEqual(expectedResult); }); }); + +describe('notify', () => { + it('create notify message', () => { + const id1 = 1540301236048; + const id2 = 1540301248293; + const id3 = 1540301248203; + + const initialState = { + appNotifications: [ + { + id: id1, + severity: AppNotificationSeverity.Success, + icon: 'success', + title: 'test', + text: 'test alert', + timeout: AppNotificationTimeout.Success, + }, + { + id: id2, + severity: AppNotificationSeverity.Warning, + icon: 'warning', + title: 'test2', + text: 'test alert fail 2', + timeout: AppNotificationTimeout.Warning, + }, + ], + }; + + const result = appNotificationsReducer( + initialState, + notifyApp({ + id: id3, + severity: AppNotificationSeverity.Info, + icon: 'info', + title: 'test3', + text: 'test alert info 3', + timeout: AppNotificationTimeout.Success, + }) + ); + + const expectedResult = { + appNotifications: [ + { + id: id1, + severity: AppNotificationSeverity.Success, + icon: 'success', + title: 'test', + text: 'test alert', + timeout: AppNotificationTimeout.Success, + }, + { + id: id2, + severity: AppNotificationSeverity.Warning, + icon: 'warning', + title: 'test2', + text: 'test alert fail 2', + timeout: AppNotificationTimeout.Warning, + }, + { + id: id3, + severity: AppNotificationSeverity.Info, + icon: 'info', + title: 'test3', + text: 'test alert info 3', + timeout: AppNotificationTimeout.Success, + }, + ], + }; + + expect(result).toEqual(expectedResult); + }); +}); diff --git a/public/app/core/reducers/appNotification.ts b/public/app/core/reducers/appNotification.ts index 2c8bbbbd84d..9a8b269ae57 100644 --- a/public/app/core/reducers/appNotification.ts +++ b/public/app/core/reducers/appNotification.ts @@ -1,19 +1,25 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { AppNotification, AppNotificationsState } from 'app/types/'; -import { Action, ActionTypes } from '../actions/appNotification'; export const initialState: AppNotificationsState = { appNotifications: [] as AppNotification[], }; -export const appNotificationsReducer = (state = initialState, action: Action): AppNotificationsState => { - switch (action.type) { - case ActionTypes.AddAppNotification: - return { ...state, appNotifications: state.appNotifications.concat([action.payload]) }; - case ActionTypes.ClearAppNotification: - return { - ...state, - appNotifications: state.appNotifications.filter(appNotification => appNotification.id !== action.payload), - }; - } - return state; -}; +const appNotificationsSlice = createSlice({ + name: 'appNotifications', + initialState, + reducers: { + notifyApp: (state, action: PayloadAction): AppNotificationsState => ({ + ...state, + appNotifications: state.appNotifications.concat([action.payload]), + }), + clearAppNotification: (state, action: PayloadAction): AppNotificationsState => ({ + ...state, + appNotifications: state.appNotifications.filter(appNotification => appNotification.id !== action.payload), + }), + }, +}); + +export const { notifyApp, clearAppNotification } = appNotificationsSlice.actions; + +export const appNotificationsReducer = appNotificationsSlice.reducer; diff --git a/public/app/core/reducers/application.test.ts b/public/app/core/reducers/application.test.ts new file mode 100644 index 00000000000..3bbacef9ca3 --- /dev/null +++ b/public/app/core/reducers/application.test.ts @@ -0,0 +1,16 @@ +import { reducerTester } from '../../../test/core/redux/reducerTester'; +import { applicationReducer, toggleLogActions } from './application'; +import { ApplicationState } from '../../types/application'; + +describe('applicationReducer', () => { + describe('when toggleLogActions is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(applicationReducer, { logActions: false }) + .whenActionIsDispatched(toggleLogActions()) + .thenStateShouldEqual({ logActions: true }) + .whenActionIsDispatched(toggleLogActions()) + .thenStateShouldEqual({ logActions: false }); + }); + }); +}); diff --git a/public/app/core/reducers/application.ts b/public/app/core/reducers/application.ts index 458f4931619..de428a9122f 100644 --- a/public/app/core/reducers/application.ts +++ b/public/app/core/reducers/application.ts @@ -1,17 +1,17 @@ +import { createSlice } from '@reduxjs/toolkit'; import { ApplicationState } from 'app/types/application'; -import { reducerFactory } from 'app/core/redux'; -import { toggleLogActions } from '../actions/application'; export const initialState: ApplicationState = { logActions: false, }; -export const applicationReducer = reducerFactory(initialState) - .addMapper({ - filter: toggleLogActions, - mapper: (state): ApplicationState => ({ - ...state, - logActions: !state.logActions, - }), - }) - .create(); +const applicationSlice = createSlice({ + name: 'application', + initialState, + reducers: { + toggleLogActions: state => ({ ...state, logActions: !state.logActions }), + }, +}); + +export const { toggleLogActions } = applicationSlice.actions; +export const applicationReducer = applicationSlice.reducer; diff --git a/public/app/core/reducers/location.test.ts b/public/app/core/reducers/location.test.ts new file mode 100644 index 00000000000..2deb5f377a4 --- /dev/null +++ b/public/app/core/reducers/location.test.ts @@ -0,0 +1,154 @@ +import { reducerTester } from '../../../test/core/redux/reducerTester'; +import { initialState, locationReducer, updateLocation } from './location'; +import { LocationState } from '../../types'; + +describe('locationReducer', () => { + describe('when updateLocation is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(locationReducer, { ...initialState, query: { queryParam: 3, queryParam2: 2 } }) + .whenActionIsDispatched( + updateLocation({ + query: { queryParam: 1 }, + partial: false, + path: '/api/dashboard', + replace: false, + routeParams: { routeParam: 2 }, + }) + ) + .thenStatePredicateShouldEqual(resultingState => { + expect(resultingState.path).toEqual('/api/dashboard'); + expect(resultingState.url).toEqual('/api/dashboard?queryParam=1'); + expect(resultingState.query).toEqual({ queryParam: 1 }); + expect(resultingState.routeParams).toEqual({ routeParam: 2 }); + expect(resultingState.replace).toEqual(false); + return true; + }); + }); + }); + + describe('when updateLocation is dispatched with replace', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(locationReducer, { ...initialState, query: { queryParam: 3, queryParam2: 2 } }) + .whenActionIsDispatched( + updateLocation({ + query: { queryParam: 1 }, + partial: false, + path: '/api/dashboard', + replace: true, + routeParams: { routeParam: 2 }, + }) + ) + .thenStatePredicateShouldEqual(resultingState => { + expect(resultingState.path).toEqual('/api/dashboard'); + expect(resultingState.url).toEqual('/api/dashboard?queryParam=1'); + expect(resultingState.query).toEqual({ queryParam: 1 }); + expect(resultingState.routeParams).toEqual({ routeParam: 2 }); + expect(resultingState.replace).toEqual(true); + return true; + }); + }); + }); + + describe('when updateLocation is dispatched with partial', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(locationReducer, { ...initialState, query: { queryParam: 3, queryParam2: 2 } }) + .whenActionIsDispatched( + updateLocation({ + query: { queryParam: 1 }, + partial: true, + path: '/api/dashboard', + replace: false, + routeParams: { routeParam: 2 }, + }) + ) + .thenStatePredicateShouldEqual(resultingState => { + expect(resultingState.path).toEqual('/api/dashboard'); + expect(resultingState.url).toEqual('/api/dashboard?queryParam=1&queryParam2=2'); + expect(resultingState.query).toEqual({ queryParam: 1, queryParam2: 2 }); + expect(resultingState.routeParams).toEqual({ routeParam: 2 }); + expect(resultingState.replace).toEqual(false); + return true; + }); + }); + }); + + describe('when updateLocation is dispatched without query', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(locationReducer, { ...initialState, query: { queryParam: 3, queryParam2: 2 } }) + .whenActionIsDispatched( + updateLocation({ + partial: false, + path: '/api/dashboard', + replace: false, + routeParams: { routeParam: 2 }, + }) + ) + .thenStatePredicateShouldEqual(resultingState => { + expect(resultingState.path).toEqual('/api/dashboard'); + expect(resultingState.url).toEqual('/api/dashboard?queryParam=3&queryParam2=2'); + expect(resultingState.query).toEqual({ queryParam: 3, queryParam2: 2 }); + expect(resultingState.routeParams).toEqual({ routeParam: 2 }); + expect(resultingState.replace).toEqual(false); + return true; + }); + }); + }); + + describe('when updateLocation is dispatched without routeParams', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(locationReducer, { + ...initialState, + query: { queryParam: 3, queryParam2: 2 }, + routeParams: { routeStateParam: 4 }, + }) + .whenActionIsDispatched( + updateLocation({ + query: { queryParam: 1 }, + partial: false, + path: '/api/dashboard', + replace: false, + }) + ) + .thenStatePredicateShouldEqual(resultingState => { + expect(resultingState.path).toEqual('/api/dashboard'); + expect(resultingState.url).toEqual('/api/dashboard?queryParam=1'); + expect(resultingState.query).toEqual({ queryParam: 1 }); + expect(resultingState.routeParams).toEqual({ routeStateParam: 4 }); + expect(resultingState.replace).toEqual(false); + return true; + }); + }); + }); + + describe('when updateLocation is dispatched without path', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(locationReducer, { + ...initialState, + query: { queryParam: 3, queryParam2: 2 }, + path: '/api/state/path', + }) + .whenActionIsDispatched( + updateLocation({ + query: { queryParam: 1 }, + partial: false, + replace: false, + routeParams: { routeParam: 2 }, + }) + ) + .thenStatePredicateShouldEqual(resultingState => { + expect(resultingState.path).toEqual('/api/state/path'); + expect(resultingState.url).toEqual('/api/state/path?queryParam=1'); + expect(resultingState.query).toEqual({ queryParam: 1 }); + expect(resultingState.routeParams).toEqual({ routeParam: 2 }); + expect(resultingState.replace).toEqual(false); + return true; + }); + }); + }); +}); diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts index 04e8ab0fc66..da67a7fdb99 100644 --- a/public/app/core/reducers/location.ts +++ b/public/app/core/reducers/location.ts @@ -1,8 +1,9 @@ +import _ from 'lodash'; +import { Action, createAction } from '@reduxjs/toolkit'; +import { LocationUpdate } from '@grafana/runtime'; + import { LocationState } from 'app/types'; import { renderUrl } from 'app/core/utils/url'; -import _ from 'lodash'; -import { reducerFactory } from 'app/core/redux'; -import { updateLocation } from 'app/core/actions'; export const initialState: LocationState = { url: '', @@ -13,26 +14,33 @@ export const initialState: LocationState = { lastUpdated: 0, }; -export const locationReducer = reducerFactory(initialState) - .addMapper({ - filter: updateLocation, - mapper: (state, action): LocationState => { - const { path, routeParams, replace } = action.payload; - let query = action.payload.query || state.query; +export const updateLocation = createAction('location/updateLocation'); - if (action.payload.partial) { - query = _.defaults(query, state.query); - query = _.omitBy(query, _.isNull); - } +// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated. +// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice +// because the state would become frozen and during run time we would get errors because Angular would try to mutate +// the frozen state. +// https://github.com/reduxjs/redux-toolkit/issues/242 +export const locationReducer = (state: LocationState = initialState, action: Action) => { + if (updateLocation.match(action)) { + const payload: LocationUpdate = action.payload; + const { path, routeParams, replace } = payload; + let query = payload.query || state.query; - return { - url: renderUrl(path || state.path, query), - path: path || state.path, - query: { ...query }, - routeParams: routeParams || state.routeParams, - replace: replace === true, - lastUpdated: new Date().getTime(), - }; - }, - }) - .create(); + if (payload.partial) { + query = _.defaults(query, state.query); + query = _.omitBy(query, _.isNull); + } + + return { + url: renderUrl(path || state.path, query), + path: path || state.path, + query: { ...query }, + routeParams: routeParams || state.routeParams, + replace: replace === true, + lastUpdated: new Date().getTime(), + }; + } + + return state; +}; diff --git a/public/app/core/reducers/navModel.test.ts b/public/app/core/reducers/navModel.test.ts new file mode 100644 index 00000000000..c3c703645e7 --- /dev/null +++ b/public/app/core/reducers/navModel.test.ts @@ -0,0 +1,40 @@ +import { reducerTester } from '../../../test/core/redux/reducerTester'; +import { initialState, navIndexReducer, updateNavIndex } from './navModel'; + +describe('applicationReducer', () => { + describe('when updateNavIndex is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(navIndexReducer, { ...initialState }) + .whenActionIsDispatched( + updateNavIndex({ + id: 'parent', + text: 'Some Text', + children: [ + { + id: 'child', + text: 'Child', + }, + ], + }) + ) + .thenStateShouldEqual({ + ...initialState, + child: { + id: 'child', + text: 'Child', + parentItem: { + id: 'parent', + text: 'Some Text', + children: [ + { + id: 'child', + text: 'Child', + }, + ], + }, + }, + }); + }); + }); +}); diff --git a/public/app/core/reducers/navModel.ts b/public/app/core/reducers/navModel.ts index d10b2a4b964..2c89a509e9a 100644 --- a/public/app/core/reducers/navModel.ts +++ b/public/app/core/reducers/navModel.ts @@ -1,5 +1,6 @@ -import { Action, ActionTypes } from 'app/core/actions/navModel'; +import { AnyAction, createAction } from '@reduxjs/toolkit'; import { NavIndex, NavModelItem } from '@grafana/data'; + import config from 'app/core/config'; export function buildInitialState(): NavIndex { @@ -22,22 +23,29 @@ function buildNavIndex(navIndex: NavIndex, children: NavModelItem[], parentItem? } } -export const initialState: NavIndex = buildInitialState(); +export const initialState: NavIndex = {}; -export const navIndexReducer = (state = initialState, action: Action): NavIndex => { - switch (action.type) { - case ActionTypes.UpdateNavIndex: - const newPages: NavIndex = {}; - const payload = action.payload; +export const updateNavIndex = createAction('navIndex/updateNavIndex'); - for (const node of payload.children) { - newPages[node.id] = { - ...node, - parentItem: payload, - }; - } +// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated. +// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice +// because the state would become frozen and during run time we would get errors because Angular would try to mutate +// the frozen state. +// https://github.com/reduxjs/redux-toolkit/issues/242 +export const navIndexReducer = (state: NavIndex = initialState, action: AnyAction): NavIndex => { + if (updateNavIndex.match(action)) { + const newPages: NavIndex = {}; + const payload = action.payload; - return { ...state, ...newPages }; + for (const node of payload.children) { + newPages[node.id] = { + ...node, + parentItem: payload, + }; + } + + return { ...state, ...newPages }; } + return state; }; diff --git a/public/app/core/reducers/root.test.ts b/public/app/core/reducers/root.test.ts index e5ebbd49560..8469cbd3116 100644 --- a/public/app/core/reducers/root.test.ts +++ b/public/app/core/reducers/root.test.ts @@ -3,10 +3,9 @@ import { describe, expect } from '../../../test/lib/common'; import { NavModelItem } from '@grafana/data'; import { reducerTester } from '../../../test/core/redux/reducerTester'; import { StoreState } from '../../types/store'; -import { ActionTypes } from '../../features/teams/state/actions'; import { Team } from '../../types'; import { cleanUpAction } from '../actions/cleanUp'; -import { initialTeamsState } from '../../features/teams/state/reducers'; +import { initialTeamsState, teamsLoaded } from '../../features/teams/state/reducers'; jest.mock('@grafana/runtime', () => ({ config: { @@ -56,17 +55,14 @@ describe('rootReducer', () => { describe('when called with any action except cleanUpAction', () => { it('then it should not clean state', () => { - const teams = [{ id: 1 }]; + const teams = [{ id: 1 } as Team]; const state = { teams: { ...initialTeamsState }, } as StoreState; reducerTester() .givenReducer(rootReducer, state) - .whenActionIsDispatched({ - type: ActionTypes.LoadTeams, - payload: teams, - }) + .whenActionIsDispatched(teamsLoaded(teams)) .thenStatePredicateShouldEqual(resultingState => { expect(resultingState.teams).toEqual({ hasFetched: true, @@ -91,7 +87,7 @@ describe('rootReducer', () => { reducerTester() .givenReducer(rootReducer, state, true) - .whenActionIsDispatched(cleanUpAction({ stateSelector: storeState => storeState.teams })) + .whenActionIsDispatched(cleanUpAction({ stateSelector: (storeState: StoreState) => storeState.teams })) .thenStatePredicateShouldEqual(resultingState => { expect(resultingState.teams).toEqual({ ...initialTeamsState }); return true; diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 8a3ff5d666d..b53b4377f66 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -1,5 +1,4 @@ -import { combineReducers } from 'redux'; -import { ActionOf } from '../redux'; +import { AnyAction, combineReducers } from 'redux'; import { CleanUp, cleanUpAction } from '../actions/cleanUp'; import sharedReducers from 'app/core/reducers'; import alertingReducers from 'app/features/alerting/state/reducers'; @@ -43,7 +42,7 @@ export const createRootReducer = () => { ...addedReducers, }); - return (state: any, action: ActionOf): any => { + return (state: any, action: AnyAction): any => { if (action.type !== cleanUpAction.type) { return appReducer(state, action); } @@ -58,13 +57,18 @@ export const createRootReducer = () => { export const recursiveCleanState = (state: any, stateSlice: any): boolean => { for (const stateKey in state) { - if (state[stateKey] === stateSlice) { + if (!state.hasOwnProperty(stateKey)) { + continue; + } + + const slice = state[stateKey]; + if (slice === stateSlice) { state[stateKey] = undefined; return true; } - if (typeof state[stateKey] === 'object') { - const cleaned = recursiveCleanState(state[stateKey], stateSlice); + if (typeof slice === 'object') { + const cleaned = recursiveCleanState(slice, stateSlice); if (cleaned) { return true; } diff --git a/public/app/core/redux/actionCreatorFactory.test.ts b/public/app/core/redux/actionCreatorFactory.test.ts deleted file mode 100644 index 6683eb040b8..00000000000 --- a/public/app/core/redux/actionCreatorFactory.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { actionCreatorFactory, resetAllActionCreatorTypes } 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 = actionCreatorFactory('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(() => { - actionCreatorFactory('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 deleted file mode 100644 index 7061facd203..00000000000 --- a/public/app/core/redux/actionCreatorFactory.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Action } from 'redux'; - -const allActionCreators = new Set(); - -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 function actionCreatorFactory(type: string): NoPayloadActionCreatorFactory; -export function actionCreatorFactory(type: string): ActionCreatorFactory; -export function actionCreatorFactory(type: string): ActionCreatorFactory { - const upperCaseType = type.toLocaleUpperCase(); - if (allActionCreators.has(upperCaseType)) { - throw new Error(`An actionCreator with type '${type}' has already been defined.`); - } - - allActionCreators.add(upperCaseType); - - const create = (): ActionCreator => { - return Object.assign((payload: Payload): ActionOf => ({ type, payload }), { type }); - }; - return { create }; -} - -export interface NoPayloadActionCreatorMock extends NoPayloadActionCreator { - calls: number; -} - -export const getNoPayloadActionCreatorMock = (creator: NoPayloadActionCreator): NoPayloadActionCreatorMock => { - const mock: NoPayloadActionCreatorMock = Object.assign( - (): ActionOf => { - mock.calls++; - return { type: creator.type, payload: undefined }; - }, - { type: creator.type, calls: 0 } - ); - return mock; -}; - -export const mockActionCreator = (creator: ActionCreator) => { - return Object.assign(jest.fn(), creator); -}; - -// Should only be used by tests -export const resetAllActionCreatorTypes = () => allActionCreators.clear(); diff --git a/public/app/core/redux/index.ts b/public/app/core/redux/index.ts deleted file mode 100644 index e5087123c1e..00000000000 --- a/public/app/core/redux/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './actionCreatorFactory'; -export * from './reducerFactory'; diff --git a/public/app/core/redux/reducerFactory.test.ts b/public/app/core/redux/reducerFactory.test.ts deleted file mode 100644 index 48cffc1ca7a..00000000000 --- a/public/app/core/redux/reducerFactory.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index bfa8e67bd4c..00000000000 --- a/public/app/core/redux/reducerFactory.ts +++ /dev/null @@ -1,45 +0,0 @@ -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/admin/state/actions.ts b/public/app/features/admin/state/actions.ts index 68f22c0ba73..ae5f5f47e22 100644 --- a/public/app/features/admin/state/actions.ts +++ b/public/app/features/admin/state/actions.ts @@ -1,34 +1,27 @@ -import { actionCreatorFactory } from 'app/core/redux'; import config from 'app/core/config'; -import { ThunkResult, SyncInfo, LdapUser, LdapConnectionInfo, LdapError, UserSession, User } from 'app/types'; +import { ThunkResult } from 'app/types'; import { - getUserInfo, getLdapState, - syncLdapUser, - getUser, - getUserSessions, - revokeUserSession, - revokeAllUserSessions, getLdapSyncStatus, + getUser, + getUserInfo, + getUserSessions, + revokeAllUserSessions, + revokeUserSession, + syncLdapUser, } from './apis'; - -// Action types - -export const ldapConnectionInfoLoadedAction = actionCreatorFactory( - 'ldap/CONNECTION_INFO_LOADED' -).create(); -export const ldapSyncStatusLoadedAction = actionCreatorFactory('ldap/SYNC_STATUS_LOADED').create(); -export const userMappingInfoLoadedAction = actionCreatorFactory('ldap/USER_INFO_LOADED').create(); -export const userMappingInfoFailedAction = actionCreatorFactory('ldap/USER_INFO_FAILED').create(); -export const clearUserMappingInfoAction = actionCreatorFactory('ldap/CLEAR_USER_MAPPING_INFO').create(); -export const clearUserErrorAction = actionCreatorFactory('ldap/CLEAR_USER_ERROR').create(); -export const ldapFailedAction = actionCreatorFactory('ldap/LDAP_FAILED').create(); - -export const userLoadedAction = actionCreatorFactory('USER_LOADED').create(); -export const userSessionsLoadedAction = actionCreatorFactory('USER_SESSIONS_LOADED').create(); -export const userSyncFailedAction = actionCreatorFactory('USER_SYNC_FAILED').create(); -export const revokeUserSessionAction = actionCreatorFactory('REVOKE_USER_SESSION').create(); -export const revokeAllUserSessionsAction = actionCreatorFactory('REVOKE_ALL_USER_SESSIONS').create(); +import { + clearUserErrorAction, + clearUserMappingInfoAction, + ldapConnectionInfoLoadedAction, + ldapFailedAction, + ldapSyncStatusLoadedAction, + userLoadedAction, + userMappingInfoFailedAction, + userMappingInfoLoadedAction, + userSessionsLoadedAction, + userSyncFailedAction, +} from './reducers'; // Actions diff --git a/public/app/features/admin/state/reducers.test.ts b/public/app/features/admin/state/reducers.test.ts index 2e181742ce6..126987f3c57 100644 --- a/public/app/features/admin/state/reducers.test.ts +++ b/public/app/features/admin/state/reducers.test.ts @@ -1,16 +1,18 @@ -import { Reducer } from 'redux'; import { reducerTester } from 'test/core/redux/reducerTester'; -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; -import { ldapReducer, ldapUserReducer } from './reducers'; import { + clearUserErrorAction, + clearUserMappingInfoAction, ldapConnectionInfoLoadedAction, - ldapSyncStatusLoadedAction, - userMappingInfoLoadedAction, - userMappingInfoFailedAction, ldapFailedAction, + ldapReducer, + ldapSyncStatusLoadedAction, + ldapUserReducer, userLoadedAction, -} from './actions'; -import { LdapState, LdapUserState, LdapUser, User } from 'app/types'; + userMappingInfoFailedAction, + userMappingInfoLoadedAction, + userSessionsLoadedAction, +} from './reducers'; +import { LdapState, LdapUser, LdapUserState, User } from 'app/types'; const makeInitialLdapState = (): LdapState => ({ connectionInfo: [], @@ -56,12 +58,12 @@ describe('LDAP page reducer', () => { describe('When page loaded', () => { describe('When connection info loaded', () => { it('should set connection info and clear error', () => { - const initalState = { + const initialState = { ...makeInitialLdapState(), }; - reducerTester() - .givenReducer(ldapReducer as Reducer>, initalState) + reducerTester() + .givenReducer(ldapReducer, initialState) .whenActionIsDispatched( ldapConnectionInfoLoadedAction([ { @@ -89,12 +91,12 @@ describe('LDAP page reducer', () => { describe('When connection failed', () => { it('should set ldap error', () => { - const initalState = { + const initialState = { ...makeInitialLdapState(), }; - reducerTester() - .givenReducer(ldapReducer as Reducer>, initalState) + reducerTester() + .givenReducer(ldapReducer, initialState) .whenActionIsDispatched( ldapFailedAction({ title: 'LDAP error', @@ -113,12 +115,12 @@ describe('LDAP page reducer', () => { describe('When LDAP sync status loaded', () => { it('should set sync info', () => { - const initalState = { + const initialState = { ...makeInitialLdapState(), }; - reducerTester() - .givenReducer(ldapReducer as Reducer>, initalState) + reducerTester() + .givenReducer(ldapReducer, initialState) .whenActionIsDispatched( ldapSyncStatusLoadedAction({ enabled: true, @@ -140,7 +142,7 @@ describe('LDAP page reducer', () => { describe('When user mapping info loaded', () => { it('should set sync info and clear user error', () => { - const initalState = { + const initialState = { ...makeInitialLdapState(), userError: { title: 'User not found', @@ -148,8 +150,8 @@ describe('LDAP page reducer', () => { }, }; - reducerTester() - .givenReducer(ldapReducer as Reducer>, initalState) + reducerTester() + .givenReducer(ldapReducer, initialState) .whenActionIsDispatched(userMappingInfoLoadedAction(getTestUserMapping())) .thenStateShouldEqual({ ...makeInitialLdapState(), @@ -161,13 +163,13 @@ describe('LDAP page reducer', () => { describe('When user not found', () => { it('should set user error and clear user info', () => { - const initalState = { + const initialState = { ...makeInitialLdapState(), user: getTestUserMapping(), }; - reducerTester() - .givenReducer(ldapReducer as Reducer>, initalState) + reducerTester() + .givenReducer(ldapReducer, initialState) .whenActionIsDispatched( userMappingInfoFailedAction({ title: 'User not found', @@ -184,12 +186,27 @@ describe('LDAP page reducer', () => { }); }); }); + + describe('when clearUserMappingInfoAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(ldapReducer, { + ...makeInitialLdapState(), + user: getTestUserMapping(), + }) + .whenActionIsDispatched(clearUserMappingInfoAction()) + .thenStateShouldEqual({ + ...makeInitialLdapState(), + user: null, + }); + }); + }); }); describe('Edit LDAP user page reducer', () => { describe('When user loaded', () => { it('should set user and clear user error', () => { - const initalState = { + const initialState = { ...makeInitialLdapUserState(), userError: { title: 'User not found', @@ -197,8 +214,8 @@ describe('Edit LDAP user page reducer', () => { }, }; - reducerTester() - .givenReducer(ldapUserReducer as Reducer>, initalState) + reducerTester() + .givenReducer(ldapUserReducer, initialState) .whenActionIsDispatched(userLoadedAction(getTestUser())) .thenStateShouldEqual({ ...makeInitialLdapUserState(), @@ -207,4 +224,120 @@ describe('Edit LDAP user page reducer', () => { }); }); }); + + describe('when userSessionsLoadedAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(ldapUserReducer, { ...makeInitialLdapUserState() }) + .whenActionIsDispatched( + userSessionsLoadedAction([ + { + browser: 'Chrome', + id: 1, + browserVersion: '79', + clientIp: '127.0.0.1', + createdAt: '2020-01-01 00:00:00', + device: 'a device', + isActive: true, + os: 'MacOS', + osVersion: '15', + seenAt: '2020-01-01 00:00:00', + }, + ]) + ) + .thenStateShouldEqual({ + ...makeInitialLdapUserState(), + sessions: [ + { + browser: 'Chrome', + id: 1, + browserVersion: '79', + clientIp: '127.0.0.1', + createdAt: '2020-01-01 00:00:00', + device: 'a device', + isActive: true, + os: 'MacOS', + osVersion: '15', + seenAt: '2020-01-01 00:00:00', + }, + ], + }); + }); + }); + + describe('when userMappingInfoLoadedAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(ldapUserReducer, { + ...makeInitialLdapUserState(), + }) + .whenActionIsDispatched(userMappingInfoLoadedAction(getTestUserMapping())) + .thenStateShouldEqual({ + ...makeInitialLdapUserState(), + ldapUser: getTestUserMapping(), + }); + }); + }); + + describe('when userMappingInfoFailedAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(ldapUserReducer, { ...makeInitialLdapUserState() }) + .whenActionIsDispatched( + userMappingInfoFailedAction({ + title: 'User not found', + body: 'Cannot find user', + }) + ) + .thenStateShouldEqual({ + ...makeInitialLdapUserState(), + userError: { + title: 'User not found', + body: 'Cannot find user', + }, + }); + }); + }); + + describe('when clearUserErrorAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(ldapUserReducer, { + ...makeInitialLdapUserState(), + userError: { + title: 'User not found', + body: 'Cannot find user', + }, + }) + .whenActionIsDispatched(clearUserErrorAction()) + .thenStateShouldEqual({ + ...makeInitialLdapUserState(), + userError: null, + }); + }); + }); + + describe('when ldapSyncStatusLoadedAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(ldapUserReducer, { + ...makeInitialLdapUserState(), + }) + .whenActionIsDispatched( + ldapSyncStatusLoadedAction({ + enabled: true, + schedule: '0 0 * * * *', + nextSync: '2019-01-01T12:00:00Z', + }) + ) + .thenStateShouldEqual({ + ...makeInitialLdapUserState(), + ldapSyncInfo: { + enabled: true, + schedule: '0 0 * * * *', + nextSync: '2019-01-01T12:00:00Z', + }, + }); + }); + }); }); diff --git a/public/app/features/admin/state/reducers.ts b/public/app/features/admin/state/reducers.ts index a8353379d33..19cab38d6e8 100644 --- a/public/app/features/admin/state/reducers.ts +++ b/public/app/features/admin/state/reducers.ts @@ -1,16 +1,14 @@ -import { reducerFactory } from 'app/core/redux'; -import { LdapState, LdapUserState } from 'app/types'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { - ldapConnectionInfoLoadedAction, - ldapFailedAction, - userMappingInfoLoadedAction, - userMappingInfoFailedAction, - clearUserErrorAction, - userLoadedAction, - userSessionsLoadedAction, - ldapSyncStatusLoadedAction, - clearUserMappingInfoAction, -} from './actions'; + LdapConnectionInfo, + LdapError, + LdapState, + LdapUser, + LdapUserState, + SyncInfo, + User, + UserSession, +} from 'app/types'; const initialLdapState: LdapState = { connectionInfo: [], @@ -27,107 +25,107 @@ const initialLdapUserState: LdapUserState = { sessions: [], }; -export const ldapReducer = reducerFactory(initialLdapState) - .addMapper({ - filter: ldapConnectionInfoLoadedAction, - mapper: (state, action) => ({ +const ldapSlice = createSlice({ + name: 'ldap', + initialState: initialLdapState, + reducers: { + ldapConnectionInfoLoadedAction: (state, action: PayloadAction): LdapState => ({ ...state, ldapError: null, connectionInfo: action.payload, }), - }) - .addMapper({ - filter: ldapFailedAction, - mapper: (state, action) => ({ + ldapFailedAction: (state, action: PayloadAction): LdapState => ({ ...state, ldapError: action.payload, }), - }) - .addMapper({ - filter: ldapSyncStatusLoadedAction, - mapper: (state, action) => ({ + ldapSyncStatusLoadedAction: (state, action: PayloadAction): LdapState => ({ ...state, syncInfo: action.payload, }), - }) - .addMapper({ - filter: userMappingInfoLoadedAction, - mapper: (state, action) => ({ + userMappingInfoLoadedAction: (state, action: PayloadAction): LdapState => ({ ...state, user: action.payload, userError: null, }), - }) - .addMapper({ - filter: userMappingInfoFailedAction, - mapper: (state, action) => ({ + userMappingInfoFailedAction: (state, action: PayloadAction): LdapState => ({ ...state, user: null, userError: action.payload, }), - }) - .addMapper({ - filter: clearUserMappingInfoAction, - mapper: (state, action) => ({ + clearUserMappingInfoAction: (state, action: PayloadAction): LdapState => ({ ...state, user: null, }), - }) - .addMapper({ - filter: clearUserErrorAction, - mapper: state => ({ + clearUserErrorAction: (state, action: PayloadAction): LdapState => ({ ...state, userError: null, }), - }) - .create(); + }, +}); -export const ldapUserReducer = reducerFactory(initialLdapUserState) - .addMapper({ - filter: userMappingInfoLoadedAction, - mapper: (state, action) => ({ - ...state, - ldapUser: action.payload, - }), - }) - .addMapper({ - filter: userMappingInfoFailedAction, - mapper: (state, action) => ({ - ...state, - ldapUser: null, - userError: action.payload, - }), - }) - .addMapper({ - filter: clearUserErrorAction, - mapper: state => ({ - ...state, - userError: null, - }), - }) - .addMapper({ - filter: ldapSyncStatusLoadedAction, - mapper: (state, action) => ({ - ...state, - ldapSyncInfo: action.payload, - }), - }) - .addMapper({ - filter: userLoadedAction, - mapper: (state, action) => ({ +export const { + clearUserErrorAction, + clearUserMappingInfoAction, + ldapConnectionInfoLoadedAction, + ldapFailedAction, + ldapSyncStatusLoadedAction, + userMappingInfoFailedAction, + userMappingInfoLoadedAction, +} = ldapSlice.actions; + +export const ldapReducer = ldapSlice.reducer; + +const ldapUserSlice = createSlice({ + name: 'ldapUser', + initialState: initialLdapUserState, + reducers: { + userLoadedAction: (state, action: PayloadAction): LdapUserState => ({ ...state, user: action.payload, userError: null, }), - }) - .addMapper({ - filter: userSessionsLoadedAction, - mapper: (state, action) => ({ + userSessionsLoadedAction: (state, action: PayloadAction): LdapUserState => ({ ...state, sessions: action.payload, }), - }) - .create(); + userSyncFailedAction: (state, action: PayloadAction): LdapUserState => state, + }, + extraReducers: builder => + builder + .addCase( + userMappingInfoLoadedAction, + (state, action): LdapUserState => ({ + ...state, + ldapUser: action.payload, + }) + ) + .addCase( + userMappingInfoFailedAction, + (state, action): LdapUserState => ({ + ...state, + ldapUser: null, + userError: action.payload, + }) + ) + .addCase( + clearUserErrorAction, + (state, action): LdapUserState => ({ + ...state, + userError: null, + }) + ) + .addCase( + ldapSyncStatusLoadedAction, + (state, action): LdapUserState => ({ + ...state, + ldapSyncInfo: action.payload, + }) + ), +}); + +export const { userLoadedAction, userSessionsLoadedAction, userSyncFailedAction } = ldapUserSlice.actions; + +export const ldapUserReducer = ldapUserSlice.reducer; export default { ldap: ldapReducer, diff --git a/public/app/features/alerting/AlertRuleList.test.tsx b/public/app/features/alerting/AlertRuleList.test.tsx index da0121a9296..91c892e22ea 100644 --- a/public/app/features/alerting/AlertRuleList.test.tsx +++ b/public/app/features/alerting/AlertRuleList.test.tsx @@ -3,10 +3,11 @@ import { shallow } from 'enzyme'; import { AlertRuleList, Props } from './AlertRuleList'; import { AlertRule } from '../../types'; import appEvents from '../../core/app_events'; -import { mockActionCreator } from 'app/core/redux'; -import { updateLocation } from 'app/core/actions'; import { NavModel } from '@grafana/data'; import { CoreEvents } from 'app/types'; +import { updateLocation } from '../../core/actions'; +import { setSearchQuery } from './state/reducers'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; jest.mock('../../core/app_events', () => ({ emit: jest.fn(), @@ -16,9 +17,9 @@ const setup = (propOverrides?: object) => { const props: Props = { navModel: {} as NavModel, alertRules: [] as AlertRule[], - updateLocation: mockActionCreator(updateLocation), + updateLocation: mockToolkitActionCreator(updateLocation), getAlertRulesAsync: jest.fn(), - setSearchQuery: jest.fn(), + setSearchQuery: mockToolkitActionCreator(setSearchQuery), togglePauseAlertRule: jest.fn(), stateFilter: '', search: '', diff --git a/public/app/features/alerting/AlertRuleList.tsx b/public/app/features/alerting/AlertRuleList.tsx index d99f81f36f9..93e00606ebf 100644 --- a/public/app/features/alerting/AlertRuleList.tsx +++ b/public/app/features/alerting/AlertRuleList.tsx @@ -6,11 +6,12 @@ import AlertRuleItem from './AlertRuleItem'; import appEvents from 'app/core/app_events'; import { updateLocation } from 'app/core/actions'; import { getNavModel } from 'app/core/selectors/navModel'; -import { StoreState, AlertRule, CoreEvents } from 'app/types'; -import { getAlertRulesAsync, setSearchQuery, togglePauseAlertRule } from './state/actions'; +import { AlertRule, CoreEvents, StoreState } from 'app/types'; +import { getAlertRulesAsync, togglePauseAlertRule } from './state/actions'; import { getAlertRuleItems, getSearchQuery } from './state/selectors'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { NavModel } from '@grafana/data'; +import { setSearchQuery } from './state/reducers'; export interface Props { navModel: NavModel; diff --git a/public/app/features/alerting/state/actions.ts b/public/app/features/alerting/state/actions.ts index 3ca51d52134..1d1a5caf2cb 100644 --- a/public/app/features/alerting/state/actions.ts +++ b/public/app/features/alerting/state/actions.ts @@ -1,44 +1,6 @@ import { getBackendSrv } from '@grafana/runtime'; -import { AlertRuleDTO, StoreState } from 'app/types'; -import { ThunkAction } from 'redux-thunk'; - -export enum ActionTypes { - LoadAlertRules = 'LOAD_ALERT_RULES', - LoadedAlertRules = 'LOADED_ALERT_RULES', - SetSearchQuery = 'SET_ALERT_SEARCH_QUERY', -} - -export interface LoadAlertRulesAction { - type: ActionTypes.LoadAlertRules; -} - -export interface LoadedAlertRulesAction { - type: ActionTypes.LoadedAlertRules; - payload: AlertRuleDTO[]; -} - -export interface SetSearchQueryAction { - type: ActionTypes.SetSearchQuery; - payload: string; -} - -export const loadAlertRules = (): LoadAlertRulesAction => ({ - type: ActionTypes.LoadAlertRules, -}); - -export const loadedAlertRules = (rules: AlertRuleDTO[]): LoadedAlertRulesAction => ({ - type: ActionTypes.LoadedAlertRules, - payload: rules, -}); - -export const setSearchQuery = (query: string): SetSearchQueryAction => ({ - type: ActionTypes.SetSearchQuery, - payload: query, -}); - -export type Action = LoadAlertRulesAction | LoadedAlertRulesAction | SetSearchQueryAction; - -type ThunkResult = ThunkAction; +import { AlertRuleDTO, ThunkResult } from 'app/types'; +import { loadAlertRules, loadedAlertRules } from './reducers'; export function getAlertRulesAsync(options: { state: string }): ThunkResult { return async dispatch => { diff --git a/public/app/features/alerting/state/reducers.test.ts b/public/app/features/alerting/state/reducers.test.ts index d66ec1884f3..1101db01d01 100644 --- a/public/app/features/alerting/state/reducers.test.ts +++ b/public/app/features/alerting/state/reducers.test.ts @@ -1,6 +1,6 @@ -import { ActionTypes, Action } from './actions'; -import { alertRulesReducer, initialState } from './reducers'; -import { AlertRuleDTO } from 'app/types'; +import { alertRulesReducer, initialState, loadAlertRules, loadedAlertRules, setSearchQuery } from './reducers'; +import { AlertRuleDTO, AlertRulesState } from 'app/types'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; describe('Alert rules', () => { const payload: AlertRuleDTO[] = [ @@ -78,14 +78,137 @@ describe('Alert rules', () => { }, ]; - it('should set alert rules', () => { - const action: Action = { - type: ActionTypes.LoadedAlertRules, - payload: payload, - }; + describe('when loadAlertRules is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(alertRulesReducer, { ...initialState }) + .whenActionIsDispatched(loadAlertRules()) + .thenStateShouldEqual({ ...initialState, isLoading: true }); + }); + }); - const result = alertRulesReducer(initialState, action); - expect(result.items.length).toEqual(payload.length); - expect(result.items[0].stateClass).toEqual('alert-state-critical'); + describe('when setSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(alertRulesReducer, { ...initialState }) + .whenActionIsDispatched(setSearchQuery('query')) + .thenStateShouldEqual({ ...initialState, searchQuery: 'query' }); + }); + }); + + describe('when loadedAlertRules is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(alertRulesReducer, { ...initialState, isLoading: true }) + .whenActionIsDispatched(loadedAlertRules(payload)) + .thenStateShouldEqual({ + ...initialState, + isLoading: false, + items: [ + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: { + evalMatches: [ + { + metric: 'A-series', + tags: null, + value: 215, + }, + ], + }, + evalDate: '0001-01-01T00:00:00Z', + executionError: '', + id: 2, + name: 'TestData - Always Alerting', + newStateDate: '2018-09-04T10:00:30+02:00', + panelId: 4, + state: 'alerting', + stateAge: 'a year', + stateClass: 'alert-state-critical', + stateIcon: 'icon-gf icon-gf-critical', + stateText: 'ALERTING', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: {}, + evalDate: '0001-01-01T00:00:00Z', + executionError: '', + id: 1, + name: 'TestData - Always OK', + newStateDate: '2018-09-04T10:01:01+02:00', + panelId: 3, + state: 'ok', + stateAge: 'a year', + stateClass: 'alert-state-ok', + stateIcon: 'icon-gf icon-gf-online', + stateText: 'OK', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: {}, + evalDate: '0001-01-01T00:00:00Z', + executionError: 'error', + id: 3, + info: 'Execution Error: error', + name: 'TestData - ok', + newStateDate: '2018-09-04T10:01:01+02:00', + panelId: 3, + state: 'ok', + stateAge: 'a year', + stateClass: 'alert-state-ok', + stateIcon: 'icon-gf icon-gf-online', + stateText: 'OK', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: {}, + evalDate: '0001-01-01T00:00:00Z', + executionError: 'error', + id: 4, + name: 'TestData - Paused', + newStateDate: '2018-09-04T10:01:01+02:00', + panelId: 3, + state: 'paused', + stateAge: 'a year', + stateClass: 'alert-state-paused', + stateIcon: 'fa fa-pause', + stateText: 'PAUSED', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: { + noData: true, + }, + evalDate: '0001-01-01T00:00:00Z', + executionError: 'error', + id: 5, + info: 'Query returned no data', + name: 'TestData - Ok', + newStateDate: '2018-09-04T10:01:01+02:00', + panelId: 3, + state: 'ok', + stateAge: 'a year', + stateClass: 'alert-state-ok', + stateIcon: 'icon-gf icon-gf-online', + stateText: 'OK', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + ], + }); + }); }); }); diff --git a/public/app/features/alerting/state/reducers.ts b/public/app/features/alerting/state/reducers.ts index e68638a1aed..4c30782fbc1 100644 --- a/public/app/features/alerting/state/reducers.ts +++ b/public/app/features/alerting/state/reducers.ts @@ -1,7 +1,7 @@ -import { AlertRuleDTO, AlertRule, AlertRulesState } from 'app/types'; -import { Action, ActionTypes } from './actions'; +import { AlertRule, AlertRuleDTO, AlertRulesState } from 'app/types'; import alertDef from './alertDef'; import { dateTime } from '@grafana/data'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; export const initialState: AlertRulesState = { items: [], searchQuery: '', isLoading: false }; @@ -28,13 +28,14 @@ function convertToAlertRule(dto: AlertRuleDTO, state: string): AlertRule { return rule; } -export const alertRulesReducer = (state = initialState, action: Action): AlertRulesState => { - switch (action.type) { - case ActionTypes.LoadAlertRules: { +const alertRulesSlice = createSlice({ + name: 'alertRules', + initialState, + reducers: { + loadAlertRules: state => { return { ...state, isLoading: true }; - } - - case ActionTypes.LoadedAlertRules: { + }, + loadedAlertRules: (state, action: PayloadAction): AlertRulesState => { const alertRules: AlertRuleDTO[] = action.payload; const alertRulesViewModel: AlertRule[] = alertRules.map(rule => { @@ -42,14 +43,16 @@ export const alertRulesReducer = (state = initialState, action: Action): AlertRu }); return { ...state, items: alertRulesViewModel, isLoading: false }; - } - - case ActionTypes.SetSearchQuery: + }, + setSearchQuery: (state, action: PayloadAction): AlertRulesState => { return { ...state, searchQuery: action.payload }; - } + }, + }, +}); - return state; -}; +export const { loadAlertRules, loadedAlertRules, setSearchQuery } = alertRulesSlice.actions; + +export const alertRulesReducer = alertRulesSlice.reducer; export default { alertRules: alertRulesReducer, diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx index 7ae70833075..47e06af8dd2 100644 --- a/public/app/features/api-keys/ApiKeysPage.test.tsx +++ b/public/app/features/api-keys/ApiKeysPage.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Props, ApiKeysPage } from './ApiKeysPage'; +import { ApiKeysPage, Props } from './ApiKeysPage'; import { ApiKey } from 'app/types'; -import { getMultipleMockKeys, getMockKey } from './__mocks__/apiKeysMock'; +import { getMockKey, getMultipleMockKeys } from './__mocks__/apiKeysMock'; import { NavModel } from '@grafana/data'; +import { setSearchQuery } from './state/reducers'; +import { mockToolkitActionCreator } from '../../../test/core/redux/mocks'; const setup = (propOverrides?: object) => { const props: Props = { @@ -20,7 +22,7 @@ const setup = (propOverrides?: object) => { hasFetched: false, loadApiKeys: jest.fn(), deleteApiKey: jest.fn(), - setSearchQuery: jest.fn(), + setSearchQuery: mockToolkitActionCreator(setSearchQuery), addApiKey: jest.fn(), apiKeysCount: 0, includeExpired: false, diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index ebfe0dd6a81..a78a87cac78 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -2,25 +2,24 @@ import React, { PureComponent } from 'react'; import ReactDOMServer from 'react-dom/server'; import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; -import { ApiKey, NewApiKey, OrgRole } from 'app/types'; +// Utils +import { ApiKey, CoreEvents, NewApiKey, OrgRole } from 'app/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { getApiKeys, getApiKeysCount } from './state/selectors'; -import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions'; +import { addApiKey, deleteApiKey, loadApiKeys } from './state/actions'; import Page from 'app/core/components/Page/Page'; import { SlideDown } from 'app/core/components/Animations/SlideDown'; import ApiKeysAddedModal from './ApiKeysAddedModal'; import config from 'app/core/config'; import appEvents from 'app/core/app_events'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { EventsWithValidation, FormLabel, Input, Switch, ValidationEvents, DeleteButton } from '@grafana/ui'; -import { NavModel, dateTime, isDateTime } from '@grafana/data'; +import { DeleteButton, EventsWithValidation, FormLabel, Input, Switch, ValidationEvents } from '@grafana/ui'; +import { dateTime, isDateTime, NavModel } from '@grafana/data'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { store } from 'app/store/store'; import kbn from 'app/core/utils/kbn'; - -// Utils -import { CoreEvents } from 'app/types'; import { getTimeZone } from 'app/features/profile/state/selectors'; +import { setSearchQuery } from './state/reducers'; const timeRangeValidationEvents: ValidationEvents = { [EventsWithValidation.onBlur]: [ diff --git a/public/app/features/api-keys/state/actions.ts b/public/app/features/api-keys/state/actions.ts index b46d3dd9ff4..f572999292a 100644 --- a/public/app/features/api-keys/state/actions.ts +++ b/public/app/features/api-keys/state/actions.ts @@ -1,30 +1,6 @@ -import { ThunkAction } from 'redux-thunk'; -import { getBackendSrv } from 'app/core/services/backend_srv'; -import { StoreState, ApiKey } from 'app/types'; - -export enum ActionTypes { - LoadApiKeys = 'LOAD_API_KEYS', - SetApiKeysSearchQuery = 'SET_API_KEYS_SEARCH_QUERY', -} - -export interface LoadApiKeysAction { - type: ActionTypes.LoadApiKeys; - payload: ApiKey[]; -} - -export interface SetSearchQueryAction { - type: ActionTypes.SetApiKeysSearchQuery; - payload: string; -} - -export type Action = LoadApiKeysAction | SetSearchQueryAction; - -type ThunkResult = ThunkAction; - -const apiKeysLoaded = (apiKeys: ApiKey[]): LoadApiKeysAction => ({ - type: ActionTypes.LoadApiKeys, - payload: apiKeys, -}); +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { ApiKey, ThunkResult } from 'app/types'; +import { apiKeysLoaded, setSearchQuery } from './reducers'; export function addApiKey( apiKey: ApiKey, @@ -53,8 +29,3 @@ export function deleteApiKey(id: number, includeExpired: boolean): ThunkResult ({ - type: ActionTypes.SetApiKeysSearchQuery, - payload: searchQuery, -}); diff --git a/public/app/features/api-keys/state/reducers.test.ts b/public/app/features/api-keys/state/reducers.test.ts index 3b2c831a5a3..4ff95593683 100644 --- a/public/app/features/api-keys/state/reducers.test.ts +++ b/public/app/features/api-keys/state/reducers.test.ts @@ -1,31 +1,27 @@ -import { Action, ActionTypes } from './actions'; -import { initialApiKeysState, apiKeysReducer } from './reducers'; +import { apiKeysLoaded, apiKeysReducer, initialApiKeysState, setSearchQuery } from './reducers'; import { getMultipleMockKeys } from '../__mocks__/apiKeysMock'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { ApiKeysState } from '../../../types'; describe('API Keys reducer', () => { it('should set keys', () => { - const payload = getMultipleMockKeys(4); - - const action: Action = { - type: ActionTypes.LoadApiKeys, - payload, - }; - - const result = apiKeysReducer(initialApiKeysState, action); - - expect(result.keys).toEqual(payload); + reducerTester() + .givenReducer(apiKeysReducer, { ...initialApiKeysState }) + .whenActionIsDispatched(apiKeysLoaded(getMultipleMockKeys(4))) + .thenStateShouldEqual({ + ...initialApiKeysState, + keys: getMultipleMockKeys(4), + hasFetched: true, + }); }); it('should set search query', () => { - const payload = 'test query'; - - const action: Action = { - type: ActionTypes.SetApiKeysSearchQuery, - payload, - }; - - const result = apiKeysReducer(initialApiKeysState, action); - - expect(result.searchQuery).toEqual('test query'); + reducerTester() + .givenReducer(apiKeysReducer, { ...initialApiKeysState }) + .whenActionIsDispatched(setSearchQuery('test query')) + .thenStateShouldEqual({ + ...initialApiKeysState, + searchQuery: 'test query', + }); }); }); diff --git a/public/app/features/api-keys/state/reducers.ts b/public/app/features/api-keys/state/reducers.ts index 1bdf5fe8a13..23e84ad0ff2 100644 --- a/public/app/features/api-keys/state/reducers.ts +++ b/public/app/features/api-keys/state/reducers.ts @@ -1,5 +1,6 @@ -import { ApiKeysState } from 'app/types'; -import { Action, ActionTypes } from './actions'; +import { createSlice } from '@reduxjs/toolkit'; + +import { ApiKeysState } from 'app/types'; export const initialApiKeysState: ApiKeysState = { keys: [], @@ -8,15 +9,22 @@ export const initialApiKeysState: ApiKeysState = { includeExpired: false, }; -export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => { - switch (action.type) { - case ActionTypes.LoadApiKeys: +const apiKeysSlice = createSlice({ + name: 'apiKeys', + initialState: initialApiKeysState, + reducers: { + apiKeysLoaded: (state, action): ApiKeysState => { return { ...state, hasFetched: true, keys: action.payload }; - case ActionTypes.SetApiKeysSearchQuery: + }, + setSearchQuery: (state, action): ApiKeysState => { return { ...state, searchQuery: action.payload }; - } - return state; -}; + }, + }, +}); + +export const { setSearchQuery, apiKeysLoaded } = apiKeysSlice.actions; + +export const apiKeysReducer = apiKeysSlice.reducer; export default { apiKeys: apiKeysReducer, diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 6ee4611ae19..e3ae6a84faa 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -1,16 +1,20 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { DashboardPage, Props, State, mapStateToProps } from './DashboardPage'; +import { DashboardPage, mapStateToProps, Props, State } from './DashboardPage'; import { DashboardModel } from '../state'; import { cleanUpDashboard } from '../state/actions'; -import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock, mockActionCreator } from 'app/core/redux'; -import { DashboardRouteInfo, DashboardInitPhase } from 'app/types'; -import { updateLocation } from 'app/core/actions'; +import { + mockToolkitActionCreator, + mockToolkitActionCreatorWithoutPayload, + ToolkitActionCreatorWithoutPayloadMockType, +} from 'test/core/redux/mocks'; +import { DashboardInitPhase, DashboardRouteInfo } from 'app/types'; +import { notifyApp, updateLocation } from 'app/core/actions'; jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({})); interface ScenarioContext { - cleanUpDashboardMock: NoPayloadActionCreatorMock; + cleanUpDashboardMock: ToolkitActionCreatorWithoutPayloadMockType; dashboard?: DashboardModel; setDashboardProp: (overrides?: any, metaOverrides?: any) => void; wrapper?: ShallowWrapper; @@ -43,7 +47,7 @@ function dashboardPageScenario(description: string, scenarioFn: (ctx: ScenarioCo let setupFn: () => void; const ctx: ScenarioContext = { - cleanUpDashboardMock: getNoPayloadActionCreatorMock(cleanUpDashboard), + cleanUpDashboardMock: mockToolkitActionCreatorWithoutPayload(cleanUpDashboard), setup: fn => { setupFn = fn; }, @@ -63,8 +67,8 @@ function dashboardPageScenario(description: string, scenarioFn: (ctx: ScenarioCo initPhase: DashboardInitPhase.NotStarted, isInitSlow: false, initDashboard: jest.fn(), - updateLocation: mockActionCreator(updateLocation), - notifyApp: jest.fn(), + updateLocation: mockToolkitActionCreator(updateLocation), + notifyApp: mockToolkitActionCreator(notifyApp), cleanUpDashboard: ctx.cleanUpDashboardMock, dashboard: null, }; @@ -243,7 +247,7 @@ describe('DashboardPage', () => { }); it('Should call clean up action', () => { - expect(ctx.cleanUpDashboardMock.calls).toBe(1); + expect(ctx.cleanUpDashboardMock).toHaveBeenCalledTimes(1); }); }); diff --git a/public/app/features/dashboard/panel_editor/PanelEditor.tsx b/public/app/features/dashboard/panel_editor/PanelEditor.tsx index 967e9a22eed..1638e0e2d95 100644 --- a/public/app/features/dashboard/panel_editor/PanelEditor.tsx +++ b/public/app/features/dashboard/panel_editor/PanelEditor.tsx @@ -14,8 +14,8 @@ import { AlertTab } from '../../alerting/AlertTab'; import { PanelModel } from '../state/PanelModel'; import { DashboardModel } from '../state/DashboardModel'; import { StoreState } from '../../../types'; -import { PanelEditorTab, PanelEditorTabIds } from './state/reducers'; -import { changePanelEditorTab, panelEditorCleanUp, refreshPanelEditor } from './state/actions'; +import { panelEditorCleanUp, PanelEditorTab, PanelEditorTabIds } from './state/reducers'; +import { changePanelEditorTab, refreshPanelEditor } from './state/actions'; import { getActiveTabAndTabs } from './state/selectors'; interface PanelEditorProps { diff --git a/public/app/features/dashboard/panel_editor/state/actions.test.ts b/public/app/features/dashboard/panel_editor/state/actions.test.ts index 73b3a58181f..0803a4761d2 100644 --- a/public/app/features/dashboard/panel_editor/state/actions.test.ts +++ b/public/app/features/dashboard/panel_editor/state/actions.test.ts @@ -1,6 +1,6 @@ import { thunkTester } from '../../../../../test/core/thunk/thunkTester'; -import { initialState, getPanelEditorTab, PanelEditorTabIds } from './reducers'; -import { refreshPanelEditor, panelEditorInitCompleted, changePanelEditorTab } from './actions'; +import { getPanelEditorTab, initialState, panelEditorInitCompleted, PanelEditorTabIds } from './reducers'; +import { changePanelEditorTab, refreshPanelEditor } from './actions'; import { updateLocation } from '../../../../core/actions'; describe('refreshPanelEditor', () => { diff --git a/public/app/features/dashboard/panel_editor/state/actions.ts b/public/app/features/dashboard/panel_editor/state/actions.ts index 7b07f04cc2d..bcaa0b9eddf 100644 --- a/public/app/features/dashboard/panel_editor/state/actions.ts +++ b/public/app/features/dashboard/panel_editor/state/actions.ts @@ -1,19 +1,7 @@ -import { actionCreatorFactory } from '../../../../core/redux'; -import { PanelEditorTabIds, PanelEditorTab, getPanelEditorTab } from './reducers'; +import { getPanelEditorTab, panelEditorInitCompleted, PanelEditorTab, PanelEditorTabIds } from './reducers'; import { ThunkResult } from '../../../../types'; import { updateLocation } from '../../../../core/actions'; -export interface PanelEditorInitCompleted { - activeTab: PanelEditorTabIds; - tabs: PanelEditorTab[]; -} - -export const panelEditorInitCompleted = actionCreatorFactory( - 'PANEL_EDITOR_INIT_COMPLETED' -).create(); - -export const panelEditorCleanUp = actionCreatorFactory('PANEL_EDITOR_CLEAN_UP').create(); - export const refreshPanelEditor = (props: { hasQueriesTab?: boolean; usesGraphPlugin?: boolean; diff --git a/public/app/features/dashboard/panel_editor/state/reducers.test.ts b/public/app/features/dashboard/panel_editor/state/reducers.test.ts index b9f2196eb28..4548f866429 100644 --- a/public/app/features/dashboard/panel_editor/state/reducers.test.ts +++ b/public/app/features/dashboard/panel_editor/state/reducers.test.ts @@ -1,6 +1,13 @@ import { reducerTester } from '../../../../../test/core/redux/reducerTester'; -import { initialState, panelEditorReducer, PanelEditorTabIds, PanelEditorTab, getPanelEditorTab } from './reducers'; -import { panelEditorInitCompleted, panelEditorCleanUp } from './actions'; +import { + getPanelEditorTab, + initialState, + panelEditorCleanUp, + panelEditorInitCompleted, + panelEditorReducer, + PanelEditorTab, + PanelEditorTabIds, +} from './reducers'; describe('panelEditorReducer', () => { describe('when panelEditorInitCompleted is dispatched', () => { diff --git a/public/app/features/dashboard/panel_editor/state/reducers.ts b/public/app/features/dashboard/panel_editor/state/reducers.ts index 746525c8356..cb651f5717c 100644 --- a/public/app/features/dashboard/panel_editor/state/reducers.ts +++ b/public/app/features/dashboard/panel_editor/state/reducers.ts @@ -1,5 +1,9 @@ -import { reducerFactory } from '../../../../core/redux'; -import { panelEditorCleanUp, panelEditorInitCompleted } from './actions'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface PanelEditorInitCompleted { + activeTab: PanelEditorTabIds; + tabs: PanelEditorTab[]; +} export interface PanelEditorTab { id: string; @@ -37,10 +41,11 @@ export const initialState: PanelEditorState = { tabs: [], }; -export const panelEditorReducer = reducerFactory(initialState) - .addMapper({ - filter: panelEditorInitCompleted, - mapper: (state, action): PanelEditorState => { +const panelEditorSlice = createSlice({ + name: 'panelEditor', + initialState, + reducers: { + panelEditorInitCompleted: (state, action: PayloadAction): PanelEditorState => { const { activeTab, tabs } = action.payload; return { ...state, @@ -48,9 +53,10 @@ export const panelEditorReducer = reducerFactory(initialState) tabs, }; }, - }) - .addMapper({ - filter: panelEditorCleanUp, - mapper: (): PanelEditorState => initialState, - }) - .create(); + panelEditorCleanUp: (state, action: PayloadAction): PanelEditorState => initialState, + }, +}); + +export const { panelEditorCleanUp, panelEditorInitCompleted } = panelEditorSlice.actions; + +export const panelEditorReducer = panelEditorSlice.reducer; diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 35da162bee9..8fcc935604e 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -1,41 +1,41 @@ // Services & Utils +import { createAction } from '@reduxjs/toolkit'; import { getBackendSrv } from '@grafana/runtime'; -import { actionCreatorFactory } from 'app/core/redux'; import { createSuccessNotification } from 'app/core/copy/appNotification'; // Actions import { loadPluginDashboards } from '../../plugins/state/actions'; import { notifyApp } from 'app/core/actions'; // Types import { - ThunkResult, DashboardAcl, DashboardAclDTO, - PermissionLevel, DashboardAclUpdateDTO, - NewDashboardAclItem, - MutableDashboard, DashboardInitError, + MutableDashboard, + NewDashboardAclItem, + PermissionLevel, + ThunkResult, } from 'app/types'; -export const loadDashboardPermissions = actionCreatorFactory('LOAD_DASHBOARD_PERMISSIONS').create(); +export const loadDashboardPermissions = createAction('dashboard/loadDashboardPermissions'); -export const dashboardInitFetching = actionCreatorFactory('DASHBOARD_INIT_FETCHING').create(); +export const dashboardInitFetching = createAction('dashboard/dashboardInitFetching'); -export const dashboardInitServices = actionCreatorFactory('DASHBOARD_INIT_SERVICES').create(); +export const dashboardInitServices = createAction('dashboard/dashboardInitServices'); -export const dashboardInitSlow = actionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create(); +export const dashboardInitSlow = createAction('dashboard/dashboardInitSlow'); -export const dashboardInitCompleted = actionCreatorFactory('DASHBOARD_INIT_COMLETED').create(); +export const dashboardInitCompleted = createAction('dashboard/dashboardInitCompleted'); /* * Unrecoverable init failure (fetch or model creation failed) */ -export const dashboardInitFailed = actionCreatorFactory('DASHBOARD_INIT_FAILED').create(); +export const dashboardInitFailed = createAction('dashboard/dashboardInitFailed'); /* * When leaving dashboard, resets state * */ -export const cleanUpDashboard = actionCreatorFactory('DASHBOARD_CLEAN_UP').create(); +export const cleanUpDashboard = createAction('dashboard/cleanUpDashboard'); export function getDashboardPermissions(id: number): ThunkResult { return async dispatch => { diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts index 634716cb903..c09c2b9c5fb 100644 --- a/public/app/features/dashboard/state/initDashboard.test.ts +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -3,8 +3,9 @@ import thunk from 'redux-thunk'; import { initDashboard, InitDashboardArgs } from './initDashboard'; import { DashboardRouteInfo } from 'app/types'; import { getBackendSrv } from 'app/core/services/backend_srv'; -import { dashboardInitFetching, dashboardInitCompleted, dashboardInitServices } from './actions'; +import { dashboardInitCompleted, dashboardInitFetching, dashboardInitServices } from './actions'; import { resetExploreAction } from 'app/features/explore/state/actionTypes'; +import { updateLocation } from '../../../core/actions'; jest.mock('app/core/services/backend_srv'); @@ -150,7 +151,7 @@ describeInitScenario('Initializing new dashboard', ctx => { }); it('Should update location with orgId query param', () => { - expect(ctx.actions[2].type).toBe('UPDATE_LOCATION'); + expect(ctx.actions[2].type).toBe(updateLocation.type); expect(ctx.actions[2].payload.query.orgId).toBe(12); }); @@ -180,7 +181,7 @@ describeInitScenario('Initializing home dashboard', ctx => { }); it('Should redirect to custom home dashboard', () => { - expect(ctx.actions[1].type).toBe('UPDATE_LOCATION'); + expect(ctx.actions[1].type).toBe(updateLocation.type); expect(ctx.actions[1].payload.path).toBe('/u/123/my-home'); }); }); @@ -217,7 +218,7 @@ describeInitScenario('Initializing existing dashboard', ctx => { }); it('Should update location with orgId query param', () => { - expect(ctx.actions[2].type).toBe('UPDATE_LOCATION'); + expect(ctx.actions[2].type).toBe(updateLocation.type); expect(ctx.actions[2].payload.query.orgId).toBe(12); }); diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index a65e85c3011..813eb2f6a6a 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -1,17 +1,17 @@ -import { DashboardState, DashboardInitPhase } from 'app/types'; +import { Action } from 'redux'; +import { DashboardInitPhase, DashboardState } from 'app/types'; import { - loadDashboardPermissions, - dashboardInitFetching, - dashboardInitSlow, - dashboardInitServices, - dashboardInitFailed, - dashboardInitCompleted, cleanUpDashboard, + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitFetching, + dashboardInitServices, + dashboardInitSlow, + loadDashboardPermissions, } from './actions'; -import { reducerFactory } from 'app/core/redux'; import { processAclItems } from 'app/core/utils/acl'; -import { DashboardModel } from './DashboardModel'; import { panelEditorReducer } from '../panel_editor/state/reducers'; +import { DashboardModel } from './DashboardModel'; export const initialState: DashboardState = { initPhase: DashboardInitPhase.NotStarted, @@ -20,71 +20,75 @@ export const initialState: DashboardState = { permissions: [], }; -export const dashboardReducer = reducerFactory(initialState) - .addMapper({ - filter: loadDashboardPermissions, - mapper: (state, action) => ({ +// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated. +// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice +// because the state would become frozen and during run time we would get errors because Angular would try to mutate +// the frozen state. +// https://github.com/reduxjs/redux-toolkit/issues/242 +export const dashboardReducer = (state: DashboardState = initialState, action: Action): DashboardState => { + if (loadDashboardPermissions.match(action)) { + return { ...state, permissions: processAclItems(action.payload), - }), - }) - .addMapper({ - filter: dashboardInitFetching, - mapper: state => ({ + }; + } + + if (dashboardInitFetching.match(action)) { + return { ...state, initPhase: DashboardInitPhase.Fetching, - }), - }) - .addMapper({ - filter: dashboardInitServices, - mapper: state => ({ + }; + } + + if (dashboardInitServices.match(action)) { + return { ...state, initPhase: DashboardInitPhase.Services, - }), - }) - .addMapper({ - filter: dashboardInitSlow, - mapper: state => ({ + }; + } + + if (dashboardInitSlow.match(action)) { + return { ...state, isInitSlow: true, - }), - }) - .addMapper({ - filter: dashboardInitFailed, - mapper: (state, action) => ({ + }; + } + + if (dashboardInitFailed.match(action)) { + return { ...state, initPhase: DashboardInitPhase.Failed, isInitSlow: false, initError: action.payload, model: new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }), - }), - }) - .addMapper({ - filter: dashboardInitCompleted, - mapper: (state, action) => ({ + }; + } + + if (dashboardInitCompleted.match(action)) { + return { ...state, initPhase: DashboardInitPhase.Completed, model: action.payload, isInitSlow: false, - }), - }) - .addMapper({ - filter: cleanUpDashboard, - mapper: (state, action) => { - // Destroy current DashboardModel - // Very important as this removes all dashboard event listeners - state.model.destroy(); + }; + } - return { - ...state, - initPhase: DashboardInitPhase.NotStarted, - model: null, - isInitSlow: false, - initError: null, - }; - }, - }) - .create(); + if (cleanUpDashboard.match(action)) { + // Destroy current DashboardModel + // Very important as this removes all dashboard event listeners + state.model.destroy(); + + return { + ...state, + initPhase: DashboardInitPhase.NotStarted, + model: null, + isInitSlow: false, + initError: null, + }; + } + + return state; +}; export default { dashboard: dashboardReducer, diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx index 79b96d45dd1..e6735cd9816 100644 --- a/public/app/features/datasources/DataSourcesListPage.test.tsx +++ b/public/app/features/datasources/DataSourcesListPage.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { DataSourceSettings, NavModel } from '@grafana/data'; + import { DataSourcesListPage, Props } from './DataSourcesListPage'; -import { DataSourceSettings } from '@grafana/data'; -import { NavModel } from '@grafana/data'; import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; import { getMockDataSources } from './__mocks__/dataSourcesMocks'; -import { setDataSourcesSearchQuery, setDataSourcesLayoutMode } from './state/actions'; +import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/reducers'; const setup = (propOverrides?: object) => { const props: Props = { diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx index f13f5b91335..f7ad8834a80 100644 --- a/public/app/features/datasources/DataSourcesListPage.tsx +++ b/public/app/features/datasources/DataSourcesListPage.tsx @@ -2,21 +2,17 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; - // Components import Page from 'app/core/components/Page/Page'; import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import DataSourcesList from './DataSourcesList'; - // Types -import { DataSourceSettings } from '@grafana/data'; -import { NavModel } from '@grafana/data'; +import { DataSourceSettings, NavModel } from '@grafana/data'; import { StoreState } from 'app/types'; import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector'; - // Actions -import { loadDataSources, setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions'; +import { loadDataSources } from './state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; import { @@ -25,6 +21,7 @@ import { getDataSourcesLayoutMode, getDataSourcesSearchQuery, } from './state/selectors'; +import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/reducers'; export interface Props { navModel: NavModel; diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx index 90786496e68..91a930b9679 100644 --- a/public/app/features/datasources/NewDataSourcePage.tsx +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -7,10 +7,11 @@ import { List } from '@grafana/ui'; import { e2e } from '@grafana/e2e'; import Page from 'app/core/components/Page/Page'; -import { StoreState, DataSourcePluginCategory } from 'app/types'; -import { addDataSource, loadDataSourcePlugins, setDataSourceTypeSearchQuery } from './state/actions'; +import { DataSourcePluginCategory, StoreState } from 'app/types'; +import { addDataSource, loadDataSourcePlugins } from './state/actions'; import { getDataSourcePlugins } from './state/selectors'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; +import { setDataSourceTypeSearchQuery } from './state/reducers'; export interface Props { navModel: NavModel; diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx index aaef1841220..43056f1fad4 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { shallow } from 'enzyme'; import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage'; -import { DataSourceSettings, DataSourcePlugin, DataSourceConstructor, NavModel } from '@grafana/data'; +import { DataSourceConstructor, DataSourcePlugin, DataSourceSettings, NavModel } from '@grafana/data'; import { getMockDataSource } from '../__mocks__/dataSourcesMocks'; import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; -import { setDataSourceName, setIsDefault, dataSourceLoaded } from '../state/actions'; +import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; const pluginMock = new DataSourcePlugin({} as DataSourceConstructor); diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx index 4db153a0400..72a5803428b 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -15,14 +15,7 @@ import { getBackendSrv } from 'app/core/services/backend_srv'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; // Actions & selectors import { getDataSource, getDataSourceMeta } from '../state/selectors'; -import { - dataSourceLoaded, - deleteDataSource, - loadDataSource, - setDataSourceName, - setIsDefault, - updateDataSource, -} from '../state/actions'; +import { deleteDataSource, loadDataSource, updateDataSource } from '../state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; import { getRouteParamsId } from 'app/core/selectors/location'; // Types @@ -32,6 +25,7 @@ import { DataSourcePluginMeta, DataSourceSettings, NavModel } from '@grafana/dat import { getDataSourceLoadingNav } from '../state/navModel'; import PluginStateinfo from 'app/features/plugins/PluginStateInfo'; import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader'; +import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; export interface Props { navModel: NavModel; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 35ed973da95..0557b341027 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -1,29 +1,21 @@ import config from '../../../core/config'; import { getBackendSrv } from '@grafana/runtime'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector'; import { updateLocation, updateNavIndex } from 'app/core/actions'; import { buildNavModel } from './navModel'; -import { DataSourceSettings, DataSourcePluginMeta } from '@grafana/data'; -import { ThunkResult, DataSourcePluginCategory } from 'app/types'; -import { actionCreatorFactory } from 'app/core/redux'; +import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data'; +import { DataSourcePluginCategory, ThunkResult } from 'app/types'; import { getPluginSettings } from 'app/features/plugins/PluginSettingsCache'; import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader'; +import { + dataSourceLoaded, + dataSourceMetaLoaded, + dataSourcePluginsLoad, + dataSourcePluginsLoaded, + dataSourcesLoaded, +} from './reducers'; import { buildCategories } from './buildCategories'; -export const dataSourceLoaded = actionCreatorFactory('LOAD_DATA_SOURCE').create(); -export const dataSourcesLoaded = actionCreatorFactory('LOAD_DATA_SOURCES').create(); -export const dataSourceMetaLoaded = actionCreatorFactory('LOAD_DATA_SOURCE_META').create(); -export const dataSourcePluginsLoad = actionCreatorFactory('LOAD_DATA_SOURCE_PLUGINS').create(); -export const dataSourcePluginsLoaded = actionCreatorFactory( - 'LOADED_DATA_SOURCE_PLUGINS' -).create(); -export const setDataSourcesSearchQuery = actionCreatorFactory('SET_DATA_SOURCES_SEARCH_QUERY').create(); -export const setDataSourcesLayoutMode = actionCreatorFactory('SET_DATA_SOURCES_LAYOUT_MODE').create(); -export const setDataSourceTypeSearchQuery = actionCreatorFactory('SET_DATA_SOURCE_TYPE_SEARCH_QUERY').create(); -export const setDataSourceName = actionCreatorFactory('SET_DATA_SOURCE_NAME').create(); -export const setIsDefault = actionCreatorFactory('SET_IS_DEFAULT').create(); - export interface DataSourceTypesLoadedPayload { plugins: DataSourcePluginMeta[]; categories: DataSourcePluginCategory[]; diff --git a/public/app/features/datasources/state/reducers.test.ts b/public/app/features/datasources/state/reducers.test.ts index 61e79c94de9..286f756b69c 100644 --- a/public/app/features/datasources/state/reducers.test.ts +++ b/public/app/features/datasources/state/reducers.test.ts @@ -1,21 +1,22 @@ import { reducerTester } from 'test/core/redux/reducerTester'; -import { dataSourcesReducer, initialState } from './reducers'; import { - dataSourcesLoaded, dataSourceLoaded, - setDataSourcesSearchQuery, - setDataSourcesLayoutMode, + dataSourceMetaLoaded, dataSourcePluginsLoad, dataSourcePluginsLoaded, - setDataSourceTypeSearchQuery, - dataSourceMetaLoaded, + dataSourcesLoaded, + dataSourcesReducer, + initialState, setDataSourceName, + setDataSourcesLayoutMode, + setDataSourcesSearchQuery, + setDataSourceTypeSearchQuery, setIsDefault, -} from './actions'; -import { getMockDataSources, getMockDataSource } from '../__mocks__/dataSourcesMocks'; +} from './reducers'; +import { getMockDataSource, getMockDataSources } from '../__mocks__/dataSourcesMocks'; import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector'; import { DataSourcesState } from 'app/types'; -import { PluginMetaInfo, PluginType, PluginMeta } from '@grafana/data'; +import { PluginMeta, PluginMetaInfo, PluginType } from '@grafana/data'; const mockPlugin = () => ({ @@ -74,7 +75,7 @@ describe('dataSourcesReducer', () => { }); }); - describe('when dataSourceTypesLoad is dispatched', () => { + describe('when dataSourcePluginsLoad is dispatched', () => { it('then state should be correct', () => { const state: DataSourcesState = { ...initialState, plugins: [mockPlugin()] }; @@ -85,7 +86,7 @@ describe('dataSourcesReducer', () => { }); }); - describe('when dataSourceTypesLoaded is dispatched', () => { + describe('when dataSourcePluginsLoaded is dispatched', () => { it('then state should be correct', () => { const dataSourceTypes = [mockPlugin()]; const state: DataSourcesState = { ...initialState, isLoadingDataSources: true }; diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 52361c1823a..90edb44220e 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -1,19 +1,9 @@ +import { AnyAction, createAction } from '@reduxjs/toolkit'; +import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data'; + import { DataSourcesState } from 'app/types'; -import { DataSourceSettings, DataSourcePluginMeta } from '@grafana/data'; -import { - dataSourceLoaded, - dataSourcesLoaded, - setDataSourcesSearchQuery, - setDataSourcesLayoutMode, - dataSourcePluginsLoad, - dataSourcePluginsLoaded, - setDataSourceTypeSearchQuery, - dataSourceMetaLoaded, - setDataSourceName, - setIsDefault, -} from './actions'; -import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector'; -import { reducerFactory } from 'app/core/redux'; +import { LayoutMode, LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector'; +import { DataSourceTypesLoadedPayload } from './actions'; export const initialState: DataSourcesState = { dataSources: [], @@ -29,61 +19,80 @@ export const initialState: DataSourcesState = { dataSourceMeta: {} as DataSourcePluginMeta, }; -export const dataSourcesReducer = reducerFactory(initialState) - .addMapper({ - filter: dataSourcesLoaded, - mapper: (state, action) => ({ +export const dataSourceLoaded = createAction('dataSources/dataSourceLoaded'); +export const dataSourcesLoaded = createAction('dataSources/dataSourcesLoaded'); +export const dataSourceMetaLoaded = createAction('dataSources/dataSourceMetaLoaded'); +export const dataSourcePluginsLoad = createAction('dataSources/dataSourcePluginsLoad'); +export const dataSourcePluginsLoaded = createAction( + 'dataSources/dataSourcePluginsLoaded' +); +export const setDataSourcesSearchQuery = createAction('dataSources/setDataSourcesSearchQuery'); +export const setDataSourcesLayoutMode = createAction('dataSources/setDataSourcesLayoutMode'); +export const setDataSourceTypeSearchQuery = createAction('dataSources/setDataSourceTypeSearchQuery'); +export const setDataSourceName = createAction('dataSources/setDataSourceName'); +export const setIsDefault = createAction('dataSources/setIsDefault'); + +// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated. +// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice +// because the state would become frozen and during run time we would get errors because Angular would try to mutate +// the frozen state. +// https://github.com/reduxjs/redux-toolkit/issues/242 +export const dataSourcesReducer = (state: DataSourcesState = initialState, action: AnyAction): DataSourcesState => { + if (dataSourcesLoaded.match(action)) { + return { ...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: dataSourcePluginsLoad, - mapper: state => ({ ...state, plugins: [], isLoadingDataSources: true }), - }) - .addMapper({ - filter: dataSourcePluginsLoaded, - mapper: (state, action) => ({ + }; + } + + if (dataSourceLoaded.match(action)) { + return { ...state, dataSource: action.payload }; + } + + if (setDataSourcesSearchQuery.match(action)) { + return { ...state, searchQuery: action.payload }; + } + + if (setDataSourcesLayoutMode.match(action)) { + return { ...state, layoutMode: action.payload }; + } + + if (dataSourcePluginsLoad.match(action)) { + return { ...state, plugins: [], isLoadingDataSources: true }; + } + + if (dataSourcePluginsLoaded.match(action)) { + return { ...state, plugins: action.payload.plugins, categories: action.payload.categories, 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) => ({ + }; + } + + if (setDataSourceTypeSearchQuery.match(action)) { + return { ...state, dataSourceTypeSearchQuery: action.payload }; + } + + if (dataSourceMetaLoaded.match(action)) { + return { ...state, dataSourceMeta: action.payload }; + } + + if (setDataSourceName.match(action)) { + return { ...state, dataSource: { ...state.dataSource, name: action.payload } }; + } + + if (setIsDefault.match(action)) { + return { ...state, dataSource: { ...state.dataSource, isDefault: action.payload }, - }), - }) - .create(); + }; + } + + return state; +}; export default { dataSources: dataSourcesReducer, diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index 0ee3135712a..eab71357c8d 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/public/app/features/explore/state/actionTypes.ts @@ -1,48 +1,21 @@ // Types import { Unsubscribable } from 'rxjs'; -import { Emitter } from 'app/core/core'; +import { createAction } from '@reduxjs/toolkit'; +import { Emitter } from 'app/core/core'; import { + AbsoluteTimeRange, DataQuery, DataSourceApi, - QueryFixAction, - PanelData, HistoryItem, - LogLevel, - TimeRange, LoadingState, - AbsoluteTimeRange, + LogLevel, + PanelData, + QueryFixAction, + TimeRange, } from '@grafana/data'; -import { ExploreId, ExploreItemState, ExploreUIState, ExploreMode } from 'app/types/explore'; -import { actionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory'; +import { ExploreId, ExploreItemState, ExploreMode, ExploreUIState } from 'app/types/explore'; -/** Higher order actions - * - */ -export enum ActionTypes { - SplitOpen = 'explore/SPLIT_OPEN', - ResetExplore = 'explore/RESET_EXPLORE', - SyncTimes = 'explore/SYNC_TIMES', -} -export interface SplitOpenAction { - type: ActionTypes.SplitOpen; - payload: { - itemState: ExploreItemState; - }; -} - -export interface ResetExploreAction { - type: ActionTypes.ResetExplore; - payload: {}; -} - -export interface SyncTimesAction { - type: ActionTypes.SyncTimes; - payload: { syncedTimes: boolean }; -} -/** Lower order actions - * - */ export interface AddQueryRowPayload { exploreId: ExploreId; index: number; @@ -218,77 +191,67 @@ export interface ResetExplorePayload { /** * Adds a query row after the row with the given index. */ -export const addQueryRowAction = actionCreatorFactory('explore/ADD_QUERY_ROW').create(); +export const addQueryRowAction = createAction('explore/addQueryRow'); /** * Change the mode of Explore. */ -export const changeModeAction = actionCreatorFactory('explore/CHANGE_MODE').create(); +export const changeModeAction = createAction('explore/changeMode'); /** * Query change handler for the query row with the given index. * If `override` is reset the query modifications and run the queries. Use this to set queries via a link. */ -export const changeQueryAction = actionCreatorFactory('explore/CHANGE_QUERY').create(); +export const changeQueryAction = createAction('explore/changeQuery'); /** * Keep track of the Explore container size, in particular the width. * The width will be used to calculate graph intervals (number of datapoints). */ -export const changeSizeAction = actionCreatorFactory('explore/CHANGE_SIZE').create(); +export const changeSizeAction = createAction('explore/changeSize'); /** * Change the time range of Explore. Usually called from the Timepicker or a graph interaction. */ -export const changeRefreshIntervalAction = actionCreatorFactory( - 'explore/CHANGE_REFRESH_INTERVAL' -).create(); +export const changeRefreshIntervalAction = createAction('explore/changeRefreshInterval'); /** * Clear all queries and results. */ -export const clearQueriesAction = actionCreatorFactory('explore/CLEAR_QUERIES').create(); +export const clearQueriesAction = createAction('explore/clearQueries'); /** * Clear origin panel id. */ -export const clearOriginAction = actionCreatorFactory('explore/CLEAR_ORIGIN').create(); +export const clearOriginAction = createAction('explore/clearOrigin'); /** * Highlight expressions in the log results */ -export const highlightLogsExpressionAction = actionCreatorFactory( - 'explore/HIGHLIGHT_LOGS_EXPRESSION' -).create(); +export const highlightLogsExpressionAction = createAction( + 'explore/highlightLogsExpression' +); /** * Initialize Explore state with state from the URL and the React component. * Call this only on components for with the Explore state has not been initialized. */ -export const initializeExploreAction = actionCreatorFactory( - 'explore/INITIALIZE_EXPLORE' -).create(); +export const initializeExploreAction = createAction('explore/initializeExplore'); /** * Display an error when no datasources have been configured */ -export const loadDatasourceMissingAction = actionCreatorFactory( - 'explore/LOAD_DATASOURCE_MISSING' -).create(); +export const loadDatasourceMissingAction = createAction('explore/loadDatasourceMissing'); /** * Start the async process of loading a datasource to display a loading indicator */ -export const loadDatasourcePendingAction = actionCreatorFactory( - 'explore/LOAD_DATASOURCE_PENDING' -).create(); +export const loadDatasourcePendingAction = createAction('explore/loadDatasourcePending'); /** * Datasource loading was completed. */ -export const loadDatasourceReadyAction = actionCreatorFactory( - 'explore/LOAD_DATASOURCE_READY' -).create(); +export const loadDatasourceReadyAction = createAction('explore/loadDatasourceReady'); /** * Action to modify a query given a datasource-specific modifier action. @@ -297,97 +260,86 @@ export const loadDatasourceReadyAction = actionCreatorFactory('explore/MODIFY_QUERIES').create(); +export const modifyQueriesAction = createAction('explore/modifyQueries'); -export const queryStreamUpdatedAction = actionCreatorFactory( - 'explore/QUERY_STREAM_UPDATED' -).create(); +export const queryStreamUpdatedAction = createAction('explore/queryStreamUpdated'); -export const queryStoreSubscriptionAction = actionCreatorFactory( - 'explore/QUERY_STORE_SUBSCRIPTION' -).create(); +export const queryStoreSubscriptionAction = createAction( + 'explore/queryStoreSubscription' +); /** * Remove query row of the given index, as well as associated query results. */ -export const removeQueryRowAction = actionCreatorFactory('explore/REMOVE_QUERY_ROW').create(); +export const removeQueryRowAction = createAction('explore/removeQueryRow'); /** * Start a scan for more results using the given scanner. * @param exploreId Explore area * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range */ -export const scanStartAction = actionCreatorFactory('explore/SCAN_START').create(); +export const scanStartAction = createAction('explore/scanStart'); /** * Stop any scanning for more results. */ -export const scanStopAction = actionCreatorFactory('explore/SCAN_STOP').create(); +export const scanStopAction = createAction('explore/scanStop'); /** * Reset queries to the given queries. Any modifications will be discarded. * Use this action for clicks on query examples. Triggers a query run. */ -export const setQueriesAction = actionCreatorFactory('explore/SET_QUERIES').create(); +export const setQueriesAction = createAction('explore/setQueries'); /** * Close the split view and save URL state. */ -export const splitCloseAction = actionCreatorFactory('explore/SPLIT_CLOSE').create(); +export const splitCloseAction = createAction('explore/splitClose'); /** * Open the split view and copy the left state to be the right state. * The right state is automatically initialized. * The copy keeps all query modifications but wipes the query results. */ -export const splitOpenAction = actionCreatorFactory('explore/SPLIT_OPEN').create(); +export const splitOpenAction = createAction('explore/splitOpen'); -export const syncTimesAction = actionCreatorFactory('explore/SYNC_TIMES').create(); +export const syncTimesAction = createAction('explore/syncTimes'); /** * Update state of Explores UI elements (panels visiblity and deduplication strategy) */ -export const updateUIStateAction = actionCreatorFactory('explore/UPDATE_UI_STATE').create(); +export const updateUIStateAction = createAction('explore/updateUIState'); /** * Expand/collapse the table result viewer. When collapsed, table queries won't be run. */ -export const toggleTableAction = actionCreatorFactory('explore/TOGGLE_TABLE').create(); +export const toggleTableAction = createAction('explore/toggleTable'); /** * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run. */ -export const toggleGraphAction = actionCreatorFactory('explore/TOGGLE_GRAPH').create(); +export const toggleGraphAction = createAction('explore/toggleGraph'); /** * Updates datasource instance before datasouce loading has started */ -export const updateDatasourceInstanceAction = actionCreatorFactory( - 'explore/UPDATE_DATASOURCE_INSTANCE' -).create(); +export const updateDatasourceInstanceAction = createAction( + 'explore/updateDatasourceInstance' +); -export const toggleLogLevelAction = actionCreatorFactory('explore/TOGGLE_LOG_LEVEL').create(); +export const toggleLogLevelAction = createAction('explore/toggleLogLevel'); /** * Resets state for explore. */ -export const resetExploreAction = actionCreatorFactory('explore/RESET_EXPLORE').create(); -export const queriesImportedAction = actionCreatorFactory('explore/QueriesImported').create(); +export const resetExploreAction = createAction('explore/resetExplore'); +export const queriesImportedAction = createAction('explore/queriesImported'); -export const historyUpdatedAction = actionCreatorFactory('explore/HISTORY_UPDATED').create(); +export const historyUpdatedAction = createAction('explore/historyUpdated'); -export const setUrlReplacedAction = actionCreatorFactory('explore/SET_URL_REPLACED').create(); +export const setUrlReplacedAction = createAction('explore/setUrlReplaced'); -export const changeRangeAction = actionCreatorFactory('explore/CHANGE_RANGE').create(); +export const changeRangeAction = createAction('explore/changeRange'); -export const changeLoadingStateAction = actionCreatorFactory( - 'changeLoadingStateAction' -).create(); +export const changeLoadingStateAction = createAction('changeLoadingState'); -export const setPausedStateAction = actionCreatorFactory('explore/SET_PAUSED_STATE').create(); - -export type HigherOrderAction = - | ActionOf - | SplitOpenAction - | ResetExploreAction - | SyncTimesAction - | ActionOf; +export const setPausedStateAction = createAction('explore/setPausedState'); diff --git a/public/app/features/explore/state/actions.test.ts b/public/app/features/explore/state/actions.test.ts index 1684266137d..767274b6c94 100644 --- a/public/app/features/explore/state/actions.test.ts +++ b/public/app/features/explore/state/actions.test.ts @@ -1,5 +1,8 @@ -import { changeDatasource, loadDatasource, navigateToExplore, refreshExplore } from './actions'; +import { PayloadAction } from '@reduxjs/toolkit'; +import { DataQuery, DefaultTimeZone, LogsDedupStrategy, RawTimeRange, toUtc } from '@grafana/data'; + import * as Actions from './actions'; +import { changeDatasource, loadDatasource, navigateToExplore, refreshExplore } from './actions'; import { ExploreId, ExploreMode, ExploreUpdateState, ExploreUrlState } from 'app/types'; import { thunkTester } from 'test/core/thunk/thunkTester'; import { @@ -8,13 +11,11 @@ import { loadDatasourcePendingAction, loadDatasourceReadyAction, setQueriesAction, - updateUIStateAction, updateDatasourceInstanceAction, + updateUIStateAction, } from './actionTypes'; import { Emitter } from 'app/core/core'; -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; import { makeInitialUpdateState } from './reducers'; -import { DataQuery, DefaultTimeZone, LogsDedupStrategy, RawTimeRange, toUtc } from '@grafana/data'; import { PanelModel } from 'app/features/dashboard/state'; import { updateLocation } from '../../../core/actions'; import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv'; @@ -117,7 +118,7 @@ describe('refreshExplore', () => { .givenThunk(refreshExplore) .whenThunkIsDispatched(exploreId); - const initializeExplore = dispatchedActions[1] as ActionOf; + const initializeExplore = dispatchedActions[1] as PayloadAction; const { type, payload } = initializeExplore; expect(type).toEqual(initializeExploreAction.type); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index 3edf952ba02..fc521ed9eaa 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -1,6 +1,22 @@ // Libraries import { map, throttleTime } from 'rxjs/operators'; import { identity } from 'rxjs'; +import { ActionCreatorWithPayload, PayloadAction } from '@reduxjs/toolkit'; +import { DataSourceSrv } from '@grafana/runtime'; +import { RefreshPicker } from '@grafana/ui'; +import { + AbsoluteTimeRange, + DataQuery, + DataSourceApi, + dateTimeForTimeZone, + isDateTime, + LoadingState, + LogsDedupStrategy, + PanelData, + QueryFixAction, + RawTimeRange, + TimeRange, +} from '@grafana/data'; // Services & Utils import store from 'app/core/store'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -23,21 +39,7 @@ import { } from 'app/core/utils/explore'; // Types import { ExploreItemState, ExploreUrlState, ThunkResult } from 'app/types'; -import { RefreshPicker } from '@grafana/ui'; -import { - AbsoluteTimeRange, - DataQuery, - DataSourceApi, - dateTimeForTimeZone, - isDateTime, - LoadingState, - LogsDedupStrategy, - PanelData, - QueryFixAction, - RawTimeRange, - TimeRange, -} from '@grafana/data'; import { ExploreId, ExploreMode, ExploreUIState, QueryOptions } from 'app/types/explore'; import { addQueryRowAction, @@ -74,14 +76,12 @@ import { updateDatasourceInstanceAction, updateUIStateAction, } from './actionTypes'; -import { ActionCreator, ActionOf } from 'app/core/redux/actionCreatorFactory'; import { getTimeZone } from 'app/features/profile/state/selectors'; import { getShiftedTimeRange } from 'app/core/utils/timePicker'; import { updateLocation } from '../../../core/actions'; import { getTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { preProcessPanelData, runRequest } from '../../dashboard/state/runRequest'; import { PanelModel } from 'app/features/dashboard/state'; -import { DataSourceSrv } from '@grafana/runtime'; import { getExploreDatasources } from './selectors'; /** @@ -189,7 +189,7 @@ export function changeQuery( export function changeSize( exploreId: ExploreId, { height, width }: { height: number; width: number } -): ActionOf { +): PayloadAction { return changeSizeAction({ exploreId, height, width }); } @@ -217,7 +217,7 @@ export const updateTimeRange = (options: { export function changeRefreshInterval( exploreId: ExploreId, refreshInterval: string -): ActionOf { +): PayloadAction { return changeRefreshIntervalAction({ exploreId, refreshInterval }); } @@ -299,7 +299,7 @@ export const loadDatasourceReady = ( exploreId: ExploreId, instance: DataSourceApi, orgId: number -): ActionOf => { +): PayloadAction => { const historyKey = `grafana.explore.history.${instance.meta.id}`; const history = store.getObject(historyKey, []); // Save last-used datasource @@ -677,7 +677,7 @@ export function syncTimes(exploreId: ExploreId): ThunkResult { * queries won't be run */ const togglePanelActionCreator = ( - actionCreator: ActionCreator | ActionCreator + actionCreator: ActionCreatorWithPayload | ActionCreatorWithPayload ) => (exploreId: ExploreId, isPanelVisible: boolean): ThunkResult => { return dispatch => { let uiFragmentStateUpdate: Partial; diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts index 9c75ade7703..98c3c24d2de 100644 --- a/public/app/features/explore/state/reducers.test.ts +++ b/public/app/features/explore/state/reducers.test.ts @@ -1,41 +1,40 @@ +import { DataQuery, DataSourceApi, dateTime, LoadingState, LogsDedupStrategy, toDataFrame } from '@grafana/data'; + import { + createEmptyQueryResponse, + exploreReducer, + initialExploreState, itemReducer, makeExploreItemState, - exploreReducer, makeInitialUpdateState, - initialExploreState, - createEmptyQueryResponse, } from './reducers'; -import { ExploreId, ExploreItemState, ExploreUrlState, ExploreState, ExploreMode } from 'app/types/explore'; +import { ExploreId, ExploreItemState, ExploreMode, ExploreState, ExploreUrlState } from 'app/types/explore'; import { reducerTester } from 'test/core/redux/reducerTester'; import { - scanStartAction, - updateDatasourceInstanceAction, - splitOpenAction, - splitCloseAction, changeModeAction, - scanStopAction, - toggleGraphAction, - toggleTableAction, changeRangeAction, changeRefreshIntervalAction, + scanStartAction, + scanStopAction, + splitCloseAction, + splitOpenAction, + toggleGraphAction, + toggleTableAction, + updateDatasourceInstanceAction, } from './actionTypes'; -import { Reducer } from 'redux'; -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; -import { updateLocation } from 'app/core/actions/location'; import { serializeStateToUrlParam } from 'app/core/utils/explore'; -import { DataSourceApi, DataQuery, LogsDedupStrategy, dateTime, LoadingState, toDataFrame } from '@grafana/data'; +import { updateLocation } from '../../../core/actions'; describe('Explore item reducer', () => { describe('scanning', () => { it('should start scanning', () => { - const initalState = { + const initialState = { ...makeExploreItemState(), scanning: false, }; reducerTester() - .givenReducer(itemReducer as Reducer>, initalState) + .givenReducer(itemReducer, initialState) .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left })) .thenStateShouldEqual({ ...makeExploreItemState(), @@ -43,14 +42,14 @@ describe('Explore item reducer', () => { }); }); it('should stop scanning', () => { - const initalState = { + const initialState = { ...makeExploreItemState(), scanning: true, scanRange: {}, }; reducerTester() - .givenReducer(itemReducer as Reducer>, initalState) + .givenReducer(itemReducer, initialState) .whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left })) .thenStateShouldEqual({ ...makeExploreItemState(), @@ -64,7 +63,7 @@ describe('Explore item reducer', () => { describe('when changeMode is dispatched', () => { it('then it should set correct state', () => { reducerTester() - .givenReducer(itemReducer as Reducer>, {}) + .givenReducer(itemReducer, {}) .whenActionIsDispatched(changeModeAction({ exploreId: ExploreId.left, mode: ExploreMode.Logs })) .thenStatePredicateShouldEqual((resultingState: ExploreItemState) => { expect(resultingState.mode).toEqual(ExploreMode.Logs); @@ -91,7 +90,7 @@ describe('Explore item reducer', () => { } as DataSourceApi; const queries: DataQuery[] = []; const queryKeys: string[] = []; - const initalState: Partial = { + const initialState: Partial = { datasourceInstance: null, queries, queryKeys, @@ -111,7 +110,7 @@ describe('Explore item reducer', () => { }; reducerTester() - .givenReducer(itemReducer, initalState) + .givenReducer(itemReducer, initialState) .whenActionIsDispatched(updateDatasourceInstanceAction({ exploreId: ExploreId.left, datasourceInstance })) .thenStateShouldEqual(expectedState); }); @@ -121,7 +120,7 @@ describe('Explore item reducer', () => { describe('changing refresh intervals', () => { it("should result in 'streaming' state, when live-tailing is active", () => { - const initalState = makeExploreItemState(); + const initialState = makeExploreItemState(); const expectedState = { ...makeExploreItemState(), refreshInterval: 'LIVE', @@ -137,13 +136,13 @@ describe('Explore item reducer', () => { }, }; reducerTester() - .givenReducer(itemReducer, initalState) + .givenReducer(itemReducer, initialState) .whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: 'LIVE' })) .thenStateShouldEqual(expectedState); }); it("should result in 'done' state, when live-tailing is stopped", () => { - const initalState = makeExploreItemState(); + const initialState = makeExploreItemState(); const expectedState = { ...makeExploreItemState(), refreshInterval: '', @@ -157,7 +156,7 @@ describe('Explore item reducer', () => { }, }; reducerTester() - .givenReducer(itemReducer, initalState) + .givenReducer(itemReducer, initialState) .whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.left, refreshInterval: '' })) .thenStateShouldEqual(expectedState); }); @@ -243,10 +242,10 @@ export const setup = (urlStateOverrides?: any) => { }; const urlState: ExploreUrlState = { ...urlStateDefaults, ...urlStateOverrides }; const serializedUrlState = serializeStateToUrlParam(urlState); - const initalState = { split: false, left: { urlState, update }, right: { urlState, update } }; + const initialState = { split: false, left: { urlState, update }, right: { urlState, update } }; return { - initalState, + initialState, serializedUrlState, }; }; @@ -258,14 +257,14 @@ describe('Explore reducer', () => { containerWidth: 100, } as ExploreItemState; - const initalState = { + const initialState = { split: null, left: leftItemMock as ExploreItemState, right: makeExploreItemState(), } as ExploreState; reducerTester() - .givenReducer(exploreReducer as Reducer>, initalState) + .givenReducer(exploreReducer, initialState) .whenActionIsDispatched(splitOpenAction({ itemState: leftItemMock })) .thenStateShouldEqual({ split: true, @@ -284,7 +283,7 @@ describe('Explore reducer', () => { containerWidth: 200, } as ExploreItemState; - const initalState = { + const initialState = { split: null, left: leftItemMock, right: rightItemMock, @@ -292,7 +291,7 @@ describe('Explore reducer', () => { // closing left item reducerTester() - .givenReducer(exploreReducer as Reducer>, initalState) + .givenReducer(exploreReducer, initialState) .whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.left })) .thenStateShouldEqual({ split: false, @@ -309,7 +308,7 @@ describe('Explore reducer', () => { containerWidth: 200, } as ExploreItemState; - const initalState = { + const initialState = { split: null, left: leftItemMock, right: rightItemMock, @@ -317,7 +316,7 @@ describe('Explore reducer', () => { // closing left item reducerTester() - .givenReducer(exploreReducer as Reducer>, initalState) + .givenReducer(exploreReducer, initialState) .whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.right })) .thenStateShouldEqual({ split: false, @@ -350,11 +349,11 @@ describe('Explore reducer', () => { describe("and query contains a 'right'", () => { it('then it should add split in state', () => { - const { initalState, serializedUrlState } = setup(); - const expectedState = { ...initalState, split: true }; + const { initialState, serializedUrlState } = setup(); + const expectedState = { ...initialState, split: true }; reducerTester() - .givenReducer(exploreReducer, initalState) + .givenReducer(exploreReducer, initialState) .whenActionIsDispatched( updateLocation({ query: { @@ -370,10 +369,10 @@ describe('Explore reducer', () => { describe("and query contains a 'left'", () => { describe('but urlState is not set in state', () => { it('then it should just add urlState and update in state', () => { - const { initalState, serializedUrlState } = setup(); + const { initialState, serializedUrlState } = setup(); const urlState: ExploreUrlState = null; - const stateWithoutUrlState = { ...initalState, left: { urlState } }; - const expectedState = { ...initalState }; + const stateWithoutUrlState = { ...initialState, left: { urlState } }; + const expectedState = { ...initialState }; reducerTester() .givenReducer(exploreReducer, stateWithoutUrlState) @@ -391,11 +390,11 @@ describe('Explore reducer', () => { describe("but '/explore' is missing in path", () => { it('then it should just add urlState and update in state', () => { - const { initalState, serializedUrlState } = setup(); - const expectedState = { ...initalState }; + const { initialState, serializedUrlState } = setup(); + const expectedState = { ...initialState }; reducerTester() - .givenReducer(exploreReducer, initalState) + .givenReducer(exploreReducer, initialState) .whenActionIsDispatched( updateLocation({ query: { @@ -411,23 +410,23 @@ describe('Explore reducer', () => { describe("and '/explore' is in path", () => { describe('and datasource differs', () => { it('then it should return update datasource', () => { - const { initalState, serializedUrlState } = setup(); + const { initialState, serializedUrlState } = setup(); const expectedState = { - ...initalState, + ...initialState, left: { - ...initalState.left, + ...initialState.left, update: { - ...initalState.left.update, + ...initialState.left.update, datasource: true, }, }, }; const stateWithDifferentDataSource = { - ...initalState, + ...initialState, left: { - ...initalState.left, + ...initialState.left, urlState: { - ...initalState.left.urlState, + ...initialState.left.urlState, datasource: 'different datasource', }, }, @@ -449,23 +448,23 @@ describe('Explore reducer', () => { describe('and range differs', () => { it('then it should return update range', () => { - const { initalState, serializedUrlState } = setup(); + const { initialState, serializedUrlState } = setup(); const expectedState = { - ...initalState, + ...initialState, left: { - ...initalState.left, + ...initialState.left, update: { - ...initalState.left.update, + ...initialState.left.update, range: true, }, }, }; const stateWithDifferentDataSource = { - ...initalState, + ...initialState, left: { - ...initalState.left, + ...initialState.left, urlState: { - ...initalState.left.urlState, + ...initialState.left.urlState, range: { from: 'now', to: 'now-6h', @@ -490,23 +489,23 @@ describe('Explore reducer', () => { describe('and queries differs', () => { it('then it should return update queries', () => { - const { initalState, serializedUrlState } = setup(); + const { initialState, serializedUrlState } = setup(); const expectedState = { - ...initalState, + ...initialState, left: { - ...initalState.left, + ...initialState.left, update: { - ...initalState.left.update, + ...initialState.left.update, queries: true, }, }, }; const stateWithDifferentDataSource = { - ...initalState, + ...initialState, left: { - ...initalState.left, + ...initialState.left, urlState: { - ...initalState.left.urlState, + ...initialState.left.urlState, queries: [{ expr: '{__filename__="some.log"}' }], }, }, @@ -528,25 +527,25 @@ describe('Explore reducer', () => { describe('and ui differs', () => { it('then it should return update ui', () => { - const { initalState, serializedUrlState } = setup(); + const { initialState, serializedUrlState } = setup(); const expectedState = { - ...initalState, + ...initialState, left: { - ...initalState.left, + ...initialState.left, update: { - ...initalState.left.update, + ...initialState.left.update, ui: true, }, }, }; const stateWithDifferentDataSource = { - ...initalState, + ...initialState, left: { - ...initalState.left, + ...initialState.left, urlState: { - ...initalState.left.urlState, + ...initialState.left.urlState, ui: { - ...initalState.left.urlState.ui, + ...initialState.left.urlState.ui, showingGraph: true, }, }, @@ -569,11 +568,11 @@ describe('Explore reducer', () => { describe('and nothing differs', () => { it('then it should return update ui', () => { - const { initalState, serializedUrlState } = setup(); - const expectedState = { ...initalState }; + const { initialState, serializedUrlState } = setup(); + const expectedState = { ...initialState }; reducerTester() - .givenReducer(exploreReducer, initalState) + .givenReducer(exploreReducer, initialState) .whenActionIsDispatched( updateLocation({ query: { diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 4a44f36ac62..1a02657f37f 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -1,68 +1,72 @@ import _ from 'lodash'; +import { AnyAction } from 'redux'; +import { PayloadAction } from '@reduxjs/toolkit'; import { - ensureQueries, - getQueryKeys, - parseUrlState, - DEFAULT_UI_STATE, - generateNewKeyAndAddRefIdIfMissing, - sortLogsResult, - stopQueryState, - refreshIntervalToSortOrder, -} from 'app/core/utils/explore'; -import { ExploreItemState, ExploreState, ExploreId, ExploreUpdateState, ExploreMode } from 'app/types/explore'; -import { - LoadingState, - toLegacyResponseData, - DefaultTimeRange, DataQuery, - DataSourceApi, - PanelData, DataQueryRequest, + DataSourceApi, + DefaultTimeRange, + LoadingState, + PanelData, PanelEvents, TimeZone, + toLegacyResponseData, } from '@grafana/data'; import { RefreshPicker } from '@grafana/ui'; +import { LocationUpdate } from '@grafana/runtime'; + +import { + DEFAULT_UI_STATE, + ensureQueries, + generateNewKeyAndAddRefIdIfMissing, + getQueryKeys, + parseUrlState, + refreshIntervalToSortOrder, + sortLogsResult, + stopQueryState, +} from 'app/core/utils/explore'; +import { ExploreId, ExploreItemState, ExploreMode, ExploreState, ExploreUpdateState } from 'app/types/explore'; import { - HigherOrderAction, - ActionTypes, - splitCloseAction, - SplitCloseActionPayload, - historyUpdatedAction, - changeModeAction, - setUrlReplacedAction, - scanStopAction, - changeRangeAction, - clearOriginAction, addQueryRowAction, + changeLoadingStateAction, + changeModeAction, changeQueryAction, - changeSizeAction, + changeRangeAction, changeRefreshIntervalAction, + changeSizeAction, + clearOriginAction, clearQueriesAction, highlightLogsExpressionAction, + historyUpdatedAction, initializeExploreAction, - updateDatasourceInstanceAction, loadDatasourceMissingAction, loadDatasourcePendingAction, loadDatasourceReadyAction, modifyQueriesAction, - removeQueryRowAction, - scanStartAction, - setQueriesAction, - toggleTableAction, queriesImportedAction, - updateUIStateAction, - toggleLogLevelAction, - changeLoadingStateAction, - queryStreamUpdatedAction, QueryEndedPayload, queryStoreSubscriptionAction, + queryStreamUpdatedAction, + removeQueryRowAction, + resetExploreAction, + ResetExplorePayload, + scanStartAction, + scanStopAction, setPausedStateAction, + setQueriesAction, + setUrlReplacedAction, + splitCloseAction, + SplitCloseActionPayload, + splitOpenAction, + syncTimesAction, toggleGraphAction, + toggleLogLevelAction, + toggleTableAction, + updateDatasourceInstanceAction, + updateUIStateAction, } from './actionTypes'; -import { reducerFactory, ActionOf } from 'app/core/redux'; -import { updateLocation } from 'app/core/actions/location'; -import { LocationUpdate } from '@grafana/runtime'; import { ResultProcessor } from '../utils/ResultProcessor'; +import { updateLocation } from '../../../core/actions'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -137,422 +141,368 @@ export const initialExploreState: ExploreState = { /** * Reducer for an Explore area, to be used by the global Explore reducer. */ -export const itemReducer = reducerFactory({} as ExploreItemState) - .addMapper({ - filter: addQueryRowAction, - mapper: (state, action): ExploreItemState => { - const { queries } = state; - const { index, query } = action.payload; +// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated. +// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice +// because the state would become frozen and during run time we would get errors because flot (Graph lib) would try to mutate +// the frozen state. +// https://github.com/reduxjs/redux-toolkit/issues/242 +export const itemReducer = (state: ExploreItemState = makeExploreItemState(), action: AnyAction): ExploreItemState => { + if (addQueryRowAction.match(action)) { + const { queries } = state; + const { index, query } = action.payload; - // Add to queries, which will cause a new row to be rendered - const nextQueries = [...queries.slice(0, index + 1), { ...query }, ...queries.slice(index + 1)]; + // Add to queries, which will cause a new row to be rendered + const nextQueries = [...queries.slice(0, index + 1), { ...query }, ...queries.slice(index + 1)]; - return { - ...state, - queries: nextQueries, - logsHighlighterExpressions: undefined, - queryKeys: getQueryKeys(nextQueries, state.datasourceInstance), - }; - }, - }) - .addMapper({ - filter: changeQueryAction, - mapper: (state, action): ExploreItemState => { - const { queries } = state; - const { query, index } = action.payload; + return { + ...state, + queries: nextQueries, + logsHighlighterExpressions: undefined, + queryKeys: getQueryKeys(nextQueries, state.datasourceInstance), + }; + } - // Override path: queries are completely reset - const nextQuery: DataQuery = generateNewKeyAndAddRefIdIfMissing(query, queries, index); - const nextQueries = [...queries]; - nextQueries[index] = nextQuery; + if (changeQueryAction.match(action)) { + const { queries } = state; + const { query, index } = action.payload; - return { - ...state, - queries: nextQueries, - queryKeys: getQueryKeys(nextQueries, state.datasourceInstance), - }; - }, - }) - .addMapper({ - filter: changeSizeAction, - mapper: (state, action): ExploreItemState => { - const containerWidth = action.payload.width; - return { ...state, containerWidth }; - }, - }) - .addMapper({ - filter: changeModeAction, - mapper: (state, action): ExploreItemState => { - return { - ...state, - mode: action.payload.mode, - graphResult: null, - tableResult: null, - logsResult: null, - queryResponse: createEmptyQueryResponse(), - loading: false, - }; - }, - }) - .addMapper({ - filter: changeRefreshIntervalAction, - mapper: (state, action): ExploreItemState => { - const { refreshInterval } = action.payload; - const live = RefreshPicker.isLive(refreshInterval); - const sortOrder = refreshIntervalToSortOrder(refreshInterval); - const logsResult = sortLogsResult(state.logsResult, sortOrder); + // Override path: queries are completely reset + const nextQuery: DataQuery = generateNewKeyAndAddRefIdIfMissing(query, queries, index); + const nextQueries = [...queries]; + nextQueries[index] = nextQuery; - if (RefreshPicker.isLive(state.refreshInterval) && !live) { - stopQueryState(state.querySubscription); - } + return { + ...state, + queries: nextQueries, + queryKeys: getQueryKeys(nextQueries, state.datasourceInstance), + }; + } - return { - ...state, - refreshInterval, - queryResponse: { - ...state.queryResponse, - state: live ? LoadingState.Streaming : LoadingState.Done, + if (changeSizeAction.match(action)) { + const containerWidth = action.payload.width; + return { ...state, containerWidth }; + } + + if (changeModeAction.match(action)) { + return { + ...state, + mode: action.payload.mode, + graphResult: null, + tableResult: null, + logsResult: null, + queryResponse: createEmptyQueryResponse(), + loading: false, + }; + } + + if (changeRefreshIntervalAction.match(action)) { + const { refreshInterval } = action.payload; + const live = RefreshPicker.isLive(refreshInterval); + const sortOrder = refreshIntervalToSortOrder(refreshInterval); + const logsResult = sortLogsResult(state.logsResult, sortOrder); + + if (RefreshPicker.isLive(state.refreshInterval) && !live) { + stopQueryState(state.querySubscription); + } + + return { + ...state, + refreshInterval, + queryResponse: { + ...state.queryResponse, + state: live ? LoadingState.Streaming : LoadingState.Done, + }, + isLive: live, + isPaused: live ? false : state.isPaused, + loading: live, + logsResult, + }; + } + + if (clearQueriesAction.match(action)) { + const queries = ensureQueries(); + stopQueryState(state.querySubscription); + return { + ...state, + queries: queries.slice(), + graphResult: null, + tableResult: null, + logsResult: null, + queryKeys: getQueryKeys(queries, state.datasourceInstance), + queryResponse: createEmptyQueryResponse(), + loading: false, + }; + } + + if (clearOriginAction.match(action)) { + return { + ...state, + originPanelId: undefined, + }; + } + + if (highlightLogsExpressionAction.match(action)) { + const { expressions } = action.payload; + return { ...state, logsHighlighterExpressions: expressions }; + } + + if (initializeExploreAction.match(action)) { + const { containerWidth, eventBridge, queries, range, mode, ui, originPanelId } = action.payload; + return { + ...state, + containerWidth, + eventBridge, + range, + mode, + queries, + initialized: true, + queryKeys: getQueryKeys(queries, state.datasourceInstance), + ...ui, + originPanelId, + update: makeInitialUpdateState(), + }; + } + + if (updateDatasourceInstanceAction.match(action)) { + const { datasourceInstance, version, mode } = action.payload; + + // Custom components + stopQueryState(state.querySubscription); + + let newMetadata = datasourceInstance.meta; + + // HACK: Temporary hack for Loki datasource. Can remove when plugin.json structure is changed. + if (version && version.length && datasourceInstance.meta.name === 'Loki') { + const lokiVersionMetadata: Record = { + v0: { + metrics: false, }, - isLive: live, - isPaused: live ? false : state.isPaused, - loading: live, - logsResult, - }; - }, - }) - .addMapper({ - filter: clearQueriesAction, - mapper: (state): ExploreItemState => { - const queries = ensureQueries(); - stopQueryState(state.querySubscription); - return { - ...state, - queries: queries.slice(), - graphResult: null, - tableResult: null, - logsResult: null, - queryKeys: getQueryKeys(queries, state.datasourceInstance), - queryResponse: createEmptyQueryResponse(), - loading: false, - }; - }, - }) - .addMapper({ - filter: clearOriginAction, - mapper: (state): ExploreItemState => { - return { - ...state, - originPanelId: undefined, - }; - }, - }) - .addMapper({ - filter: highlightLogsExpressionAction, - mapper: (state, action): ExploreItemState => { - const { expressions } = action.payload; - return { ...state, logsHighlighterExpressions: expressions }; - }, - }) - .addMapper({ - filter: initializeExploreAction, - mapper: (state, action): ExploreItemState => { - const { containerWidth, eventBridge, queries, range, mode, ui, originPanelId } = action.payload; - return { - ...state, - containerWidth, - eventBridge, - range, - mode, - queries, - initialized: true, - queryKeys: getQueryKeys(queries, state.datasourceInstance), - ...ui, - originPanelId, - update: makeInitialUpdateState(), - }; - }, - }) - .addMapper({ - filter: updateDatasourceInstanceAction, - mapper: (state, action): ExploreItemState => { - const { datasourceInstance, version, mode } = action.payload; - // Custom components - stopQueryState(state.querySubscription); - - let newMetadata = datasourceInstance.meta; - - // HACK: Temporary hack for Loki datasource. Can remove when plugin.json structure is changed. - if (version && version.length && datasourceInstance.meta.name === 'Loki') { - const lokiVersionMetadata: Record = { - v0: { - metrics: false, - }, - - v1: { - metrics: true, - }, - }; - newMetadata = { ...newMetadata, ...lokiVersionMetadata[version] }; - } - - const updatedDatasourceInstance = Object.assign(datasourceInstance, { meta: newMetadata }); - const [supportedModes, newMode] = getModesForDatasource(updatedDatasourceInstance, state.mode); - - return { - ...state, - datasourceInstance: updatedDatasourceInstance, - graphResult: null, - tableResult: null, - logsResult: null, - latency: 0, - queryResponse: createEmptyQueryResponse(), - loading: false, - queryKeys: [], - supportedModes, - mode: mode ?? newMode, - originPanelId: state.urlState && state.urlState.originPanelId, + v1: { + metrics: true, + }, }; - }, - }) - .addMapper({ - filter: loadDatasourceMissingAction, - mapper: (state): ExploreItemState => { - return { - ...state, - datasourceMissing: true, - datasourceLoading: false, - update: makeInitialUpdateState(), - }; - }, - }) - .addMapper({ - filter: loadDatasourcePendingAction, - mapper: (state, action): ExploreItemState => { - return { - ...state, - datasourceLoading: true, - requestedDatasourceName: action.payload.requestedDatasourceName, - }; - }, - }) - .addMapper({ - filter: loadDatasourceReadyAction, - mapper: (state, action): ExploreItemState => { - const { history } = action.payload; - return { - ...state, - history, - datasourceLoading: false, - datasourceMissing: false, - logsHighlighterExpressions: undefined, - update: makeInitialUpdateState(), - }; - }, - }) - .addMapper({ - filter: modifyQueriesAction, - mapper: (state, action): ExploreItemState => { - const { queries } = state; - const { modification, index, modifier } = action.payload; - let nextQueries: DataQuery[]; - if (index === undefined) { - // Modify all queries - nextQueries = queries.map((query, i) => { + newMetadata = { ...newMetadata, ...lokiVersionMetadata[version] }; + } + + const updatedDatasourceInstance = Object.assign(datasourceInstance, { meta: newMetadata }); + const [supportedModes, newMode] = getModesForDatasource(updatedDatasourceInstance, state.mode); + + return { + ...state, + datasourceInstance: updatedDatasourceInstance, + graphResult: null, + tableResult: null, + logsResult: null, + latency: 0, + queryResponse: createEmptyQueryResponse(), + loading: false, + queryKeys: [], + supportedModes, + mode: mode ?? newMode, + originPanelId: state.urlState && state.urlState.originPanelId, + }; + } + + if (loadDatasourceMissingAction.match(action)) { + return { + ...state, + datasourceMissing: true, + datasourceLoading: false, + update: makeInitialUpdateState(), + }; + } + + if (loadDatasourcePendingAction.match(action)) { + return { + ...state, + datasourceLoading: true, + requestedDatasourceName: action.payload.requestedDatasourceName, + }; + } + + if (loadDatasourceReadyAction.match(action)) { + const { history } = action.payload; + return { + ...state, + history, + datasourceLoading: false, + datasourceMissing: false, + logsHighlighterExpressions: undefined, + update: makeInitialUpdateState(), + }; + } + + if (modifyQueriesAction.match(action)) { + const { queries } = state; + const { modification, index, modifier } = action.payload; + let nextQueries: DataQuery[]; + if (index === undefined) { + // Modify all queries + nextQueries = queries.map((query, i) => { + const nextQuery = modifier({ ...query }, modification); + return generateNewKeyAndAddRefIdIfMissing(nextQuery, queries, i); + }); + } else { + // Modify query only at index + nextQueries = queries.map((query, i) => { + if (i === index) { const nextQuery = modifier({ ...query }, modification); return generateNewKeyAndAddRefIdIfMissing(nextQuery, queries, i); - }); - } else { - // Modify query only at index - nextQueries = queries.map((query, i) => { - if (i === index) { - const nextQuery = modifier({ ...query }, modification); - return generateNewKeyAndAddRefIdIfMissing(nextQuery, queries, i); - } + } - return query; - }); - } - return { - ...state, - queries: nextQueries, - queryKeys: getQueryKeys(nextQueries, state.datasourceInstance), - }; - }, - }) - .addMapper({ - filter: removeQueryRowAction, - mapper: (state, action): ExploreItemState => { - const { queries, queryKeys } = state; - const { index } = action.payload; + return query; + }); + } + return { + ...state, + queries: nextQueries, + queryKeys: getQueryKeys(nextQueries, state.datasourceInstance), + }; + } - if (queries.length <= 1) { - return state; - } + if (removeQueryRowAction.match(action)) { + const { queries, queryKeys } = state; + const { index } = action.payload; - const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; - const nextQueryKeys = [...queryKeys.slice(0, index), ...queryKeys.slice(index + 1)]; + if (queries.length <= 1) { + return state; + } - return { - ...state, - queries: nextQueries, - logsHighlighterExpressions: undefined, - queryKeys: nextQueryKeys, - }; - }, - }) - .addMapper({ - filter: scanStartAction, - mapper: (state, action): ExploreItemState => { - return { ...state, scanning: true }; - }, - }) - .addMapper({ - filter: scanStopAction, - mapper: (state): ExploreItemState => { - return { - ...state, - scanning: false, - scanRange: undefined, - update: makeInitialUpdateState(), - }; - }, - }) - .addMapper({ - filter: setQueriesAction, - mapper: (state, action): ExploreItemState => { - const { queries } = action.payload; - return { - ...state, - queries: queries.slice(), - queryKeys: getQueryKeys(queries, state.datasourceInstance), - }; - }, - }) - .addMapper({ - filter: updateUIStateAction, - mapper: (state, action): ExploreItemState => { - return { ...state, ...action.payload }; - }, - }) - .addMapper({ - filter: toggleGraphAction, - mapper: (state): ExploreItemState => { - const showingGraph = !state.showingGraph; - if (showingGraph) { - return { ...state, showingGraph }; - } + const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; + const nextQueryKeys = [...queryKeys.slice(0, index), ...queryKeys.slice(index + 1)]; - return { ...state, showingGraph, graphResult: null }; - }, - }) - .addMapper({ - filter: toggleTableAction, - mapper: (state): ExploreItemState => { - const showingTable = !state.showingTable; - if (showingTable) { - return { ...state, showingTable }; - } + return { + ...state, + queries: nextQueries, + logsHighlighterExpressions: undefined, + queryKeys: nextQueryKeys, + }; + } - return { ...state, showingTable, tableResult: null }; - }, - }) - .addMapper({ - filter: queriesImportedAction, - mapper: (state, action): ExploreItemState => { - const { queries } = action.payload; - return { - ...state, - queries, - queryKeys: getQueryKeys(queries, state.datasourceInstance), - }; - }, - }) - .addMapper({ - filter: toggleLogLevelAction, - mapper: (state, action): ExploreItemState => { - const { hiddenLogLevels } = action.payload; - return { - ...state, - hiddenLogLevels: Array.from(hiddenLogLevels), - }; - }, - }) - .addMapper({ - filter: historyUpdatedAction, - mapper: (state, action): ExploreItemState => { - return { - ...state, - history: action.payload.history, - }; - }, - }) - .addMapper({ - filter: setUrlReplacedAction, - mapper: (state): ExploreItemState => { - return { - ...state, - urlReplaced: true, - }; - }, - }) - .addMapper({ - filter: changeRangeAction, - mapper: (state, action): ExploreItemState => { - const { range, absoluteRange } = action.payload; - return { - ...state, - range, - absoluteRange, - update: makeInitialUpdateState(), - }; - }, - }) - .addMapper({ - filter: changeLoadingStateAction, - mapper: (state, action): ExploreItemState => { - const { loadingState } = action.payload; - return { - ...state, - queryResponse: { - ...state.queryResponse, - state: loadingState, - }, - loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming, - }; - }, - }) - .addMapper({ - filter: setPausedStateAction, - mapper: (state, action): ExploreItemState => { - const { isPaused } = action.payload; - return { - ...state, - isPaused: isPaused, - }; - }, - }) - .addMapper({ - filter: queryStoreSubscriptionAction, - mapper: (state, action): ExploreItemState => { - const { querySubscription } = action.payload; - return { - ...state, - querySubscription, - }; - }, - }) - .addMapper({ - filter: queryStreamUpdatedAction, - mapper: (state, action): ExploreItemState => { - return processQueryResponse(state, action); - }, - }) - .create(); + if (scanStartAction.match(action)) { + return { ...state, scanning: true }; + } + + if (scanStopAction.match(action)) { + return { + ...state, + scanning: false, + scanRange: undefined, + update: makeInitialUpdateState(), + }; + } + + if (setQueriesAction.match(action)) { + const { queries } = action.payload; + return { + ...state, + queries: queries.slice(), + queryKeys: getQueryKeys(queries, state.datasourceInstance), + }; + } + + if (updateUIStateAction.match(action)) { + return { ...state, ...action.payload }; + } + + if (toggleGraphAction.match(action)) { + const showingGraph = !state.showingGraph; + if (showingGraph) { + return { ...state, showingGraph }; + } + + return { ...state, showingGraph, graphResult: null }; + } + + if (toggleTableAction.match(action)) { + const showingTable = !state.showingTable; + if (showingTable) { + return { ...state, showingTable }; + } + + return { ...state, showingTable, tableResult: null }; + } + + if (queriesImportedAction.match(action)) { + const { queries } = action.payload; + return { + ...state, + queries, + queryKeys: getQueryKeys(queries, state.datasourceInstance), + }; + } + + if (toggleLogLevelAction.match(action)) { + const { hiddenLogLevels } = action.payload; + return { + ...state, + hiddenLogLevels: Array.from(hiddenLogLevels), + }; + } + + if (historyUpdatedAction.match(action)) { + return { + ...state, + history: action.payload.history, + }; + } + + if (setUrlReplacedAction.match(action)) { + return { + ...state, + urlReplaced: true, + }; + } + + if (changeRangeAction.match(action)) { + const { range, absoluteRange } = action.payload; + return { + ...state, + range, + absoluteRange, + update: makeInitialUpdateState(), + }; + } + + if (changeLoadingStateAction.match(action)) { + const { loadingState } = action.payload; + return { + ...state, + queryResponse: { + ...state.queryResponse, + state: loadingState, + }, + loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming, + }; + } + + if (setPausedStateAction.match(action)) { + const { isPaused } = action.payload; + return { + ...state, + isPaused: isPaused, + }; + } + + if (queryStoreSubscriptionAction.match(action)) { + const { querySubscription } = action.payload; + return { + ...state, + querySubscription, + }; + } + + if (queryStreamUpdatedAction.match(action)) { + return processQueryResponse(state, action); + } + + return state; +}; export const processQueryResponse = ( state: ExploreItemState, - action: ActionOf + action: PayloadAction ): ExploreItemState => { const { response } = action.payload; const { request, state: loadingState, series, error } = response; @@ -674,73 +624,74 @@ const getModesForDatasource = (dataSource: DataSourceApi, currentMode: ExploreMo * Global Explore reducer that handles multiple Explore areas (left and right). * Actions that have an `exploreId` get routed to the ExploreItemReducer. */ -export const exploreReducer = (state = initialExploreState, action: HigherOrderAction): ExploreState => { - switch (action.type) { - case splitCloseAction.type: { - const { itemId } = action.payload as SplitCloseActionPayload; - const targetSplit = { - left: itemId === ExploreId.left ? state.right : state.left, - right: initialExploreState.right, - }; - return { - ...state, - ...targetSplit, - split: false, - }; +export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => { + if (splitCloseAction.match(action)) { + const { itemId } = action.payload as SplitCloseActionPayload; + const targetSplit = { + left: itemId === ExploreId.left ? state.right : state.left, + right: initialExploreState.right, + }; + return { + ...state, + ...targetSplit, + split: false, + }; + } + + if (splitOpenAction.match(action)) { + return { ...state, split: true, right: { ...action.payload.itemState } }; + } + + if (syncTimesAction.match(action)) { + return { ...state, syncedTimes: action.payload.syncedTimes }; + } + + if (resetExploreAction.match(action)) { + const payload: ResetExplorePayload = action.payload; + const leftState = state[ExploreId.left]; + const rightState = state[ExploreId.right]; + stopQueryState(leftState.querySubscription); + stopQueryState(rightState.querySubscription); + + if (payload.force || !Number.isInteger(state.left.originPanelId)) { + return initialExploreState; } - case ActionTypes.SplitOpen: { - return { ...state, split: true, right: { ...action.payload.itemState } }; - } - case ActionTypes.SyncTimes: { - return { ...state, syncedTimes: action.payload.syncedTimes }; + return { + ...initialExploreState, + left: { + ...initialExploreItemState, + queries: state.left.queries, + originPanelId: state.left.originPanelId, + }, + }; + } + + if (updateLocation.match(action)) { + const payload: LocationUpdate = action.payload; + const { query } = payload; + if (!query || !query[ExploreId.left]) { + return state; } - case ActionTypes.ResetExplore: { - const leftState = state[ExploreId.left]; - const rightState = state[ExploreId.right]; - stopQueryState(leftState.querySubscription); - stopQueryState(rightState.querySubscription); + const split = query[ExploreId.right] ? true : false; + const leftState = state[ExploreId.left]; + const rightState = state[ExploreId.right]; - if (action.payload.force || !Number.isInteger(state.left.originPanelId)) { - return initialExploreState; - } - - return { - ...initialExploreState, - left: { - ...initialExploreItemState, - queries: state.left.queries, - originPanelId: state.left.originPanelId, - }, - }; - } - - case updateLocation.type: { - const { query } = action.payload; - if (!query || !query[ExploreId.left]) { - return state; - } - - const split = query[ExploreId.right] ? true : false; - const leftState = state[ExploreId.left]; - const rightState = state[ExploreId.right]; - - return { - ...state, - split, - [ExploreId.left]: updateChildRefreshState(leftState, action.payload, ExploreId.left), - [ExploreId.right]: updateChildRefreshState(rightState, action.payload, ExploreId.right), - }; - } + return { + ...state, + split, + [ExploreId.left]: updateChildRefreshState(leftState, payload, ExploreId.left), + [ExploreId.right]: updateChildRefreshState(rightState, payload, ExploreId.right), + }; } if (action.payload) { - const { exploreId } = action.payload as any; + const { exploreId } = action.payload; if (exploreId !== undefined) { // @ts-ignore const exploreItemState = state[exploreId]; - return { ...state, [exploreId]: itemReducer(exploreItemState, action) }; + return { ...state, [exploreId]: itemReducer(exploreItemState, action as any) }; } } diff --git a/public/app/features/folders/FolderSettingsPage.test.tsx b/public/app/features/folders/FolderSettingsPage.test.tsx index 3e00ec931fa..6f5fe5abb5c 100644 --- a/public/app/features/folders/FolderSettingsPage.test.tsx +++ b/public/app/features/folders/FolderSettingsPage.test.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { FolderSettingsPage, Props } from './FolderSettingsPage'; import { shallow } from 'enzyme'; import { NavModel } from '@grafana/data'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; +import { setFolderTitle } from './state/reducers'; const setup = (propOverrides?: object) => { const props: Props = { @@ -18,7 +20,7 @@ const setup = (propOverrides?: object) => { permissions: [], }, getFolderByUid: jest.fn(), - setFolderTitle: jest.fn(), + setFolderTitle: mockToolkitActionCreator(setFolderTitle), saveFolder: jest.fn(), deleteFolder: jest.fn(), }; diff --git a/public/app/features/folders/FolderSettingsPage.tsx b/public/app/features/folders/FolderSettingsPage.tsx index be4bba7b642..214544c27e4 100644 --- a/public/app/features/folders/FolderSettingsPage.tsx +++ b/public/app/features/folders/FolderSettingsPage.tsx @@ -6,9 +6,10 @@ import { Input } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; import appEvents from 'app/core/app_events'; import { getNavModel } from 'app/core/selectors/navModel'; -import { StoreState, FolderState, CoreEvents } from 'app/types'; -import { getFolderByUid, setFolderTitle, saveFolder, deleteFolder } from './state/actions'; +import { CoreEvents, FolderState, StoreState } from 'app/types'; +import { deleteFolder, getFolderByUid, saveFolder } from './state/actions'; import { getLoadingNav } from './state/navModel'; +import { setFolderTitle } from './state/reducers'; export interface Props { navModel: NavModel; diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts index af391e084f9..3790a8cb771 100644 --- a/public/app/features/folders/state/actions.ts +++ b/public/app/features/folders/state/actions.ts @@ -1,60 +1,13 @@ -import { getBackendSrv } from 'app/core/services/backend_srv'; -import { StoreState } from 'app/types'; -import { ThunkAction } from 'redux-thunk'; -import { FolderDTO, FolderState } from 'app/types'; -import { - DashboardAcl, - DashboardAclDTO, - PermissionLevel, - DashboardAclUpdateDTO, - NewDashboardAclItem, -} from 'app/types/acl'; - -import { updateNavIndex, updateLocation } from 'app/core/actions'; -import { buildNavModel } from './navModel'; -import appEvents from 'app/core/app_events'; import { AppEvents } from '@grafana/data'; -export enum ActionTypes { - LoadFolder = 'LOAD_FOLDER', - SetFolderTitle = 'SET_FOLDER_TITLE', - SaveFolder = 'SAVE_FOLDER', - LoadFolderPermissions = 'LOAD_FOLDER_PERMISSONS', -} +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { FolderState, ThunkResult } from 'app/types'; +import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel } from 'app/types/acl'; -export interface LoadFolderAction { - type: ActionTypes.LoadFolder; - payload: FolderDTO; -} - -export interface SetFolderTitleAction { - type: ActionTypes.SetFolderTitle; - payload: string; -} - -export interface LoadFolderPermissionsAction { - type: ActionTypes.LoadFolderPermissions; - payload: DashboardAcl[]; -} - -export type Action = LoadFolderAction | SetFolderTitleAction | LoadFolderPermissionsAction; - -type ThunkResult = ThunkAction; - -export const loadFolder = (folder: FolderDTO): LoadFolderAction => ({ - type: ActionTypes.LoadFolder, - payload: folder, -}); - -export const setFolderTitle = (newTitle: string): SetFolderTitleAction => ({ - type: ActionTypes.SetFolderTitle, - payload: newTitle, -}); - -export const loadFolderPermissions = (items: DashboardAclDTO[]): LoadFolderPermissionsAction => ({ - type: ActionTypes.LoadFolderPermissions, - payload: items, -}); +import { updateLocation, updateNavIndex } from 'app/core/actions'; +import { buildNavModel } from './navModel'; +import appEvents from 'app/core/app_events'; +import { loadFolder, loadFolderPermissions } from './reducers'; export function getFolderByUid(uid: string): ThunkResult { return async dispatch => { diff --git a/public/app/features/folders/state/reducers.test.ts b/public/app/features/folders/state/reducers.test.ts index 72e97f39562..c420d3c34b1 100644 --- a/public/app/features/folders/state/reducers.test.ts +++ b/public/app/features/folders/state/reducers.test.ts @@ -1,6 +1,6 @@ -import { Action, ActionTypes } from './actions'; -import { FolderDTO, OrgRole, PermissionLevel, FolderState } from 'app/types'; -import { inititalState, folderReducer } from './reducers'; +import { FolderDTO, FolderState, OrgRole, PermissionLevel } from 'app/types'; +import { folderReducer, initialState, loadFolder, loadFolderPermissions, setFolderTitle } from './reducers'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; function getTestFolder(): FolderDTO { return { @@ -14,84 +14,130 @@ function getTestFolder(): FolderDTO { } describe('folder reducer', () => { - describe('loadFolder', () => { + describe('when loadFolder is dispatched', () => { it('should load folder and set hasChanged to false', () => { - const folder = getTestFolder(); - - const action: Action = { - type: ActionTypes.LoadFolder, - payload: folder, - }; - - const state = folderReducer(inititalState, action); - - expect(state.hasChanged).toEqual(false); - expect(state.title).toEqual('test folder'); + reducerTester() + .givenReducer(folderReducer, { ...initialState, hasChanged: true }) + .whenActionIsDispatched(loadFolder(getTestFolder())) + .thenStateShouldEqual({ + ...initialState, + hasChanged: false, + ...getTestFolder(), + }); }); }); - describe('detFolderTitle', () => { - it('should set title', () => { - const action: Action = { - type: ActionTypes.SetFolderTitle, - payload: 'new title', - }; + describe('when setFolderTitle is dispatched', () => { + describe('and title has length', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(folderReducer, { ...initialState }) + .whenActionIsDispatched(setFolderTitle('ready')) + .thenStateShouldEqual({ + ...initialState, + hasChanged: true, + title: 'ready', + }); + }); + }); - const state = folderReducer(inititalState, action); - - expect(state.hasChanged).toEqual(true); - expect(state.title).toEqual('new title'); + describe('and title has no length', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(folderReducer, { ...initialState }) + .whenActionIsDispatched(setFolderTitle('')) + .thenStateShouldEqual({ + ...initialState, + hasChanged: false, + title: '', + }); + }); }); }); - describe('loadFolderPermissions', () => { - let state: FolderState; - - beforeEach(() => { - const action: Action = { - type: ActionTypes.LoadFolderPermissions, - payload: [ - { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View }, - { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, - { - id: 4, - dashboardId: 10, - permission: PermissionLevel.View, - teamId: 1, - team: 'MyTestTeam', - inherited: true, - }, - { - id: 5, - dashboardId: 1, - permission: PermissionLevel.View, - userId: 1, - userLogin: 'MyTestUser', - }, - { - id: 6, - dashboardId: 1, - permission: PermissionLevel.Edit, - teamId: 2, - team: 'MyTestTeam2', - }, - ], - }; - - state = folderReducer(inititalState, action); - }); - - it('should add permissions to state', async () => { - expect(state.permissions.length).toBe(5); - }); - - it('should be sorted by sort rank and alphabetically', async () => { - expect(state.permissions[0].name).toBe('MyTestTeam'); - expect(state.permissions[0].dashboardId).toBe(10); - expect(state.permissions[1].name).toBe('Editor'); - expect(state.permissions[2].name).toBe('Viewer'); - expect(state.permissions[3].name).toBe('MyTestTeam2'); - expect(state.permissions[4].name).toBe('MyTestUser'); + describe('when loadFolderPermissions is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(folderReducer, { ...initialState }) + .whenActionIsDispatched( + loadFolderPermissions([ + { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View }, + { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, + { + id: 4, + dashboardId: 10, + permission: PermissionLevel.View, + teamId: 1, + team: 'MyTestTeam', + inherited: true, + }, + { + id: 5, + dashboardId: 1, + permission: PermissionLevel.View, + userId: 1, + userLogin: 'MyTestUser', + }, + { + id: 6, + dashboardId: 1, + permission: PermissionLevel.Edit, + teamId: 2, + team: 'MyTestTeam2', + }, + ]) + ) + .thenStateShouldEqual({ + ...initialState, + permissions: [ + { + dashboardId: 10, + id: 4, + inherited: true, + name: 'MyTestTeam', + permission: 1, + sortRank: 120, + team: 'MyTestTeam', + teamId: 1, + }, + { + dashboardId: 1, + icon: 'fa fa-fw fa-street-view', + id: 3, + name: 'Editor', + permission: 2, + role: OrgRole.Editor, + sortRank: 31, + }, + { + dashboardId: 1, + icon: 'fa fa-fw fa-street-view', + id: 2, + name: 'Viewer', + permission: 1, + role: OrgRole.Viewer, + sortRank: 30, + }, + { + dashboardId: 1, + id: 6, + name: 'MyTestTeam2', + permission: 2, + sortRank: 20, + team: 'MyTestTeam2', + teamId: 2, + }, + { + dashboardId: 1, + id: 5, + name: 'MyTestUser', + permission: 1, + sortRank: 10, + userId: 1, + userLogin: 'MyTestUser', + }, + ], + }); }); }); }); diff --git a/public/app/features/folders/state/reducers.ts b/public/app/features/folders/state/reducers.ts index 4560c999659..40b93c041ad 100644 --- a/public/app/features/folders/state/reducers.ts +++ b/public/app/features/folders/state/reducers.ts @@ -1,8 +1,9 @@ -import { FolderState } from 'app/types'; -import { Action, ActionTypes } from './actions'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { DashboardAclDTO, FolderDTO, FolderState } from 'app/types'; import { processAclItems } from 'app/core/utils/acl'; -export const inititalState: FolderState = { +export const initialState: FolderState = { id: 0, uid: 'loading', title: 'loading', @@ -13,28 +14,36 @@ export const inititalState: FolderState = { permissions: [], }; -export const folderReducer = (state = inititalState, action: Action): FolderState => { - switch (action.type) { - case ActionTypes.LoadFolder: +const folderSlice = createSlice({ + name: 'folder', + initialState, + reducers: { + loadFolder: (state, action: PayloadAction): FolderState => { return { ...state, ...action.payload, hasChanged: false, }; - case ActionTypes.SetFolderTitle: + }, + setFolderTitle: (state, action: PayloadAction): FolderState => { return { ...state, title: action.payload, hasChanged: action.payload.trim().length > 0, }; - case ActionTypes.LoadFolderPermissions: + }, + loadFolderPermissions: (state, action: PayloadAction): FolderState => { return { ...state, permissions: processAclItems(action.payload), }; - } - return state; -}; + }, + }, +}); + +export const { loadFolderPermissions, loadFolder, setFolderTitle } = folderSlice.actions; + +export const folderReducer = folderSlice.reducer; export default { folder: folderReducer, diff --git a/public/app/features/org/OrgDetailsPage.test.tsx b/public/app/features/org/OrgDetailsPage.test.tsx index fc11454bb0b..7b62b1f83a6 100644 --- a/public/app/features/org/OrgDetailsPage.test.tsx +++ b/public/app/features/org/OrgDetailsPage.test.tsx @@ -1,8 +1,11 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { NavModel } from '@grafana/data'; + import { OrgDetailsPage, Props } from './OrgDetailsPage'; import { Organization } from '../../types'; -import { NavModel } from '@grafana/data'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; +import { setOrganizationName } from './state/reducers'; const setup = (propOverrides?: object) => { const props: Props = { @@ -16,7 +19,7 @@ const setup = (propOverrides?: object) => { }, } as NavModel, loadOrganization: jest.fn(), - setOrganizationName: jest.fn(), + setOrganizationName: mockToolkitActionCreator(setOrganizationName), updateOrganization: jest.fn(), }; diff --git a/public/app/features/org/OrgDetailsPage.tsx b/public/app/features/org/OrgDetailsPage.tsx index 95061535fc9..3bb83189309 100644 --- a/public/app/features/org/OrgDetailsPage.tsx +++ b/public/app/features/org/OrgDetailsPage.tsx @@ -1,13 +1,15 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; +import { NavModel } from '@grafana/data'; + import Page from 'app/core/components/Page/Page'; import OrgProfile from './OrgProfile'; import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences'; -import { loadOrganization, setOrganizationName, updateOrganization } from './state/actions'; +import { loadOrganization, updateOrganization } from './state/actions'; import { Organization, StoreState } from 'app/types'; import { getNavModel } from 'app/core/selectors/navModel'; -import { NavModel } from '@grafana/data'; +import { setOrganizationName } from './state/reducers'; export interface Props { navModel: NavModel; diff --git a/public/app/features/org/state/actions.ts b/public/app/features/org/state/actions.ts index 214674783ce..f94cd4d1cc5 100644 --- a/public/app/features/org/state/actions.ts +++ b/public/app/features/org/state/actions.ts @@ -1,32 +1,6 @@ -import { Organization, ThunkResult } from 'app/types'; +import { ThunkResult } from 'app/types'; import { getBackendSrv } from '@grafana/runtime'; - -export enum ActionTypes { - LoadOrganization = 'LOAD_ORGANIZATION', - SetOrganizationName = 'SET_ORGANIZATION_NAME', -} - -interface LoadOrganizationAction { - type: ActionTypes.LoadOrganization; - payload: Organization; -} - -interface SetOrganizationNameAction { - type: ActionTypes.SetOrganizationName; - payload: string; -} - -const organizationLoaded = (organization: Organization) => ({ - type: ActionTypes.LoadOrganization, - payload: organization, -}); - -export const setOrganizationName = (orgName: string) => ({ - type: ActionTypes.SetOrganizationName, - payload: orgName, -}); - -export type Action = LoadOrganizationAction | SetOrganizationNameAction; +import { organizationLoaded } from './reducers'; export function loadOrganization(): ThunkResult { return async dispatch => { diff --git a/public/app/features/org/state/reducers.test.ts b/public/app/features/org/state/reducers.test.ts new file mode 100644 index 00000000000..49982c12273 --- /dev/null +++ b/public/app/features/org/state/reducers.test.ts @@ -0,0 +1,27 @@ +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { OrganizationState } from '../../../types'; +import { initialState, organizationLoaded, organizationReducer, setOrganizationName } from './reducers'; + +describe('organizationReducer', () => { + describe('when organizationLoaded is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(organizationReducer, { ...initialState }) + .whenActionIsDispatched(organizationLoaded({ id: 1, name: 'An org' })) + .thenStateShouldEqual({ + organization: { id: 1, name: 'An org' }, + }); + }); + }); + + describe('when setOrganizationName is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(organizationReducer, { ...initialState, organization: { id: 1, name: 'An org' } }) + .whenActionIsDispatched(setOrganizationName('New Name')) + .thenStateShouldEqual({ + organization: { id: 1, name: 'New Name' }, + }); + }); + }); +}); diff --git a/public/app/features/org/state/reducers.ts b/public/app/features/org/state/reducers.ts index acde6d17e5f..0c459c58541 100644 --- a/public/app/features/org/state/reducers.ts +++ b/public/app/features/org/state/reducers.ts @@ -1,21 +1,27 @@ -import { Organization, OrganizationState } from 'app/types'; -import { Action, ActionTypes } from './actions'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -const initialState: OrganizationState = { +import { Organization, OrganizationState } from 'app/types'; + +export const initialState: OrganizationState = { organization: {} as Organization, }; -const organizationReducer = (state = initialState, action: Action): OrganizationState => { - switch (action.type) { - case ActionTypes.LoadOrganization: +const organizationSlice = createSlice({ + name: 'organization', + initialState, + reducers: { + organizationLoaded: (state, action: PayloadAction): OrganizationState => { return { ...state, organization: action.payload }; - - case ActionTypes.SetOrganizationName: + }, + setOrganizationName: (state, action: PayloadAction): OrganizationState => { return { ...state, organization: { ...state.organization, name: action.payload } }; - } + }, + }, +}); - return state; -}; +export const { setOrganizationName, organizationLoaded } = organizationSlice.actions; + +export const organizationReducer = organizationSlice.reducer; export default { organization: organizationReducer, diff --git a/public/app/features/plugins/PluginListPage.test.tsx b/public/app/features/plugins/PluginListPage.test.tsx index 549fcb85533..3d07956304d 100644 --- a/public/app/features/plugins/PluginListPage.test.tsx +++ b/public/app/features/plugins/PluginListPage.test.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { PluginListPage, Props } from './PluginListPage'; import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; -import { NavModel } from '@grafana/data'; -import { PluginMeta } from '@grafana/data'; +import { NavModel, PluginMeta } from '@grafana/data'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; +import { setPluginsLayoutMode, setPluginsSearchQuery } from './state/reducers'; const setup = (propOverrides?: object) => { const props: Props = { @@ -17,8 +18,8 @@ const setup = (propOverrides?: object) => { } as NavModel, plugins: [] as PluginMeta[], searchQuery: '', - setPluginsSearchQuery: jest.fn(), - setPluginsLayoutMode: jest.fn(), + setPluginsSearchQuery: mockToolkitActionCreator(setPluginsSearchQuery), + setPluginsLayoutMode: mockToolkitActionCreator(setPluginsLayoutMode), layoutMode: LayoutModes.Grid, loadPlugins: jest.fn(), hasFetched: false, diff --git a/public/app/features/plugins/PluginListPage.tsx b/public/app/features/plugins/PluginListPage.tsx index f55bbdb421f..4d75d038d67 100644 --- a/public/app/features/plugins/PluginListPage.tsx +++ b/public/app/features/plugins/PluginListPage.tsx @@ -4,13 +4,13 @@ import { connect } from 'react-redux'; import Page from 'app/core/components/Page/Page'; import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar'; import PluginList from './PluginList'; -import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions'; +import { loadPlugins } from './state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; import { getLayoutMode, getPlugins, getPluginsSearchQuery } from './state/selectors'; import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector'; -import { NavModel } from '@grafana/data'; -import { PluginMeta } from '@grafana/data'; +import { NavModel, PluginMeta } from '@grafana/data'; import { StoreState } from 'app/types'; +import { setPluginsLayoutMode, setPluginsSearchQuery } from './state/reducers'; export interface Props { navModel: NavModel; diff --git a/public/app/features/plugins/state/actions.ts b/public/app/features/plugins/state/actions.ts index f51ee7c1c3a..c9dce425caa 100644 --- a/public/app/features/plugins/state/actions.ts +++ b/public/app/features/plugins/state/actions.ts @@ -1,74 +1,7 @@ -import { StoreState } from 'app/types'; -import { ThunkAction } from 'redux-thunk'; import { getBackendSrv } from '@grafana/runtime'; -import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector'; -import { PluginDashboard } from '../../../types/plugins'; -import { PluginMeta } from '@grafana/data'; -export enum ActionTypes { - LoadPlugins = 'LOAD_PLUGINS', - LoadPluginDashboards = 'LOAD_PLUGIN_DASHBOARDS', - LoadedPluginDashboards = 'LOADED_PLUGIN_DASHBOARDS', - SetPluginsSearchQuery = 'SET_PLUGIN_SEARCH_QUERY', - SetLayoutMode = 'SET_LAYOUT_MODE', -} - -export interface LoadPluginsAction { - type: ActionTypes.LoadPlugins; - payload: PluginMeta[]; -} - -export interface LoadPluginDashboardsAction { - type: ActionTypes.LoadPluginDashboards; -} - -export interface LoadedPluginDashboardsAction { - type: ActionTypes.LoadedPluginDashboards; - payload: PluginDashboard[]; -} - -export interface SetPluginsSearchQueryAction { - type: ActionTypes.SetPluginsSearchQuery; - payload: string; -} - -export interface SetLayoutModeAction { - type: ActionTypes.SetLayoutMode; - payload: LayoutMode; -} - -export const setPluginsLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({ - type: ActionTypes.SetLayoutMode, - payload: mode, -}); - -export const setPluginsSearchQuery = (query: string): SetPluginsSearchQueryAction => ({ - type: ActionTypes.SetPluginsSearchQuery, - payload: query, -}); - -const pluginsLoaded = (plugins: PluginMeta[]): LoadPluginsAction => ({ - type: ActionTypes.LoadPlugins, - payload: plugins, -}); - -const pluginDashboardsLoad = (): LoadPluginDashboardsAction => ({ - type: ActionTypes.LoadPluginDashboards, -}); - -const pluginDashboardsLoaded = (dashboards: PluginDashboard[]): LoadedPluginDashboardsAction => ({ - type: ActionTypes.LoadedPluginDashboards, - payload: dashboards, -}); - -export type Action = - | LoadPluginsAction - | LoadPluginDashboardsAction - | LoadedPluginDashboardsAction - | SetPluginsSearchQueryAction - | SetLayoutModeAction; - -type ThunkResult = ThunkAction; +import { ThunkResult } from 'app/types'; +import { pluginDashboardsLoad, pluginDashboardsLoaded, pluginsLoaded } from './reducers'; export function loadPlugins(): ThunkResult { return async dispatch => { diff --git a/public/app/features/plugins/state/reducers.test.ts b/public/app/features/plugins/state/reducers.test.ts new file mode 100644 index 00000000000..430f92581d3 --- /dev/null +++ b/public/app/features/plugins/state/reducers.test.ts @@ -0,0 +1,151 @@ +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { PluginsState } from '../../../types'; +import { + initialState, + pluginDashboardsLoad, + pluginDashboardsLoaded, + pluginsLoaded, + pluginsReducer, + setPluginsLayoutMode, + setPluginsSearchQuery, +} from './reducers'; +import { PluginMetaInfo, PluginType } from '@grafana/data'; +import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector'; + +describe('pluginsReducer', () => { + describe('when pluginsLoaded is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(pluginsReducer, { ...initialState }) + .whenActionIsDispatched( + pluginsLoaded([ + { + id: 'some-id', + baseUrl: 'some-url', + module: 'some module', + name: 'Some Plugin', + type: PluginType.app, + info: {} as PluginMetaInfo, + }, + ]) + ) + .thenStateShouldEqual({ + ...initialState, + hasFetched: true, + plugins: [ + { + baseUrl: 'some-url', + id: 'some-id', + info: {} as PluginMetaInfo, + module: 'some module', + name: 'Some Plugin', + type: PluginType.app, + }, + ], + }); + }); + }); + + describe('when setPluginsSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(pluginsReducer, { ...initialState }) + .whenActionIsDispatched(setPluginsSearchQuery('A query')) + .thenStateShouldEqual({ + ...initialState, + searchQuery: 'A query', + }); + }); + }); + + describe('when setPluginsLayoutMode is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(pluginsReducer, { ...initialState }) + .whenActionIsDispatched(setPluginsLayoutMode(LayoutModes.List)) + .thenStateShouldEqual({ + ...initialState, + layoutMode: LayoutModes.List, + }); + }); + }); + + describe('when pluginDashboardsLoad is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(pluginsReducer, { + ...initialState, + dashboards: [ + { + dashboardId: 1, + title: 'Some Dash', + description: 'Some Desc', + folderId: 2, + imported: false, + importedRevision: 1, + importedUri: 'some-uri', + importedUrl: 'some-url', + path: 'some/path', + pluginId: 'some-plugin-id', + removed: false, + revision: 22, + slug: 'someSlug', + }, + ], + }) + .whenActionIsDispatched(pluginDashboardsLoad()) + .thenStateShouldEqual({ + ...initialState, + dashboards: [], + isLoadingPluginDashboards: true, + }); + }); + }); + + describe('when pluginDashboardsLoad is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(pluginsReducer, { ...initialState, isLoadingPluginDashboards: true }) + .whenActionIsDispatched( + pluginDashboardsLoaded([ + { + dashboardId: 1, + title: 'Some Dash', + description: 'Some Desc', + folderId: 2, + imported: false, + importedRevision: 1, + importedUri: 'some-uri', + importedUrl: 'some-url', + path: 'some/path', + pluginId: 'some-plugin-id', + removed: false, + revision: 22, + slug: 'someSlug', + }, + ]) + ) + .thenStateShouldEqual({ + ...initialState, + dashboards: [ + { + dashboardId: 1, + title: 'Some Dash', + description: 'Some Desc', + folderId: 2, + imported: false, + importedRevision: 1, + importedUri: 'some-uri', + importedUrl: 'some-url', + path: 'some/path', + pluginId: 'some-plugin-id', + removed: false, + revision: 22, + slug: 'someSlug', + }, + ], + isLoadingPluginDashboards: false, + }); + }); + }); +}); diff --git a/public/app/features/plugins/state/reducers.ts b/public/app/features/plugins/state/reducers.ts index 9527950c040..4933774a22e 100644 --- a/public/app/features/plugins/state/reducers.ts +++ b/public/app/features/plugins/state/reducers.ts @@ -1,37 +1,49 @@ -import { Action, ActionTypes } from './actions'; -import { PluginsState } from 'app/types'; -import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector'; -import { PluginDashboard } from '../../../types/plugins'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { PluginMeta } from '@grafana/data'; +import { PluginsState } from 'app/types'; +import { LayoutMode, LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector'; +import { PluginDashboard } from '../../../types/plugins'; export const initialState: PluginsState = { - plugins: [] as PluginMeta[], + plugins: [], searchQuery: '', layoutMode: LayoutModes.Grid, hasFetched: false, - dashboards: [] as PluginDashboard[], + dashboards: [], isLoadingPluginDashboards: false, }; -export const pluginsReducer = (state = initialState, action: Action): PluginsState => { - switch (action.type) { - case ActionTypes.LoadPlugins: +const pluginsSlice = createSlice({ + name: 'plugins', + initialState, + reducers: { + pluginsLoaded: (state, action: PayloadAction): PluginsState => { return { ...state, hasFetched: true, plugins: action.payload }; - - case ActionTypes.SetPluginsSearchQuery: + }, + setPluginsSearchQuery: (state, action: PayloadAction): PluginsState => { return { ...state, searchQuery: action.payload }; - - case ActionTypes.SetLayoutMode: + }, + setPluginsLayoutMode: (state, action: PayloadAction): PluginsState => { return { ...state, layoutMode: action.payload }; - - case ActionTypes.LoadPluginDashboards: + }, + pluginDashboardsLoad: (state, action: PayloadAction): PluginsState => { return { ...state, dashboards: [], isLoadingPluginDashboards: true }; - - case ActionTypes.LoadedPluginDashboards: + }, + pluginDashboardsLoaded: (state, action: PayloadAction): PluginsState => { return { ...state, dashboards: action.payload, isLoadingPluginDashboards: false }; - } - return state; -}; + }, + }, +}); + +export const { + pluginsLoaded, + pluginDashboardsLoad, + pluginDashboardsLoaded, + setPluginsLayoutMode, + setPluginsSearchQuery, +} = pluginsSlice.actions; + +export const pluginsReducer = pluginsSlice.reducer; export default { plugins: pluginsReducer, diff --git a/public/app/features/teams/CreateTeam.test.tsx b/public/app/features/teams/CreateTeam.test.tsx index 66d19bf3850..e37bb68454a 100644 --- a/public/app/features/teams/CreateTeam.test.tsx +++ b/public/app/features/teams/CreateTeam.test.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { shallow } from 'enzyme'; import { CreateTeam, Props } from './CreateTeam'; -import { mockActionCreator } from 'app/core/redux'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { updateLocation } from 'app/core/actions'; describe('Render', () => { it('should render component', () => { const props: Props = { - updateLocation: mockActionCreator(updateLocation), + updateLocation: mockToolkitActionCreator(updateLocation), navModel: {} as any, }; const wrapper = shallow(); diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index 803b3b3f6dd..3d96a8916f9 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Props, TeamList } from './TeamList'; -import { Team, OrgRole } from '../../types'; +import { OrgRole, Team } from '../../types'; import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks'; import { User } from 'app/core/services/context_srv'; import { NavModel } from '@grafana/data'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; +import { setSearchQuery } from './state/reducers'; const setup = (propOverrides?: object) => { const props: Props = { @@ -19,7 +21,7 @@ const setup = (propOverrides?: object) => { teams: [] as Team[], loadTeams: jest.fn(), deleteTeam: jest.fn(), - setSearchQuery: jest.fn(), + setSearchQuery: mockToolkitActionCreator(setSearchQuery), searchQuery: '', teamsCount: 0, hasFetched: false, diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index 75ba2f3642b..1ede12e05bf 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -4,14 +4,15 @@ import Page from 'app/core/components/Page/Page'; import { DeleteButton } from '@grafana/ui'; import { NavModel } from '@grafana/data'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { Team, OrgRole, StoreState } from 'app/types'; -import { loadTeams, deleteTeam, setSearchQuery } from './state/actions'; +import { OrgRole, StoreState, Team } from 'app/types'; +import { deleteTeam, loadTeams } from './state/actions'; import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors'; import { getNavModel } from 'app/core/selectors/navModel'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { config } from 'app/core/config'; import { contextSrv, User } from 'app/core/services/context_srv'; import { connectWithCleanUp } from '../../core/components/connectWithCleanUp'; +import { setSearchQuery } from './state/reducers'; export interface Props { navModel: NavModel; diff --git a/public/app/features/teams/TeamMembers.test.tsx b/public/app/features/teams/TeamMembers.test.tsx index c57b577a0f9..fdd418ebd02 100644 --- a/public/app/features/teams/TeamMembers.test.tsx +++ b/public/app/features/teams/TeamMembers.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { TeamMembers, Props, State } from './TeamMembers'; -import { TeamMember, OrgRole } from '../../types'; +import { Props, State, TeamMembers } from './TeamMembers'; +import { OrgRole, TeamMember } from '../../types'; import { getMockTeamMembers } from './__mocks__/teamMocks'; import { User } from 'app/core/services/context_srv'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; +import { setSearchMemberQuery } from './state/reducers'; const signedInUserId = 1; @@ -11,7 +13,7 @@ const setup = (propOverrides?: object) => { const props: Props = { members: [] as TeamMember[], searchMemberQuery: '', - setSearchMemberQuery: jest.fn(), + setSearchMemberQuery: mockToolkitActionCreator(setSearchMemberQuery), addTeamMember: jest.fn(), syncEnabled: false, editorsCanAdmin: false, diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index 1f48988d983..ebbeb3a71cd 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -4,13 +4,14 @@ import { SlideDown } from 'app/core/components/Animations/SlideDown'; import { UserPicker } from 'app/core/components/Select/UserPicker'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; import { TeamMember, User } from 'app/types'; -import { addTeamMember, setSearchMemberQuery } from './state/actions'; +import { addTeamMember } from './state/actions'; import { getSearchMemberQuery, isSignedInUserTeamAdmin } from './state/selectors'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle'; import { config } from 'app/core/config'; import { contextSrv, User as SignedInUser } from 'app/core/services/context_srv'; import TeamMemberRow from './TeamMemberRow'; +import { setSearchMemberQuery } from './state/reducers'; export interface Props { members: TeamMember[]; diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts index 001a1d1b086..6d15acdc703 100644 --- a/public/app/features/teams/state/actions.ts +++ b/public/app/features/teams/state/actions.ts @@ -1,87 +1,9 @@ -import { ThunkAction } from 'redux-thunk'; import { getBackendSrv } from '@grafana/runtime'; -import { StoreState, Team, TeamGroup, TeamMember } from 'app/types'; -import { updateNavIndex, UpdateNavIndexAction } from 'app/core/actions'; + +import { TeamMember, ThunkResult } from 'app/types'; +import { updateNavIndex } from 'app/core/actions'; import { buildNavModel } from './navModel'; - -export enum ActionTypes { - LoadTeams = 'LOAD_TEAMS', - LoadTeam = 'LOAD_TEAM', - SetSearchQuery = 'SET_TEAM_SEARCH_QUERY', - SetSearchMemberQuery = 'SET_TEAM_MEMBER_SEARCH_QUERY', - LoadTeamMembers = 'TEAM_MEMBERS_LOADED', - LoadTeamGroups = 'TEAM_GROUPS_LOADED', -} - -export interface LoadTeamsAction { - type: ActionTypes.LoadTeams; - payload: Team[]; -} - -export interface LoadTeamAction { - type: ActionTypes.LoadTeam; - payload: Team; -} - -export interface LoadTeamMembersAction { - type: ActionTypes.LoadTeamMembers; - payload: TeamMember[]; -} - -export interface LoadTeamGroupsAction { - type: ActionTypes.LoadTeamGroups; - payload: TeamGroup[]; -} - -export interface SetSearchQueryAction { - type: ActionTypes.SetSearchQuery; - payload: string; -} - -export interface SetSearchMemberQueryAction { - type: ActionTypes.SetSearchMemberQuery; - payload: string; -} - -export type Action = - | LoadTeamsAction - | SetSearchQueryAction - | LoadTeamAction - | LoadTeamMembersAction - | SetSearchMemberQueryAction - | LoadTeamGroupsAction; - -type ThunkResult = ThunkAction; - -const teamsLoaded = (teams: Team[]): LoadTeamsAction => ({ - type: ActionTypes.LoadTeams, - payload: teams, -}); - -const teamLoaded = (team: Team): LoadTeamAction => ({ - type: ActionTypes.LoadTeam, - payload: team, -}); - -const teamMembersLoaded = (teamMembers: TeamMember[]): LoadTeamMembersAction => ({ - type: ActionTypes.LoadTeamMembers, - payload: teamMembers, -}); - -const teamGroupsLoaded = (teamGroups: TeamGroup[]): LoadTeamGroupsAction => ({ - type: ActionTypes.LoadTeamGroups, - payload: teamGroups, -}); - -export const setSearchMemberQuery = (searchQuery: string): SetSearchMemberQueryAction => ({ - type: ActionTypes.SetSearchMemberQuery, - payload: searchQuery, -}); - -export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({ - type: ActionTypes.SetSearchQuery, - payload: searchQuery, -}); +import { teamGroupsLoaded, teamLoaded, teamMembersLoaded, teamsLoaded } from './reducers'; export function loadTeams(): ThunkResult { return async dispatch => { diff --git a/public/app/features/teams/state/reducers.test.ts b/public/app/features/teams/state/reducers.test.ts index 7f7a33d60ac..0dd9d83eaa6 100644 --- a/public/app/features/teams/state/reducers.test.ts +++ b/public/app/features/teams/state/reducers.test.ts @@ -1,72 +1,92 @@ -import { Action, ActionTypes } from './actions'; -import { initialTeamsState, initialTeamState, teamReducer, teamsReducer } from './reducers'; -import { getMockTeam, getMockTeamMember } from '../__mocks__/teamMocks'; +import { + initialTeamsState, + initialTeamState, + setSearchMemberQuery, + setSearchQuery, + teamGroupsLoaded, + teamLoaded, + teamMembersLoaded, + teamReducer, + teamsLoaded, + teamsReducer, +} from './reducers'; +import { getMockTeam, getMockTeamGroups, getMockTeamMember } from '../__mocks__/teamMocks'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { TeamsState, TeamState } from '../../../types'; describe('teams reducer', () => { - it('should set teams', () => { - const payload = [getMockTeam()]; - - const action: Action = { - type: ActionTypes.LoadTeams, - payload, - }; - - const result = teamsReducer(initialTeamsState, action); - - expect(result.teams).toEqual(payload); + describe('when teamsLoaded is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(teamsReducer, { ...initialTeamsState }) + .whenActionIsDispatched(teamsLoaded([getMockTeam()])) + .thenStateShouldEqual({ + ...initialTeamsState, + hasFetched: true, + teams: [getMockTeam()], + }); + }); }); - it('should set search query', () => { - const payload = 'test'; - - const action: Action = { - type: ActionTypes.SetSearchQuery, - payload, - }; - - const result = teamsReducer(initialTeamsState, action); - - expect(result.searchQuery).toEqual('test'); + describe('when setSearchQueryAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(teamsReducer, { ...initialTeamsState }) + .whenActionIsDispatched(setSearchQuery('test')) + .thenStateShouldEqual({ + ...initialTeamsState, + searchQuery: 'test', + }); + }); }); }); describe('team reducer', () => { - it('should set team', () => { - const payload = getMockTeam(); - - const action: Action = { - type: ActionTypes.LoadTeam, - payload, - }; - - const result = teamReducer(initialTeamState, action); - - expect(result.team).toEqual(payload); + describe('when loadTeamsAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(teamReducer, { ...initialTeamState }) + .whenActionIsDispatched(teamLoaded(getMockTeam())) + .thenStateShouldEqual({ + ...initialTeamState, + team: getMockTeam(), + }); + }); }); - it('should set team members', () => { - const mockTeamMember = getMockTeamMember(); - - const action: Action = { - type: ActionTypes.LoadTeamMembers, - payload: [mockTeamMember], - }; - - const result = teamReducer(initialTeamState, action); - - expect(result.members).toEqual([mockTeamMember]); + describe('when loadTeamMembersAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(teamReducer, { ...initialTeamState }) + .whenActionIsDispatched(teamMembersLoaded([getMockTeamMember()])) + .thenStateShouldEqual({ + ...initialTeamState, + members: [getMockTeamMember()], + }); + }); }); - it('should set member search query', () => { - const payload = 'member'; + describe('when setSearchMemberQueryAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(teamReducer, { ...initialTeamState }) + .whenActionIsDispatched(setSearchMemberQuery('member')) + .thenStateShouldEqual({ + ...initialTeamState, + searchMemberQuery: 'member', + }); + }); + }); - const action: Action = { - type: ActionTypes.SetSearchMemberQuery, - payload, - }; - - const result = teamReducer(initialTeamState, action); - - expect(result.searchMemberQuery).toEqual('member'); + describe('when loadTeamGroupsAction is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(teamReducer, { ...initialTeamState }) + .whenActionIsDispatched(teamGroupsLoaded(getMockTeamGroups(1))) + .thenStateShouldEqual({ + ...initialTeamState, + groups: getMockTeamGroups(1), + }); + }); }); }); diff --git a/public/app/features/teams/state/reducers.ts b/public/app/features/teams/state/reducers.ts index 2e72dce0afb..392dbf1c5ac 100644 --- a/public/app/features/teams/state/reducers.ts +++ b/public/app/features/teams/state/reducers.ts @@ -1,7 +1,26 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types'; -import { Action, ActionTypes } from './actions'; export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', hasFetched: false }; + +const teamsSlice = createSlice({ + name: 'teams', + initialState: initialTeamsState, + reducers: { + teamsLoaded: (state, action: PayloadAction): TeamsState => { + return { ...state, hasFetched: true, teams: action.payload }; + }, + setSearchQuery: (state, action: PayloadAction): TeamsState => { + return { ...state, searchQuery: action.payload }; + }, + }, +}); + +export const { teamsLoaded, setSearchQuery } = teamsSlice.actions; + +export const teamsReducer = teamsSlice.reducer; + export const initialTeamState: TeamState = { team: {} as Team, members: [] as TeamMember[], @@ -9,34 +28,28 @@ export const initialTeamState: TeamState = { searchMemberQuery: '', }; -export const teamsReducer = (state = initialTeamsState, action: Action): TeamsState => { - switch (action.type) { - case ActionTypes.LoadTeams: - return { ...state, hasFetched: true, teams: action.payload }; - - case ActionTypes.SetSearchQuery: - return { ...state, searchQuery: action.payload }; - } - return state; -}; - -export const teamReducer = (state = initialTeamState, action: Action): TeamState => { - switch (action.type) { - case ActionTypes.LoadTeam: +const teamSlice = createSlice({ + name: 'team', + initialState: initialTeamState, + reducers: { + teamLoaded: (state, action: PayloadAction): TeamState => { return { ...state, team: action.payload }; - - case ActionTypes.LoadTeamMembers: + }, + teamMembersLoaded: (state, action: PayloadAction): TeamState => { return { ...state, members: action.payload }; - - case ActionTypes.SetSearchMemberQuery: + }, + setSearchMemberQuery: (state, action: PayloadAction): TeamState => { return { ...state, searchMemberQuery: action.payload }; - - case ActionTypes.LoadTeamGroups: + }, + teamGroupsLoaded: (state, action: PayloadAction): TeamState => { return { ...state, groups: action.payload }; - } + }, + }, +}); - return state; -}; +export const { teamLoaded, teamGroupsLoaded, teamMembersLoaded, setSearchMemberQuery } = teamSlice.actions; + +export const teamReducer = teamSlice.reducer; export default { teams: teamsReducer, diff --git a/public/app/features/users/UsersActionBar.test.tsx b/public/app/features/users/UsersActionBar.test.tsx index 7326de4d247..1364bb1774b 100644 --- a/public/app/features/users/UsersActionBar.test.tsx +++ b/public/app/features/users/UsersActionBar.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { UsersActionBar, Props } from './UsersActionBar'; +import { Props, UsersActionBar } from './UsersActionBar'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; +import { setUsersSearchQuery } from './state/reducers'; const setup = (propOverrides?: object) => { const props: Props = { searchQuery: '', - setUsersSearchQuery: jest.fn(), + setUsersSearchQuery: mockToolkitActionCreator(setUsersSearchQuery), onShowInvites: jest.fn(), pendingInvitesCount: 0, canInvite: false, diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx index f31384f823b..6e7d56294c6 100644 --- a/public/app/features/users/UsersActionBar.tsx +++ b/public/app/features/users/UsersActionBar.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import classNames from 'classnames'; -import { setUsersSearchQuery } from './state/actions'; +import { setUsersSearchQuery } from './state/reducers'; import { getInviteesCount, getUsersSearchQuery } from './state/selectors'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; diff --git a/public/app/features/users/UsersListPage.test.tsx b/public/app/features/users/UsersListPage.test.tsx index a17b9b086cf..96d5c17d2ed 100644 --- a/public/app/features/users/UsersListPage.test.tsx +++ b/public/app/features/users/UsersListPage.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { UsersListPage, Props } from './UsersListPage'; +import { Props, UsersListPage } from './UsersListPage'; import { Invitee, OrgUser } from 'app/types'; import { getMockUser } from './__mocks__/userMocks'; import appEvents from '../../core/app_events'; import { NavModel } from '@grafana/data'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; +import { setUsersSearchQuery } from './state/reducers'; jest.mock('../../core/app_events', () => ({ emit: jest.fn(), @@ -28,7 +30,7 @@ const setup = (propOverrides?: object) => { loadUsers: jest.fn(), updateUser: jest.fn(), removeUser: jest.fn(), - setUsersSearchQuery: jest.fn(), + setUsersSearchQuery: mockToolkitActionCreator(setUsersSearchQuery), hasFetched: false, }; diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx index ab32f289528..60087857d34 100644 --- a/public/app/features/users/UsersListPage.tsx +++ b/public/app/features/users/UsersListPage.tsx @@ -1,17 +1,18 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -import { renderMarkdown } from '@grafana/data'; +import { NavModel, renderMarkdown } from '@grafana/data'; + import Page from 'app/core/components/Page/Page'; import UsersActionBar from './UsersActionBar'; import UsersTable from './UsersTable'; import InviteesTable from './InviteesTable'; -import { Invitee, OrgUser, CoreEvents } from 'app/types'; +import { CoreEvents, Invitee, OrgUser } from 'app/types'; import appEvents from 'app/core/app_events'; -import { loadUsers, loadInvitees, setUsersSearchQuery, updateUser, removeUser } from './state/actions'; +import { loadInvitees, loadUsers, removeUser, updateUser } from './state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors'; -import { NavModel } from '@grafana/data'; +import { setUsersSearchQuery } from './state/reducers'; export interface Props { navModel: NavModel; diff --git a/public/app/features/users/state/actions.ts b/public/app/features/users/state/actions.ts index 3d69e663859..8acdf17e004 100644 --- a/public/app/features/users/state/actions.ts +++ b/public/app/features/users/state/actions.ts @@ -1,47 +1,7 @@ -import { ThunkAction } from 'redux-thunk'; -import { StoreState } from '../../../types'; +import { ThunkResult } from '../../../types'; import { getBackendSrv } from '@grafana/runtime'; -import { Invitee, OrgUser } from 'app/types'; - -export enum ActionTypes { - LoadUsers = 'LOAD_USERS', - LoadInvitees = 'LOAD_INVITEES', - SetUsersSearchQuery = 'SET_USERS_SEARCH_QUERY', -} - -export interface LoadUsersAction { - type: ActionTypes.LoadUsers; - payload: OrgUser[]; -} - -export interface LoadInviteesAction { - type: ActionTypes.LoadInvitees; - payload: Invitee[]; -} - -export interface SetUsersSearchQueryAction { - type: ActionTypes.SetUsersSearchQuery; - payload: string; -} - -const usersLoaded = (users: OrgUser[]): LoadUsersAction => ({ - type: ActionTypes.LoadUsers, - payload: users, -}); - -const inviteesLoaded = (invitees: Invitee[]): LoadInviteesAction => ({ - type: ActionTypes.LoadInvitees, - payload: invitees, -}); - -export const setUsersSearchQuery = (query: string): SetUsersSearchQueryAction => ({ - type: ActionTypes.SetUsersSearchQuery, - payload: query, -}); - -export type Action = LoadUsersAction | SetUsersSearchQueryAction | LoadInviteesAction; - -type ThunkResult = ThunkAction; +import { OrgUser } from 'app/types'; +import { inviteesLoaded, usersLoaded } from './reducers'; export function loadUsers(): ThunkResult { return async dispatch => { diff --git a/public/app/features/users/state/reducers.test.ts b/public/app/features/users/state/reducers.test.ts new file mode 100644 index 00000000000..31912950686 --- /dev/null +++ b/public/app/features/users/state/reducers.test.ts @@ -0,0 +1,44 @@ +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { UsersState } from '../../../types'; +import { initialState, inviteesLoaded, setUsersSearchQuery, usersLoaded, usersReducer } from './reducers'; +import { getMockInvitees, getMockUsers } from '../__mocks__/userMocks'; + +describe('usersReducer', () => { + describe('when usersLoaded is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(usersReducer, { ...initialState }) + .whenActionIsDispatched(usersLoaded(getMockUsers(1))) + .thenStateShouldEqual({ + ...initialState, + users: getMockUsers(1), + hasFetched: true, + }); + }); + }); + + describe('when inviteesLoaded is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(usersReducer, { ...initialState }) + .whenActionIsDispatched(inviteesLoaded(getMockInvitees(1))) + .thenStateShouldEqual({ + ...initialState, + invitees: getMockInvitees(1), + hasFetched: true, + }); + }); + }); + + describe('when setUsersSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(usersReducer, { ...initialState }) + .whenActionIsDispatched(setUsersSearchQuery('a query')) + .thenStateShouldEqual({ + ...initialState, + searchQuery: 'a query', + }); + }); + }); +}); diff --git a/public/app/features/users/state/reducers.ts b/public/app/features/users/state/reducers.ts index e9896a28d11..6e3a36c35a9 100644 --- a/public/app/features/users/state/reducers.ts +++ b/public/app/features/users/state/reducers.ts @@ -1,5 +1,6 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + import { Invitee, OrgUser, UsersState } from 'app/types'; -import { Action, ActionTypes } from './actions'; import config from 'app/core/config'; export const initialState: UsersState = { @@ -13,20 +14,25 @@ export const initialState: UsersState = { hasFetched: false, }; -export const usersReducer = (state = initialState, action: Action): UsersState => { - switch (action.type) { - case ActionTypes.LoadUsers: +const usersSlice = createSlice({ + name: 'users', + initialState, + reducers: { + usersLoaded: (state, action: PayloadAction): UsersState => { return { ...state, hasFetched: true, users: action.payload }; - - case ActionTypes.LoadInvitees: + }, + inviteesLoaded: (state, action: PayloadAction): UsersState => { return { ...state, hasFetched: true, invitees: action.payload }; - - case ActionTypes.SetUsersSearchQuery: + }, + setUsersSearchQuery: (state, action: PayloadAction): UsersState => { return { ...state, searchQuery: action.payload }; - } + }, + }, +}); - return state; -}; +export const { inviteesLoaded, setUsersSearchQuery, usersLoaded } = usersSlice.actions; + +export const usersReducer = usersSlice.reducer; export default { users: usersReducer, diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 2cb2871b6d1..73fb663c920 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -1,12 +1,12 @@ -import { applyMiddleware, compose, createStore } from 'redux'; -import thunk from 'redux-thunk'; +import { configureStore as reduxConfigureStore } from '@reduxjs/toolkit'; import { createLogger } from 'redux-logger'; +import thunk from 'redux-thunk'; import { setStore } from './store'; import { StoreState } from 'app/types/store'; import { toggleLogActionsMiddleware } from 'app/core/middlewares/application'; import { addReducer, createRootReducer } from '../core/reducers/root'; -import { ActionOf } from 'app/core/redux'; +import { buildInitialState } from '../core/reducers/navModel'; export function addRootReducer(reducers: any) { // this is ok now because we add reducers before configureStore is called @@ -16,23 +16,23 @@ export function addRootReducer(reducers: any) { } export function configureStore() { - const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; - const logger = createLogger({ predicate: (getState: () => StoreState) => { return getState().application.logActions; }, }); - const storeEnhancers = - process.env.NODE_ENV !== 'production' - ? applyMiddleware(toggleLogActionsMiddleware, thunk, logger) - : applyMiddleware(thunk); - const store = createStore, any, any>( - createRootReducer(), - {}, - composeEnhancers(storeEnhancers) - ); + const middleware = process.env.NODE_ENV !== 'production' ? [toggleLogActionsMiddleware, thunk, logger] : [thunk]; + + const store = reduxConfigureStore({ + reducer: createRootReducer(), + middleware, + devTools: process.env.NODE_ENV !== 'production', + preloadedState: { + navIndex: buildInitialState(), + }, + }); + setStore(store); return store; } diff --git a/public/app/types/alerting.ts b/public/app/types/alerting.ts index 26516a052ab..eccae13e25a 100644 --- a/public/app/types/alerting.ts +++ b/public/app/types/alerting.ts @@ -16,9 +16,12 @@ export interface AlertRuleDTO { export interface AlertRule { id: number; dashboardId: number; + dashboardUid?: string; + dashboardSlug?: string; panelId: number; name: string; state: string; + newStateDate?: string; stateText: string; stateIcon: string; stateClass: string; @@ -26,6 +29,7 @@ export interface AlertRule { url: string; info?: string; executionError?: string; + evalDate?: string; evalData?: { noData?: boolean; evalMatches?: any }; } diff --git a/public/app/types/store.ts b/public/app/types/store.ts index fd4358a8c82..7d8a2dac35c 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -1,5 +1,6 @@ import { ThunkAction, ThunkDispatch as GenericThunkDispatch } from 'redux-thunk'; -import { ActionOf } from 'app/core/redux'; +import { PayloadAction } from '@reduxjs/toolkit'; +import { NavIndex } from '@grafana/data'; import { LocationState } from './location'; import { AlertRulesState } from './alerting'; @@ -12,7 +13,6 @@ import { UsersState, UserState } from './user'; import { OrganizationState } from './organization'; import { AppNotificationsState } from './appNotifications'; import { PluginsState } from './plugins'; -import { NavIndex } from '@grafana/data'; import { ApplicationState } from './application'; import { LdapState, LdapUserState } from './ldap'; import { PanelEditorState } from '../features/dashboard/panel_editor/state/reducers'; @@ -43,6 +43,6 @@ export interface StoreState { /* * Utility type to get strongly types thunks */ -export type ThunkResult = ThunkAction>; +export type ThunkResult = ThunkAction>; export type ThunkDispatch = GenericThunkDispatch; diff --git a/public/test/core/redux/mocks.ts b/public/test/core/redux/mocks.ts new file mode 100644 index 00000000000..38c3a75ce4a --- /dev/null +++ b/public/test/core/redux/mocks.ts @@ -0,0 +1,12 @@ +import { ActionCreatorWithoutPayload, PayloadActionCreator } from '@reduxjs/toolkit'; + +export const mockToolkitActionCreator = (creator: PayloadActionCreator) => { + return Object.assign(jest.fn(), creator); +}; + +export type ToolkitActionCreatorWithoutPayloadMockType = typeof mockToolkitActionCreatorWithoutPayload & + ActionCreatorWithoutPayload; + +export const mockToolkitActionCreatorWithoutPayload = (creator: ActionCreatorWithoutPayload) => { + return Object.assign(jest.fn(), creator); +}; diff --git a/public/test/core/redux/reducerTester.test.ts b/public/test/core/redux/reducerTester.test.ts index 63d163647fc..6f7c4d60043 100644 --- a/public/test/core/redux/reducerTester.test.ts +++ b/public/test/core/redux/reducerTester.test.ts @@ -1,4 +1,6 @@ -import { reducerFactory, actionCreatorFactory } from 'app/core/redux'; +import { AnyAction } from 'redux'; +import { createAction } from '@reduxjs/toolkit'; + import { reducerTester } from './reducerTester'; interface DummyState { @@ -9,29 +11,27 @@ const initialState: DummyState = { data: [], }; -const dummyAction = actionCreatorFactory('dummyAction').create(); +const dummyAction = createAction('dummyAction'); -const mutatingReducer = reducerFactory(initialState) - .addMapper({ - filter: dummyAction, - mapper: (state, action) => { - state.data.push(action.payload); - return state; - }, - }) - .create(); +const mutatingReducer = (state: DummyState = initialState, action: AnyAction): DummyState => { + if (dummyAction.match(action)) { + state.data.push(action.payload); + return state; + } -const okReducer = reducerFactory(initialState) - .addMapper({ - filter: dummyAction, - mapper: (state, action) => { - return { - ...state, - data: state.data.concat(action.payload), - }; - }, - }) - .create(); + return state; +}; + +const okReducer = (state: DummyState = initialState, action: AnyAction): DummyState => { + if (dummyAction.match(action)) { + return { + ...state, + data: state.data.concat(action.payload), + }; + } + + return state; +}; describe('reducerTester', () => { describe('when reducer mutates state', () => { diff --git a/public/test/core/redux/reducerTester.ts b/public/test/core/redux/reducerTester.ts index 32ce4e84fc6..0fd45d8d56a 100644 --- a/public/test/core/redux/reducerTester.ts +++ b/public/test/core/redux/reducerTester.ts @@ -1,13 +1,12 @@ import { Reducer } from 'redux'; - -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; +import { PayloadAction } from '@reduxjs/toolkit'; export interface Given { - givenReducer: (reducer: Reducer>, state: State, disableDeepFreeze?: boolean) => When; + givenReducer: (reducer: Reducer>, state: State, disableDeepFreeze?: boolean) => When; } export interface When { - whenActionIsDispatched: (action: ActionOf) => Then; + whenActionIsDispatched: (action: PayloadAction) => Then; } export interface Then { @@ -50,12 +49,12 @@ export const deepFreeze = (obj: T): T => { interface ReducerTester extends Given, When, Then {} export const reducerTester = (): Given => { - let reducerUnderTest: Reducer>; + let reducerUnderTest: Reducer>; let resultingState: State; let initialState: State; const givenReducer = ( - reducer: Reducer>, + reducer: Reducer>, state: State, disableDeepFreeze = false ): When => { @@ -68,7 +67,7 @@ export const reducerTester = (): Given => { return instance; }; - const whenActionIsDispatched = (action: ActionOf): Then => { + const whenActionIsDispatched = (action: PayloadAction): Then => { resultingState = reducerUnderTest(resultingState || initialState, action); return instance; diff --git a/public/test/core/thunk/thunkTester.ts b/public/test/core/thunk/thunkTester.ts index 8b199e7ab81..944d44fd129 100644 --- a/public/test/core/thunk/thunkTester.ts +++ b/public/test/core/thunk/thunkTester.ts @@ -1,7 +1,7 @@ // @ts-ignore import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; +import { PayloadAction } from '@reduxjs/toolkit'; const mockStore = configureMockStore([thunk]); @@ -10,13 +10,13 @@ export interface ThunkGiven { } export interface ThunkWhen { - whenThunkIsDispatched: (...args: any) => Promise>>; + whenThunkIsDispatched: (...args: any) => Promise>>; } export const thunkTester = (initialState: any, debug?: boolean): ThunkGiven => { const store = mockStore(initialState); let thunkUnderTest: any = null; - let dispatchedActions: Array> = []; + let dispatchedActions: Array> = []; const givenThunk = (thunkFunction: any): ThunkWhen => { thunkUnderTest = thunkFunction; @@ -24,7 +24,7 @@ export const thunkTester = (initialState: any, debug?: boolean): ThunkGiven => { return instance; }; - const whenThunkIsDispatched = async (...args: any): Promise>> => { + const whenThunkIsDispatched = async (...args: any): Promise>> => { await store.dispatch(thunkUnderTest(...args)); dispatchedActions = store.getActions(); diff --git a/yarn.lock b/yarn.lock index fe250f6ec2f..eef00df5914 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2914,6 +2914,18 @@ react-lifecycles-compat "^3.0.4" warning "^3.0.0" +"@reduxjs/toolkit@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.2.1.tgz#2a68b608ff377a5409272c0a0ea08d87047007c1" + integrity sha512-mVvcjAyPS91wbAG2JoLIVP0JiWoLYHCA5WYevjHBjxhTlBPa3lYbUS9/IqanGPIt9TmSYON42l3BTPfWsfr2wg== + dependencies: + immer "^4.0.1" + redux "^4.0.0" + redux-devtools-extension "^2.13.8" + redux-immutable-state-invariant "^2.1.0" + redux-thunk "^2.3.0" + reselect "^4.0.0" + "@rtsao/plugin-proposal-class-properties@7.0.1-patch.1": version "7.0.1-patch.1" resolved "https://registry.yarnpkg.com/@rtsao/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.0.1-patch.1.tgz#ac0f758a25a85b5be0e70a25f6e5b58103c58391" @@ -11579,6 +11591,11 @@ immer@1.10.0: resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== +immer@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/immer/-/immer-4.0.2.tgz#9ff0fcdf88e06f92618a5978ceecb5884e633559" + integrity sha512-Q/tm+yKqnKy4RIBmmtISBlhXuSDrB69e9EKTYiIenIKQkXBQir43w+kN/eGiax3wt1J0O1b2fYcNqLSbEcXA7w== + immutable@3.8.2, immutable@^3.8.2: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" @@ -11786,7 +11803,7 @@ interpret@^1.0.0, interpret@^1.1.0, interpret@^1.2.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== -invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: +invariant@^2.1.0, invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -18318,6 +18335,19 @@ reduce-flatten@^1.0.1: resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327" integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc= +redux-devtools-extension@^2.13.8: + version "2.13.8" + resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1" + integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg== + +redux-immutable-state-invariant@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/redux-immutable-state-invariant/-/redux-immutable-state-invariant-2.1.0.tgz#308fd3cc7415a0e7f11f51ec997b6379c7055ce1" + integrity sha512-3czbDKs35FwiBRsx/3KabUk5zSOoTXC+cgVofGkpBNv3jQcqIe5JrHcF5AmVt7B/4hyJ8MijBIpCJ8cife6yJg== + dependencies: + invariant "^2.1.0" + json-stringify-safe "^5.0.1" + redux-logger@3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" @@ -18332,7 +18362,7 @@ redux-mock-store@1.5.3: dependencies: lodash.isplainobject "^4.0.6" -redux-thunk@2.3.0: +redux-thunk@2.3.0, redux-thunk@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== @@ -18654,7 +18684,7 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= -reselect@*, reselect@4.0.0: +reselect@*, reselect@4.0.0, reselect@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==