Redux: Factor Invites out to separate slice (#45552)

This commit is contained in:
kay delaney 2022-02-21 11:37:49 +00:00 committed by GitHub
parent 0f29c66c1d
commit d134740bba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 183 additions and 93 deletions

View File

@ -275,6 +275,9 @@ exports[`no enzyme tests`] = {
"public/app/features/folders/FolderSettingsPage.test.tsx:1751147194": [
[2, 19, 13, "RegExp match", "2409514259"]
],
"public/app/features/invites/InviteesTable.test.tsx:3077684439": [
[1, 19, 13, "RegExp match", "2409514259"]
],
"public/app/features/org/OrgDetailsPage.test.tsx:2540662821": [
[1, 19, 13, "RegExp match", "2409514259"]
],
@ -302,13 +305,10 @@ exports[`no enzyme tests`] = {
"public/app/features/teams/TeamSettings.test.tsx:2628968507": [
[1, 19, 13, "RegExp match", "2409514259"]
],
"public/app/features/users/InviteesTable.test.tsx:2271264692": [
[1, 19, 13, "RegExp match", "2409514259"]
],
"public/app/features/users/UsersActionBar.test.tsx:4031641375": [
[1, 19, 13, "RegExp match", "2409514259"]
],
"public/app/features/users/UsersListPage.test.tsx:2523261097": [
"public/app/features/users/UsersListPage.test.tsx:2626906707": [
[1, 19, 13, "RegExp match", "2409514259"]
],
"public/app/features/users/UsersTable.test.tsx:3051231816": [

View File

@ -10,6 +10,7 @@ import exploreReducers from 'app/features/explore/state/main';
import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
import invitesReducers from 'app/features/invites/state/reducers';
import userReducers from 'app/features/profile/state/reducers';
import organizationReducers from 'app/features/org/state/reducers';
import ldapReducers from 'app/features/admin/state/reducers';
@ -31,6 +32,7 @@ const rootReducers = {
...usersReducers,
...serviceAccountsReducer,
...userReducers,
...invitesReducers,
...organizationReducers,
...ldapReducers,
...importDashboardReducers,

View File

@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import InviteesTable, { Props } from './InviteesTable';
import { Invitee } from 'app/types';
import { getMockInvitees } from './__mocks__/userMocks';
import { getMockInvitees } from '../users/__mocks__/userMocks';
const setup = (propOverrides?: object) => {
const props: Props = {

View File

@ -0,0 +1,23 @@
import { getBackendSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { FormModel } from 'app/features/org/UserInviteForm';
import { AccessControlAction, createAsyncThunk, Invitee } from 'app/types';
export const fetchInvitees = createAsyncThunk('users/fetchInvitees', async () => {
if (!contextSrv.hasPermission(AccessControlAction.UsersCreate)) {
return [];
}
const invitees: Invitee[] = await getBackendSrv().get('/api/org/invites');
return invitees;
});
export const addInvitee = createAsyncThunk('users/addInvitee', async (addInviteForm: FormModel, { dispatch }) => {
await getBackendSrv().post(`/api/org/invites`, addInviteForm);
await dispatch(fetchInvitees());
});
export const revokeInvite = createAsyncThunk('users/revokeInvite', async (code: string) => {
await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
return code;
});

View File

@ -0,0 +1,44 @@
import { keyBy } from 'lodash';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { initialState, invitesReducer } from './reducers';
import { fetchInvitees, revokeInvite } from './actions';
import { getMockInvitees } from 'app/features/users/__mocks__/userMocks';
describe('inviteesReducer', () => {
describe('when fetchInvitees is dispatched', () => {
it('then state should be correct', () => {
const invitees = getMockInvitees(1);
reducerTester<typeof initialState>()
.givenReducer(invitesReducer, { ...initialState })
.whenActionIsDispatched(fetchInvitees.fulfilled(invitees, ''))
.thenStateShouldEqual({
entities: keyBy(invitees, 'code'),
ids: invitees.map((i) => i.code),
status: 'succeeded',
});
});
});
describe('when revokeInvite is dispatched', () => {
it('then state should be correct', () => {
const invitees = getMockInvitees(1);
const fakeInitialState: typeof initialState = {
entities: keyBy(invitees, 'code'),
ids: invitees.map((i) => i.code),
status: 'succeeded',
};
reducerTester<typeof initialState>()
.givenReducer(invitesReducer, fakeInitialState)
.whenActionIsDispatched(revokeInvite.fulfilled(invitees[0].code, '', ''))
.thenStateShouldEqual({
entities: {
[invitees[1].code]: invitees[1],
},
ids: [invitees[1].code],
status: 'succeeded',
});
});
});
});

View File

@ -0,0 +1,38 @@
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { Invitee } from 'app/types';
import { fetchInvitees, revokeInvite } from './actions';
export type Status = 'idle' | 'loading' | 'succeeded' | 'failed';
const invitesAdapter = createEntityAdapter({ selectId: (invite: Invitee) => invite.code });
export const selectors = invitesAdapter.getSelectors();
export const initialState = invitesAdapter.getInitialState<{ status: Status }>({ status: 'idle' });
const invitesSlice = createSlice({
name: 'invites',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchInvitees.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchInvitees.fulfilled, (state, { payload: invites }) => {
invitesAdapter.setAll(state, invites);
state.status = 'succeeded';
})
.addCase(fetchInvitees.rejected, (state) => {
state.status = 'failed';
})
.addCase(revokeInvite.fulfilled, (state, { payload: inviteCode }) => {
invitesAdapter.removeOne(state, inviteCode);
state.status = 'succeeded';
});
},
});
export const invitesReducer = invitesSlice.reducer;
export default {
invites: invitesReducer,
};

View File

@ -0,0 +1,11 @@
import { createSelector } from '@reduxjs/toolkit';
import { selectors } from './reducers';
export const { selectAll, selectById, selectTotal } = selectors;
const selectQuery = (_: any, query: string) => query;
export const selectInvitesMatchingQuery = createSelector([selectAll, selectQuery], (invites, searchQuery) => {
const regex = new RegExp(searchQuery, 'i');
const matches = invites.filter((invite) => regex.test(invite.name) || regex.test(invite.email));
return matches;
});

View File

@ -10,11 +10,11 @@ import {
Field,
InputControl,
} from '@grafana/ui';
import { getConfig } from 'app/core/config';
import { OrgRole } from 'app/types';
import { locationService } from '@grafana/runtime';
import { locationUtil } from '@grafana/data';
import { userInviteSubmit } from './api';
import { getConfig } from 'app/core/config';
import { OrgRole, useDispatch } from 'app/types';
import { addInvitee } from '../invites/state/actions';
const roles = [
{ label: 'Viewer', value: OrgRole.Viewer },
@ -22,11 +22,6 @@ const roles = [
{ label: 'Admin', value: OrgRole.Admin },
];
const onSubmit = async (formData: FormModel) => {
await userInviteSubmit(formData);
locationService.push('/org/users/');
};
export interface FormModel {
role: OrgRole;
name: string;
@ -35,12 +30,19 @@ export interface FormModel {
email: string;
}
const defaultValues: FormModel = {
name: '',
email: '',
role: OrgRole.Editor,
sendEmail: true,
};
export const UserInviteForm = () => {
const defaultValues: FormModel = {
name: '',
email: '',
role: OrgRole.Editor,
sendEmail: true,
const dispatch = useDispatch();
const onSubmit = async (formData: FormModel) => {
await dispatch(addInvitee(formData)).unwrap();
locationService.push('/org/users/');
};
return (

View File

@ -1,12 +0,0 @@
import { getBackendSrv } from '@grafana/runtime';
import { appEvents } from 'app/core/core';
import { FormModel } from './UserInviteForm';
import { AppEvents } from '@grafana/data';
export const userInviteSubmit = async (formData: FormModel) => {
try {
await getBackendSrv().post('/api/org/invites', formData);
} catch (err) {
appEvents.emit(AppEvents.alertError, ['Failed to send invitation.', err.message]);
}
};

View File

@ -1,7 +1,8 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { setUsersSearchQuery } from './state/reducers';
import { getInviteesCount, getUsersSearchQuery } from './state/selectors';
import { selectTotal } from '../invites/state/selectors';
import { getUsersSearchQuery } from './state/selectors';
import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
@ -63,7 +64,7 @@ export class UsersActionBar extends PureComponent<Props> {
function mapStateToProps(state: any) {
return {
searchQuery: getUsersSearchQuery(state.users),
pendingInvitesCount: getInviteesCount(state.users),
pendingInvitesCount: selectTotal(state.invites),
externalUserMngLinkName: state.users.externalUserMngLinkName,
externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
canInvite: state.users.canInvite,

View File

@ -26,7 +26,7 @@ const setup = (propOverrides?: object) => {
searchQuery: '',
searchPage: 1,
externalUserMngInfo: '',
loadInvitees: jest.fn(),
fetchInvitees: jest.fn(),
loadUsers: jest.fn(),
updateUser: jest.fn(),
removeUser: jest.fn(),

View File

@ -6,20 +6,23 @@ import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import UsersActionBar from './UsersActionBar';
import UsersTable from './UsersTable';
import InviteesTable from './InviteesTable';
import InviteesTable from '../invites/InviteesTable';
import { OrgUser, OrgRole, StoreState } from 'app/types';
import { loadInvitees, loadUsers, removeUser, updateUser } from './state/actions';
import { loadUsers, removeUser, updateUser } from './state/actions';
import { fetchInvitees } from '../invites/state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getInvitees, getUsers, getUsersSearchQuery, getUsersSearchPage } from './state/selectors';
import { getUsers, getUsersSearchQuery, getUsersSearchPage } from './state/selectors';
import { setUsersSearchQuery, setUsersSearchPage } from './state/reducers';
import { selectInvitesMatchingQuery } from '../invites/state/selectors';
function mapStateToProps(state: StoreState) {
const searchQuery = getUsersSearchQuery(state.users);
return {
navModel: getNavModel(state.navIndex, 'users'),
users: getUsers(state.users),
searchQuery: getUsersSearchQuery(state.users),
searchPage: getUsersSearchPage(state.users),
invitees: getInvitees(state.users),
invitees: selectInvitesMatchingQuery(state.invites, searchQuery),
externalUserMngInfo: state.users.externalUserMngInfo,
hasFetched: state.users.hasFetched,
};
@ -27,7 +30,7 @@ function mapStateToProps(state: StoreState) {
const mapDispatchToProps = {
loadUsers,
loadInvitees,
fetchInvitees,
setUsersSearchQuery,
setUsersSearchPage,
updateUser,
@ -69,7 +72,7 @@ export class UsersListPage extends PureComponent<Props, State> {
}
async fetchInvitees() {
return await this.props.loadInvitees();
return await this.props.fetchInvitees();
}
onRoleChange = (role: OrgRole, user: OrgUser) => {

View File

@ -1,8 +1,7 @@
import { AccessControlAction, ThunkResult } from '../../../types';
import { ThunkResult } from '../../../types';
import { getBackendSrv } from '@grafana/runtime';
import { OrgUser } from 'app/types';
import { inviteesLoaded, usersLoaded } from './reducers';
import { contextSrv } from 'app/core/core';
import { usersLoaded } from './reducers';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
export function loadUsers(): ThunkResult<void> {
@ -12,17 +11,6 @@ export function loadUsers(): ThunkResult<void> {
};
}
export function loadInvitees(): ThunkResult<void> {
return async (dispatch) => {
if (!contextSrv.hasPermission(AccessControlAction.UsersCreate)) {
return;
}
const invitees = await getBackendSrv().get('/api/org/invites');
dispatch(inviteesLoaded(invitees));
};
}
export function updateUser(user: OrgUser): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role });
@ -36,10 +24,3 @@ export function removeUser(userId: number): ThunkResult<void> {
dispatch(loadUsers());
};
}
export function revokeInvite(code: string): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
dispatch(loadInvitees());
};
}

View File

@ -1,7 +1,7 @@
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';
import { initialState, setUsersSearchQuery, usersLoaded, usersReducer } from './reducers';
import { getMockUsers } from '../__mocks__/userMocks';
describe('usersReducer', () => {
describe('when usersLoaded is dispatched', () => {
@ -17,19 +17,6 @@ describe('usersReducer', () => {
});
});
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>()

View File

@ -1,10 +1,9 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Invitee, OrgUser, UsersState } from 'app/types';
import { OrgUser, UsersState } from 'app/types';
import config from 'app/core/config';
export const initialState: UsersState = {
invitees: [] as Invitee[],
users: [] as OrgUser[],
searchQuery: '',
searchPage: 1,
@ -22,9 +21,6 @@ const usersSlice = createSlice({
usersLoaded: (state, action: PayloadAction<OrgUser[]>): UsersState => {
return { ...state, hasFetched: true, users: action.payload };
},
inviteesLoaded: (state, action: PayloadAction<Invitee[]>): UsersState => {
return { ...state, hasFetched: true, invitees: action.payload };
},
setUsersSearchQuery: (state, action: PayloadAction<string>): UsersState => {
// reset searchPage otherwise search results won't appear
return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage };
@ -35,7 +31,7 @@ const usersSlice = createSlice({
},
});
export const { inviteesLoaded, setUsersSearchQuery, setUsersSearchPage, usersLoaded } = usersSlice.actions;
export const { setUsersSearchQuery, setUsersSearchPage, usersLoaded } = usersSlice.actions;
export const usersReducer = usersSlice.reducer;

View File

@ -8,14 +8,5 @@ export const getUsers = (state: UsersState) => {
});
};
export const getInvitees = (state: UsersState) => {
const regex = new RegExp(state.searchQuery, 'i');
return state.invitees.filter((invitee) => {
return regex.test(invitee.name) || regex.test(invitee.email);
});
};
export const getInviteesCount = (state: UsersState) => state.invitees.length;
export const getUsersSearchQuery = (state: UsersState) => state.searchQuery;
export const getUsersSearchPage = (state: UsersState) => state.searchPage;

View File

@ -323,7 +323,7 @@ export function getAppRoutes(): RouteDescriptor[] {
{
path: '/invite/:code',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "SignupInvited" */ 'app/features/users/SignupInvited')
() => import(/* webpackChunkName: "SignupInvited" */ 'app/features/invites/SignupInvited')
),
pageClass: 'sidemenu-hidden',
},

View File

@ -1,6 +1,19 @@
import { ThunkAction, ThunkDispatch as GenericThunkDispatch } from 'redux-thunk';
import { Action, PayloadAction } from '@reduxjs/toolkit';
import {
useSelector as useSelectorUntyped,
TypedUseSelectorHook,
useDispatch as useDispatchUntyped,
} from 'react-redux';
import {
Action,
AsyncThunk,
AsyncThunkOptions,
AsyncThunkPayloadCreator,
createAsyncThunk as createAsyncThunkUntyped,
PayloadAction,
} from '@reduxjs/toolkit';
import type { createRootReducer } from 'app/core/reducers/root';
import { configureStore } from 'app/store/configureStore';
export type StoreState = ReturnType<ReturnType<typeof createRootReducer>>;
@ -10,3 +23,14 @@ export type StoreState = ReturnType<ReturnType<typeof createRootReducer>>;
export type ThunkResult<R> = ThunkAction<R, StoreState, undefined, PayloadAction<any>>;
export type ThunkDispatch = GenericThunkDispatch<StoreState, undefined, Action>;
export type AppDispatch = ReturnType<typeof configureStore>['dispatch'];
export const useDispatch = () => useDispatchUntyped<AppDispatch>();
export const useSelector: TypedUseSelectorHook<StoreState> = useSelectorUntyped;
type DefaultThunkApiConfig = { dispatch: AppDispatch; state: StoreState };
export const createAsyncThunk = <Returned, ThunkArg = void, ThunkApiConfig = DefaultThunkApiConfig>(
typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>,
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
): AsyncThunk<Returned, ThunkArg, ThunkApiConfig> => createAsyncThunkUntyped(typePrefix, payloadCreator, options);

View File

@ -64,7 +64,6 @@ export interface Invitee {
export interface UsersState {
users: OrgUser[];
invitees: Invitee[];
searchQuery: string;
searchPage: number;
canInvite: boolean;