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
97 changed files with 2392 additions and 2305 deletions

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,