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>
This commit is contained in:
Hugo Häggmark 2020-01-13 08:03:22 +01:00 committed by GitHub
parent b0515f46cc
commit 4f0fa776be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 2392 additions and 2305 deletions

View File

@ -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<string>('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<SomeAction>('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<string>('SOME_ACTION').create();
export const theAction = actionCreatorFactory<string>('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<string>('SOME_ACTION').create();
export const otherAction = actionCreatorFactory<string[]>('Other_ACTION').create();
export const exampleReducer = reducerFactory<ExampleReducerState>(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, Payload>(state: State, action: ActionOf<Payload>) => 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

View File

@ -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",

View File

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

View File

@ -1,3 +0,0 @@
import { actionCreatorFactory } from 'app/core/redux';
export const toggleLogActions = actionCreatorFactory('TOGGLE_LOG_ACTIONS').create();

View File

@ -1,10 +1,11 @@
import { createAction } from '@reduxjs/toolkit';
import { StoreState } from '../../types';
import { actionCreatorFactory } from '../redux';
export type StateSelector<T extends object> = (state: StoreState) => T;
export type StateSelector<T> = (state: StoreState) => T;
export interface CleanUp<T extends object> {
stateSelector: StateSelector<T>;
export interface CleanUp<T> {
stateSelector: (state: StoreState) => T;
}
export const cleanUpAction = actionCreatorFactory<CleanUp<{}>>('CORE_CLEAN_UP_STATE').create();
export const cleanUpAction = createAction<CleanUp<{}>>('core/cleanUpState');

View File

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

View File

@ -1,4 +0,0 @@
import { LocationUpdate } from '@grafana/runtime';
import { actionCreatorFactory } from 'app/core/redux';
export const updateLocation = actionCreatorFactory<LocationUpdate>('UPDATE_LOCATION').create();

View File

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

View File

@ -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<StoreState>) => (next: Dispatch) => (action: ActionOf<any>) => {
import { StoreState } from 'app/types/store';
import { toggleLogActions } from '../reducers/application';
export const toggleLogActionsMiddleware = (store: Store<StoreState>) => (next: Dispatch) => (action: AnyAction) => {
const isLogActionsAction = action.type === toggleLogActions.type;
if (isLogActionsAction) {
return next(action);

View File

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

View File

@ -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<AppNotification>): AppNotificationsState => ({
...state,
appNotifications: state.appNotifications.concat([action.payload]),
}),
clearAppNotification: (state, action: PayloadAction<number>): AppNotificationsState => ({
...state,
appNotifications: state.appNotifications.filter(appNotification => appNotification.id !== action.payload),
}),
},
});
export const { notifyApp, clearAppNotification } = appNotificationsSlice.actions;
export const appNotificationsReducer = appNotificationsSlice.reducer;

View File

@ -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<ApplicationState>()
.givenReducer(applicationReducer, { logActions: false })
.whenActionIsDispatched(toggleLogActions())
.thenStateShouldEqual({ logActions: true })
.whenActionIsDispatched(toggleLogActions())
.thenStateShouldEqual({ logActions: false });
});
});
});

View File

@ -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<ApplicationState>(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;

View File

@ -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<LocationState>()
.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<LocationState>()
.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<LocationState>()
.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<LocationState>()
.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<LocationState>()
.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<LocationState>()
.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;
});
});
});
});

View File

@ -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<LocationState>(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<LocationUpdate>('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<unknown>) => {
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;
};

View File

@ -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',
},
],
},
},
});
});
});
});

View File

@ -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<NavModelItem>('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;
};

View File

@ -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<StoreState>()
.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<StoreState>()
.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;

View File

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

View File

@ -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>('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<Dummy>('nOpAyLoAd').create();
}).toThrow();
});
});
});

View File

@ -1,64 +0,0 @@
import { Action } from 'redux';
const allActionCreators = new Set<string>();
export interface ActionOf<Payload> extends Action {
readonly type: string;
readonly payload: Payload;
}
export interface ActionCreator<Payload> {
readonly type: string;
(payload: Payload): ActionOf<Payload>;
}
export interface NoPayloadActionCreator {
readonly type: string;
(): ActionOf<undefined>;
}
export interface ActionCreatorFactory<Payload> {
create: () => ActionCreator<Payload>;
}
export interface NoPayloadActionCreatorFactory {
create: () => NoPayloadActionCreator;
}
export function actionCreatorFactory<Payload extends undefined>(type: string): NoPayloadActionCreatorFactory;
export function actionCreatorFactory<Payload>(type: string): ActionCreatorFactory<Payload>;
export function actionCreatorFactory<Payload>(type: string): ActionCreatorFactory<Payload> {
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<Payload> => {
return Object.assign((payload: Payload): ActionOf<Payload> => ({ type, payload }), { type });
};
return { create };
}
export interface NoPayloadActionCreatorMock extends NoPayloadActionCreator {
calls: number;
}
export const getNoPayloadActionCreatorMock = (creator: NoPayloadActionCreator): NoPayloadActionCreatorMock => {
const mock: NoPayloadActionCreatorMock = Object.assign(
(): ActionOf<undefined> => {
mock.calls++;
return { type: creator.type, payload: undefined };
},
{ type: creator.type, calls: 0 }
);
return mock;
};
export const mockActionCreator = (creator: ActionCreator<any>) => {
return Object.assign(jest.fn(), creator);
};
// Should only be used by tests
export const resetAllActionCreatorTypes = () => allActionCreators.clear();

View File

@ -1,2 +0,0 @@
export * from './actionCreatorFactory';
export * from './reducerFactory';

View File

@ -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<DummyReducerState>('dummy').create();
const dummyReducer = reducerFactory(dummyReducerIntialState)
.addMapper({
filter: dummyActionCreator,
mapper: (state, action) => ({ ...state, ...action.payload }),
})
.create();
describe('reducerFactory', () => {
describe('given it is created with a defined handler', () => {
describe('when reducer is called with no state', () => {
describe('and with an action that the handler can not handle', () => {
it('then the resulting state should be intial state', () => {
const result = dummyReducer(undefined as DummyReducerState, {} as ActionOf<any>);
expect(result).toEqual(dummyReducerIntialState);
});
});
describe('and with an action that the handler can handle', () => {
it('then the resulting state should correct', () => {
const payload = { n: 10, s: 'ten', b: false, o: { n: 20, s: 'twenty', b: true } };
const result = dummyReducer(undefined as DummyReducerState, dummyActionCreator(payload));
expect(result).toEqual(payload);
});
});
});
describe('when reducer is called with a state', () => {
describe('and with an action that the handler can not handle', () => {
it('then the resulting state should be intial state', () => {
const result = dummyReducer(dummyReducerIntialState, {} as ActionOf<any>);
expect(result).toEqual(dummyReducerIntialState);
});
});
describe('and with an action that the handler can handle', () => {
it('then the resulting state should correct', () => {
const payload = { n: 10, s: 'ten', b: false, o: { n: 20, s: 'twenty', b: true } };
const result = dummyReducer(dummyReducerIntialState, dummyActionCreator(payload));
expect(result).toEqual(payload);
});
});
});
});
describe('given a handler is added', () => {
describe('when a handler with the same creator is added', () => {
it('then is should throw', () => {
const faultyReducer = reducerFactory(dummyReducerIntialState).addMapper({
filter: dummyActionCreator,
mapper: (state, action) => {
return { ...state, ...action.payload };
},
});
expect(() => {
faultyReducer.addMapper({
filter: dummyActionCreator,
mapper: state => {
return state;
},
});
}).toThrow();
});
});
});
});

View File

@ -1,45 +0,0 @@
import { ActionOf, ActionCreator } from './actionCreatorFactory';
import { Reducer } from 'redux';
export type Mapper<State, Payload> = (state: State, action: ActionOf<Payload>) => State;
export interface MapperConfig<State, Payload> {
filter: ActionCreator<Payload>;
mapper: Mapper<State, Payload>;
}
export interface AddMapper<State> {
addMapper: <Payload>(config: MapperConfig<State, Payload>) => CreateReducer<State>;
}
export interface CreateReducer<State> extends AddMapper<State> {
create: () => Reducer<State, ActionOf<any>>;
}
export const reducerFactory = <State>(initialState: State): AddMapper<State> => {
const allMappers: { [key: string]: Mapper<State, any> } = {};
const addMapper = <Payload>(config: MapperConfig<State, Payload>): CreateReducer<State> => {
if (allMappers[config.filter.type]) {
throw new Error(`There is already a mapper defined with the type ${config.filter.type}`);
}
allMappers[config.filter.type] = config.mapper;
return instance;
};
const create = (): Reducer<State, ActionOf<any>> => (state: State = initialState, action: ActionOf<any>): State => {
const mapper = allMappers[action.type];
if (mapper) {
return mapper(state, action);
}
return state;
};
const instance: CreateReducer<State> = { addMapper, create };
return instance;
};

View File

@ -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<LdapConnectionInfo>(
'ldap/CONNECTION_INFO_LOADED'
).create();
export const ldapSyncStatusLoadedAction = actionCreatorFactory<SyncInfo>('ldap/SYNC_STATUS_LOADED').create();
export const userMappingInfoLoadedAction = actionCreatorFactory<LdapUser>('ldap/USER_INFO_LOADED').create();
export const userMappingInfoFailedAction = actionCreatorFactory<LdapError>('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<LdapError>('ldap/LDAP_FAILED').create();
export const userLoadedAction = actionCreatorFactory<User>('USER_LOADED').create();
export const userSessionsLoadedAction = actionCreatorFactory<UserSession[]>('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

View File

@ -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<LdapState, ActionOf<any>>, initalState)
reducerTester<LdapState>()
.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<LdapState, ActionOf<any>>, initalState)
reducerTester<LdapState>()
.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<LdapState, ActionOf<any>>, initalState)
reducerTester<LdapState>()
.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<LdapState, ActionOf<any>>, initalState)
reducerTester<LdapState>()
.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<LdapState, ActionOf<any>>, initalState)
reducerTester<LdapState>()
.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<LdapState>()
.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<LdapUserState, ActionOf<any>>, initalState)
reducerTester<LdapUserState>()
.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<LdapUserState>()
.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<LdapUserState>()
.givenReducer(ldapUserReducer, {
...makeInitialLdapUserState(),
})
.whenActionIsDispatched(userMappingInfoLoadedAction(getTestUserMapping()))
.thenStateShouldEqual({
...makeInitialLdapUserState(),
ldapUser: getTestUserMapping(),
});
});
});
describe('when userMappingInfoFailedAction is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LdapUserState>()
.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<LdapUserState>()
.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<LdapUserState>()
.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',
},
});
});
});
});

View File

@ -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<LdapConnectionInfo>): LdapState => ({
...state,
ldapError: null,
connectionInfo: action.payload,
}),
})
.addMapper({
filter: ldapFailedAction,
mapper: (state, action) => ({
ldapFailedAction: (state, action: PayloadAction<LdapError>): LdapState => ({
...state,
ldapError: action.payload,
}),
})
.addMapper({
filter: ldapSyncStatusLoadedAction,
mapper: (state, action) => ({
ldapSyncStatusLoadedAction: (state, action: PayloadAction<SyncInfo>): LdapState => ({
...state,
syncInfo: action.payload,
}),
})
.addMapper({
filter: userMappingInfoLoadedAction,
mapper: (state, action) => ({
userMappingInfoLoadedAction: (state, action: PayloadAction<LdapUser>): LdapState => ({
...state,
user: action.payload,
userError: null,
}),
})
.addMapper({
filter: userMappingInfoFailedAction,
mapper: (state, action) => ({
userMappingInfoFailedAction: (state, action: PayloadAction<LdapError>): LdapState => ({
...state,
user: null,
userError: action.payload,
}),
})
.addMapper({
filter: clearUserMappingInfoAction,
mapper: (state, action) => ({
clearUserMappingInfoAction: (state, action: PayloadAction<undefined>): LdapState => ({
...state,
user: null,
}),
})
.addMapper({
filter: clearUserErrorAction,
mapper: state => ({
clearUserErrorAction: (state, action: PayloadAction<undefined>): 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<User>): LdapUserState => ({
...state,
user: action.payload,
userError: null,
}),
})
.addMapper({
filter: userSessionsLoadedAction,
mapper: (state, action) => ({
userSessionsLoadedAction: (state, action: PayloadAction<UserSession[]>): LdapUserState => ({
...state,
sessions: action.payload,
}),
})
.create();
userSyncFailedAction: (state, action: PayloadAction<undefined>): 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,

View File

@ -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: '',

View File

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

View File

@ -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<R> = ThunkAction<R, StoreState, undefined, Action>;
import { AlertRuleDTO, ThunkResult } from 'app/types';
import { loadAlertRules, loadedAlertRules } from './reducers';
export function getAlertRulesAsync(options: { state: string }): ThunkResult<void> {
return async dispatch => {

View File

@ -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<AlertRulesState>()
.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<AlertRulesState>()
.givenReducer(alertRulesReducer, { ...initialState })
.whenActionIsDispatched(setSearchQuery('query'))
.thenStateShouldEqual({ ...initialState, searchQuery: 'query' });
});
});
describe('when loadedAlertRules is dispatched', () => {
it('then state should be correct', () => {
reducerTester<AlertRulesState>()
.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',
},
],
});
});
});
});

View File

@ -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<AlertRuleDTO[]>): 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<string>): AlertRulesState => {
return { ...state, searchQuery: action.payload };
}
},
},
});
return state;
};
export const { loadAlertRules, loadedAlertRules, setSearchQuery } = alertRulesSlice.actions;
export const alertRulesReducer = alertRulesSlice.reducer;
export default {
alertRules: alertRulesReducer,

View File

@ -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,

View File

@ -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]: [

View File

@ -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<R> = ThunkAction<R, StoreState, undefined, Action>;
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<v
.then(dispatch(loadApiKeys(includeExpired)));
};
}
export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({
type: ActionTypes.SetApiKeysSearchQuery,
payload: searchQuery,
});

View File

@ -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<ApiKeysState>()
.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<ApiKeysState>()
.givenReducer(apiKeysReducer, { ...initialApiKeysState })
.whenActionIsDispatched(setSearchQuery('test query'))
.thenStateShouldEqual({
...initialApiKeysState,
searchQuery: 'test query',
});
});
});

View File

@ -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,

View File

@ -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<Props, State, DashboardPage>;
@ -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);
});
});

View File

@ -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 {

View File

@ -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', () => {

View File

@ -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<PanelEditorInitCompleted>(
'PANEL_EDITOR_INIT_COMPLETED'
).create();
export const panelEditorCleanUp = actionCreatorFactory('PANEL_EDITOR_CLEAN_UP').create();
export const refreshPanelEditor = (props: {
hasQueriesTab?: boolean;
usesGraphPlugin?: boolean;

View File

@ -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', () => {

View File

@ -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<PanelEditorState>(initialState)
.addMapper({
filter: panelEditorInitCompleted,
mapper: (state, action): PanelEditorState => {
const panelEditorSlice = createSlice({
name: 'panelEditor',
initialState,
reducers: {
panelEditorInitCompleted: (state, action: PayloadAction<PanelEditorInitCompleted>): PanelEditorState => {
const { activeTab, tabs } = action.payload;
return {
...state,
@ -48,9 +53,10 @@ export const panelEditorReducer = reducerFactory<PanelEditorState>(initialState)
tabs,
};
},
})
.addMapper({
filter: panelEditorCleanUp,
mapper: (): PanelEditorState => initialState,
})
.create();
panelEditorCleanUp: (state, action: PayloadAction<undefined>): PanelEditorState => initialState,
},
});
export const { panelEditorCleanUp, panelEditorInitCompleted } = panelEditorSlice.actions;
export const panelEditorReducer = panelEditorSlice.reducer;

View File

@ -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<DashboardAclDTO[]>('LOAD_DASHBOARD_PERMISSIONS').create();
export const loadDashboardPermissions = createAction<DashboardAclDTO[]>('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<MutableDashboard>('DASHBOARD_INIT_COMLETED').create();
export const dashboardInitCompleted = createAction<MutableDashboard>('dashboard/dashboardInitCompleted');
/*
* Unrecoverable init failure (fetch or model creation failed)
*/
export const dashboardInitFailed = actionCreatorFactory<DashboardInitError>('DASHBOARD_INIT_FAILED').create();
export const dashboardInitFailed = createAction<DashboardInitError>('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<void> {
return async dispatch => {

View File

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

View File

@ -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<unknown>): 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,

View File

@ -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 = {

View File

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

View File

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

View File

@ -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<any>);

View File

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

View File

@ -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<DataSourceSettings>('LOAD_DATA_SOURCE').create();
export const dataSourcesLoaded = actionCreatorFactory<DataSourceSettings[]>('LOAD_DATA_SOURCES').create();
export const dataSourceMetaLoaded = actionCreatorFactory<DataSourcePluginMeta>('LOAD_DATA_SOURCE_META').create();
export const dataSourcePluginsLoad = actionCreatorFactory('LOAD_DATA_SOURCE_PLUGINS').create();
export const dataSourcePluginsLoaded = actionCreatorFactory<DataSourceTypesLoadedPayload>(
'LOADED_DATA_SOURCE_PLUGINS'
).create();
export const setDataSourcesSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCES_SEARCH_QUERY').create();
export const setDataSourcesLayoutMode = actionCreatorFactory<LayoutMode>('SET_DATA_SOURCES_LAYOUT_MODE').create();
export const setDataSourceTypeSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCE_TYPE_SEARCH_QUERY').create();
export const setDataSourceName = actionCreatorFactory<string>('SET_DATA_SOURCE_NAME').create();
export const setIsDefault = actionCreatorFactory<boolean>('SET_IS_DEFAULT').create();
export interface DataSourceTypesLoadedPayload {
plugins: DataSourcePluginMeta[];
categories: DataSourcePluginCategory[];

View File

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

View File

@ -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<DataSourceSettings>('dataSources/dataSourceLoaded');
export const dataSourcesLoaded = createAction<DataSourceSettings[]>('dataSources/dataSourcesLoaded');
export const dataSourceMetaLoaded = createAction<DataSourcePluginMeta>('dataSources/dataSourceMetaLoaded');
export const dataSourcePluginsLoad = createAction('dataSources/dataSourcePluginsLoad');
export const dataSourcePluginsLoaded = createAction<DataSourceTypesLoadedPayload>(
'dataSources/dataSourcePluginsLoaded'
);
export const setDataSourcesSearchQuery = createAction<string>('dataSources/setDataSourcesSearchQuery');
export const setDataSourcesLayoutMode = createAction<LayoutMode>('dataSources/setDataSourcesLayoutMode');
export const setDataSourceTypeSearchQuery = createAction<string>('dataSources/setDataSourceTypeSearchQuery');
export const setDataSourceName = createAction<string>('dataSources/setDataSourceName');
export const setIsDefault = createAction<boolean>('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,

View File

@ -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<AddQueryRowPayload>('explore/ADD_QUERY_ROW').create();
export const addQueryRowAction = createAction<AddQueryRowPayload>('explore/addQueryRow');
/**
* Change the mode of Explore.
*/
export const changeModeAction = actionCreatorFactory<ChangeModePayload>('explore/CHANGE_MODE').create();
export const changeModeAction = createAction<ChangeModePayload>('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<ChangeQueryPayload>('explore/CHANGE_QUERY').create();
export const changeQueryAction = createAction<ChangeQueryPayload>('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<ChangeSizePayload>('explore/CHANGE_SIZE').create();
export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeSize');
/**
* Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
*/
export const changeRefreshIntervalAction = actionCreatorFactory<ChangeRefreshIntervalPayload>(
'explore/CHANGE_REFRESH_INTERVAL'
).create();
export const changeRefreshIntervalAction = createAction<ChangeRefreshIntervalPayload>('explore/changeRefreshInterval');
/**
* Clear all queries and results.
*/
export const clearQueriesAction = actionCreatorFactory<ClearQueriesPayload>('explore/CLEAR_QUERIES').create();
export const clearQueriesAction = createAction<ClearQueriesPayload>('explore/clearQueries');
/**
* Clear origin panel id.
*/
export const clearOriginAction = actionCreatorFactory<ClearOriginPayload>('explore/CLEAR_ORIGIN').create();
export const clearOriginAction = createAction<ClearOriginPayload>('explore/clearOrigin');
/**
* Highlight expressions in the log results
*/
export const highlightLogsExpressionAction = actionCreatorFactory<HighlightLogsExpressionPayload>(
'explore/HIGHLIGHT_LOGS_EXPRESSION'
).create();
export const highlightLogsExpressionAction = createAction<HighlightLogsExpressionPayload>(
'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<InitializeExplorePayload>(
'explore/INITIALIZE_EXPLORE'
).create();
export const initializeExploreAction = createAction<InitializeExplorePayload>('explore/initializeExplore');
/**
* Display an error when no datasources have been configured
*/
export const loadDatasourceMissingAction = actionCreatorFactory<LoadDatasourceMissingPayload>(
'explore/LOAD_DATASOURCE_MISSING'
).create();
export const loadDatasourceMissingAction = createAction<LoadDatasourceMissingPayload>('explore/loadDatasourceMissing');
/**
* Start the async process of loading a datasource to display a loading indicator
*/
export const loadDatasourcePendingAction = actionCreatorFactory<LoadDatasourcePendingPayload>(
'explore/LOAD_DATASOURCE_PENDING'
).create();
export const loadDatasourcePendingAction = createAction<LoadDatasourcePendingPayload>('explore/loadDatasourcePending');
/**
* Datasource loading was completed.
*/
export const loadDatasourceReadyAction = actionCreatorFactory<LoadDatasourceReadyPayload>(
'explore/LOAD_DATASOURCE_READY'
).create();
export const loadDatasourceReadyAction = createAction<LoadDatasourceReadyPayload>('explore/loadDatasourceReady');
/**
* Action to modify a query given a datasource-specific modifier action.
@ -297,97 +260,86 @@ export const loadDatasourceReadyAction = actionCreatorFactory<LoadDatasourceRead
* @param index Optional query row index. If omitted, the modification is applied to all query rows.
* @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
*/
export const modifyQueriesAction = actionCreatorFactory<ModifyQueriesPayload>('explore/MODIFY_QUERIES').create();
export const modifyQueriesAction = createAction<ModifyQueriesPayload>('explore/modifyQueries');
export const queryStreamUpdatedAction = actionCreatorFactory<QueryEndedPayload>(
'explore/QUERY_STREAM_UPDATED'
).create();
export const queryStreamUpdatedAction = createAction<QueryEndedPayload>('explore/queryStreamUpdated');
export const queryStoreSubscriptionAction = actionCreatorFactory<QueryStoreSubscriptionPayload>(
'explore/QUERY_STORE_SUBSCRIPTION'
).create();
export const queryStoreSubscriptionAction = createAction<QueryStoreSubscriptionPayload>(
'explore/queryStoreSubscription'
);
/**
* Remove query row of the given index, as well as associated query results.
*/
export const removeQueryRowAction = actionCreatorFactory<RemoveQueryRowPayload>('explore/REMOVE_QUERY_ROW').create();
export const removeQueryRowAction = createAction<RemoveQueryRowPayload>('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<ScanStartPayload>('explore/SCAN_START').create();
export const scanStartAction = createAction<ScanStartPayload>('explore/scanStart');
/**
* Stop any scanning for more results.
*/
export const scanStopAction = actionCreatorFactory<ScanStopPayload>('explore/SCAN_STOP').create();
export const scanStopAction = createAction<ScanStopPayload>('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<SetQueriesPayload>('explore/SET_QUERIES').create();
export const setQueriesAction = createAction<SetQueriesPayload>('explore/setQueries');
/**
* Close the split view and save URL state.
*/
export const splitCloseAction = actionCreatorFactory<SplitCloseActionPayload>('explore/SPLIT_CLOSE').create();
export const splitCloseAction = createAction<SplitCloseActionPayload>('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<SplitOpenPayload>('explore/SPLIT_OPEN').create();
export const splitOpenAction = createAction<SplitOpenPayload>('explore/splitOpen');
export const syncTimesAction = actionCreatorFactory<SyncTimesPayload>('explore/SYNC_TIMES').create();
export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes');
/**
* Update state of Explores UI elements (panels visiblity and deduplication strategy)
*/
export const updateUIStateAction = actionCreatorFactory<UpdateUIStatePayload>('explore/UPDATE_UI_STATE').create();
export const updateUIStateAction = createAction<UpdateUIStatePayload>('explore/updateUIState');
/**
* Expand/collapse the table result viewer. When collapsed, table queries won't be run.
*/
export const toggleTableAction = actionCreatorFactory<ToggleTablePayload>('explore/TOGGLE_TABLE').create();
export const toggleTableAction = createAction<ToggleTablePayload>('explore/toggleTable');
/**
* Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
*/
export const toggleGraphAction = actionCreatorFactory<ToggleGraphPayload>('explore/TOGGLE_GRAPH').create();
export const toggleGraphAction = createAction<ToggleGraphPayload>('explore/toggleGraph');
/**
* Updates datasource instance before datasouce loading has started
*/
export const updateDatasourceInstanceAction = actionCreatorFactory<UpdateDatasourceInstancePayload>(
'explore/UPDATE_DATASOURCE_INSTANCE'
).create();
export const updateDatasourceInstanceAction = createAction<UpdateDatasourceInstancePayload>(
'explore/updateDatasourceInstance'
);
export const toggleLogLevelAction = actionCreatorFactory<ToggleLogLevelPayload>('explore/TOGGLE_LOG_LEVEL').create();
export const toggleLogLevelAction = createAction<ToggleLogLevelPayload>('explore/toggleLogLevel');
/**
* Resets state for explore.
*/
export const resetExploreAction = actionCreatorFactory<ResetExplorePayload>('explore/RESET_EXPLORE').create();
export const queriesImportedAction = actionCreatorFactory<QueriesImportedPayload>('explore/QueriesImported').create();
export const resetExploreAction = createAction<ResetExplorePayload>('explore/resetExplore');
export const queriesImportedAction = createAction<QueriesImportedPayload>('explore/queriesImported');
export const historyUpdatedAction = actionCreatorFactory<HistoryUpdatedPayload>('explore/HISTORY_UPDATED').create();
export const historyUpdatedAction = createAction<HistoryUpdatedPayload>('explore/historyUpdated');
export const setUrlReplacedAction = actionCreatorFactory<SetUrlReplacedPayload>('explore/SET_URL_REPLACED').create();
export const setUrlReplacedAction = createAction<SetUrlReplacedPayload>('explore/setUrlReplaced');
export const changeRangeAction = actionCreatorFactory<ChangeRangePayload>('explore/CHANGE_RANGE').create();
export const changeRangeAction = createAction<ChangeRangePayload>('explore/changeRange');
export const changeLoadingStateAction = actionCreatorFactory<ChangeLoadingStatePayload>(
'changeLoadingStateAction'
).create();
export const changeLoadingStateAction = createAction<ChangeLoadingStatePayload>('changeLoadingState');
export const setPausedStateAction = actionCreatorFactory<SetPausedStatePayload>('explore/SET_PAUSED_STATE').create();
export type HigherOrderAction =
| ActionOf<SplitCloseActionPayload>
| SplitOpenAction
| ResetExploreAction
| SyncTimesAction
| ActionOf<any>;
export const setPausedStateAction = createAction<SetPausedStatePayload>('explore/setPausedState');

View File

@ -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<InitializeExplorePayload>;
const initializeExplore = dispatchedActions[1] as PayloadAction<InitializeExplorePayload>;
const { type, payload } = initializeExplore;
expect(type).toEqual(initializeExploreAction.type);

View File

@ -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<ChangeSizePayload> {
): PayloadAction<ChangeSizePayload> {
return changeSizeAction({ exploreId, height, width });
}
@ -217,7 +217,7 @@ export const updateTimeRange = (options: {
export function changeRefreshInterval(
exploreId: ExploreId,
refreshInterval: string
): ActionOf<ChangeRefreshIntervalPayload> {
): PayloadAction<ChangeRefreshIntervalPayload> {
return changeRefreshIntervalAction({ exploreId, refreshInterval });
}
@ -299,7 +299,7 @@ export const loadDatasourceReady = (
exploreId: ExploreId,
instance: DataSourceApi,
orgId: number
): ActionOf<LoadDatasourceReadyPayload> => {
): PayloadAction<LoadDatasourceReadyPayload> => {
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<void> {
* queries won't be run
*/
const togglePanelActionCreator = (
actionCreator: ActionCreator<ToggleGraphPayload> | ActionCreator<ToggleTablePayload>
actionCreator: ActionCreatorWithPayload<ToggleGraphPayload> | ActionCreatorWithPayload<ToggleTablePayload>
) => (exploreId: ExploreId, isPanelVisible: boolean): ThunkResult<void> => {
return dispatch => {
let uiFragmentStateUpdate: Partial<ExploreUIState>;

View File

@ -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<ExploreItemState, ActionOf<any>>, 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<ExploreItemState, ActionOf<any>>, 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<ExploreItemState, ActionOf<any>>, {})
.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<ExploreItemState> = {
const initialState: Partial<ExploreItemState> = {
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<ExploreState, ActionOf<any>>, 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<ExploreState, ActionOf<any>>, 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<ExploreState, ActionOf<any>>, 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: {

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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<R> = ThunkAction<R, StoreState, undefined, any>;
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<void> {
return async dispatch => {

View File

@ -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<FolderState>()
.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<FolderState>()
.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<FolderState>()
.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<FolderState>()
.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',
},
],
});
});
});
});

View File

@ -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<FolderDTO>): FolderState => {
return {
...state,
...action.payload,
hasChanged: false,
};
case ActionTypes.SetFolderTitle:
},
setFolderTitle: (state, action: PayloadAction<string>): FolderState => {
return {
...state,
title: action.payload,
hasChanged: action.payload.trim().length > 0,
};
case ActionTypes.LoadFolderPermissions:
},
loadFolderPermissions: (state, action: PayloadAction<DashboardAclDTO[]>): 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,

View File

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

View File

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

View File

@ -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<any> {
return async dispatch => {

View File

@ -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<OrganizationState>()
.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<OrganizationState>()
.givenReducer(organizationReducer, { ...initialState, organization: { id: 1, name: 'An org' } })
.whenActionIsDispatched(setOrganizationName('New Name'))
.thenStateShouldEqual({
organization: { id: 1, name: 'New Name' },
});
});
});
});

View File

@ -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<Organization>): OrganizationState => {
return { ...state, organization: action.payload };
case ActionTypes.SetOrganizationName:
},
setOrganizationName: (state, action: PayloadAction<string>): 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,

View File

@ -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,

View File

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

View File

@ -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<R> = ThunkAction<R, StoreState, undefined, Action>;
import { ThunkResult } from 'app/types';
import { pluginDashboardsLoad, pluginDashboardsLoaded, pluginsLoaded } from './reducers';
export function loadPlugins(): ThunkResult<void> {
return async dispatch => {

View File

@ -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<PluginsState>()
.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<PluginsState>()
.givenReducer(pluginsReducer, { ...initialState })
.whenActionIsDispatched(setPluginsSearchQuery('A query'))
.thenStateShouldEqual({
...initialState,
searchQuery: 'A query',
});
});
});
describe('when setPluginsLayoutMode is dispatched', () => {
it('then state should be correct', () => {
reducerTester<PluginsState>()
.givenReducer(pluginsReducer, { ...initialState })
.whenActionIsDispatched(setPluginsLayoutMode(LayoutModes.List))
.thenStateShouldEqual({
...initialState,
layoutMode: LayoutModes.List,
});
});
});
describe('when pluginDashboardsLoad is dispatched', () => {
it('then state should be correct', () => {
reducerTester<PluginsState>()
.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<PluginsState>()
.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,
});
});
});
});

View File

@ -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<PluginMeta[]>): PluginsState => {
return { ...state, hasFetched: true, plugins: action.payload };
case ActionTypes.SetPluginsSearchQuery:
},
setPluginsSearchQuery: (state, action: PayloadAction<string>): PluginsState => {
return { ...state, searchQuery: action.payload };
case ActionTypes.SetLayoutMode:
},
setPluginsLayoutMode: (state, action: PayloadAction<LayoutMode>): PluginsState => {
return { ...state, layoutMode: action.payload };
case ActionTypes.LoadPluginDashboards:
},
pluginDashboardsLoad: (state, action: PayloadAction<undefined>): PluginsState => {
return { ...state, dashboards: [], isLoadingPluginDashboards: true };
case ActionTypes.LoadedPluginDashboards:
},
pluginDashboardsLoaded: (state, action: PayloadAction<PluginDashboard[]>): 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,

View File

@ -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(<CreateTeam {...props} />);

View File

@ -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,

View File

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

View File

@ -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,

View File

@ -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[];

View File

@ -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<R> = ThunkAction<R, StoreState, undefined, Action | UpdateNavIndexAction>;
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<void> {
return async dispatch => {

View File

@ -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<TeamsState>()
.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<TeamsState>()
.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<TeamState>()
.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<TeamState>()
.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<TeamState>()
.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<TeamState>()
.givenReducer(teamReducer, { ...initialTeamState })
.whenActionIsDispatched(teamGroupsLoaded(getMockTeamGroups(1)))
.thenStateShouldEqual({
...initialTeamState,
groups: getMockTeamGroups(1),
});
});
});
});

View File

@ -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<Team[]>): TeamsState => {
return { ...state, hasFetched: true, teams: action.payload };
},
setSearchQuery: (state, action: PayloadAction<string>): 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<Team>): TeamState => {
return { ...state, team: action.payload };
case ActionTypes.LoadTeamMembers:
},
teamMembersLoaded: (state, action: PayloadAction<TeamMember[]>): TeamState => {
return { ...state, members: action.payload };
case ActionTypes.SetSearchMemberQuery:
},
setSearchMemberQuery: (state, action: PayloadAction<string>): TeamState => {
return { ...state, searchMemberQuery: action.payload };
case ActionTypes.LoadTeamGroups:
},
teamGroupsLoaded: (state, action: PayloadAction<TeamGroup[]>): 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,

View File

@ -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,

View File

@ -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';

View File

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

View File

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

View File

@ -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<R> = ThunkAction<R, StoreState, undefined, Action>;
import { OrgUser } from 'app/types';
import { inviteesLoaded, usersLoaded } from './reducers';
export function loadUsers(): ThunkResult<void> {
return async dispatch => {

View File

@ -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<UsersState>()
.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<UsersState>()
.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<UsersState>()
.givenReducer(usersReducer, { ...initialState })
.whenActionIsDispatched(setUsersSearchQuery('a query'))
.thenStateShouldEqual({
...initialState,
searchQuery: 'a query',
});
});
});
});

View File

@ -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<OrgUser[]>): UsersState => {
return { ...state, hasFetched: true, users: action.payload };
case ActionTypes.LoadInvitees:
},
inviteesLoaded: (state, action: PayloadAction<Invitee[]>): UsersState => {
return { ...state, hasFetched: true, invitees: action.payload };
case ActionTypes.SetUsersSearchQuery:
},
setUsersSearchQuery: (state, action: PayloadAction<string>): UsersState => {
return { ...state, searchQuery: action.payload };
}
},
},
});
return state;
};
export const { inviteesLoaded, setUsersSearchQuery, usersLoaded } = usersSlice.actions;
export const usersReducer = usersSlice.reducer;
export default {
users: usersReducer,

View File

@ -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<StoreState, ActionOf<any>, 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;
}

View File

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

View File

@ -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<R> = ThunkAction<R, StoreState, undefined, ActionOf<any>>;
export type ThunkResult<R> = ThunkAction<R, StoreState, undefined, PayloadAction<any>>;
export type ThunkDispatch = GenericThunkDispatch<StoreState, undefined, any>;

View File

@ -0,0 +1,12 @@
import { ActionCreatorWithoutPayload, PayloadActionCreator } from '@reduxjs/toolkit';
export const mockToolkitActionCreator = (creator: PayloadActionCreator<any>) => {
return Object.assign(jest.fn(), creator);
};
export type ToolkitActionCreatorWithoutPayloadMockType = typeof mockToolkitActionCreatorWithoutPayload &
ActionCreatorWithoutPayload<any>;
export const mockToolkitActionCreatorWithoutPayload = (creator: ActionCreatorWithoutPayload<any>) => {
return Object.assign(jest.fn(), creator);
};

View File

@ -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<string>('dummyAction').create();
const dummyAction = createAction<string>('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', () => {

View File

@ -1,13 +1,12 @@
import { Reducer } from 'redux';
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
import { PayloadAction } from '@reduxjs/toolkit';
export interface Given<State> {
givenReducer: (reducer: Reducer<State, ActionOf<any>>, state: State, disableDeepFreeze?: boolean) => When<State>;
givenReducer: (reducer: Reducer<State, PayloadAction<any>>, state: State, disableDeepFreeze?: boolean) => When<State>;
}
export interface When<State> {
whenActionIsDispatched: (action: ActionOf<any>) => Then<State>;
whenActionIsDispatched: (action: PayloadAction<any>) => Then<State>;
}
export interface Then<State> {
@ -50,12 +49,12 @@ export const deepFreeze = <T>(obj: T): T => {
interface ReducerTester<State> extends Given<State>, When<State>, Then<State> {}
export const reducerTester = <State>(): Given<State> => {
let reducerUnderTest: Reducer<State, ActionOf<any>>;
let reducerUnderTest: Reducer<State, PayloadAction<any>>;
let resultingState: State;
let initialState: State;
const givenReducer = (
reducer: Reducer<State, ActionOf<any>>,
reducer: Reducer<State, PayloadAction<any>>,
state: State,
disableDeepFreeze = false
): When<State> => {
@ -68,7 +67,7 @@ export const reducerTester = <State>(): Given<State> => {
return instance;
};
const whenActionIsDispatched = (action: ActionOf<any>): Then<State> => {
const whenActionIsDispatched = (action: PayloadAction<any>): Then<State> => {
resultingState = reducerUnderTest(resultingState || initialState, action);
return instance;

View File

@ -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<Array<ActionOf<any>>>;
whenThunkIsDispatched: (...args: any) => Promise<Array<PayloadAction<any>>>;
}
export const thunkTester = (initialState: any, debug?: boolean): ThunkGiven => {
const store = mockStore(initialState);
let thunkUnderTest: any = null;
let dispatchedActions: Array<ActionOf<any>> = [];
let dispatchedActions: Array<PayloadAction<any>> = [];
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<Array<ActionOf<any>>> => {
const whenThunkIsDispatched = async (...args: any): Promise<Array<PayloadAction<any>>> => {
await store.dispatch(thunkUnderTest(...args));
dispatchedActions = store.getActions();

View File

@ -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==