diff --git a/.betterer.results b/.betterer.results index 367368dd38d..235ffb75cae 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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": [ diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index de3b901986f..fe0d22f773c 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -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, diff --git a/public/app/features/users/InviteeRow.tsx b/public/app/features/invites/InviteeRow.tsx similarity index 100% rename from public/app/features/users/InviteeRow.tsx rename to public/app/features/invites/InviteeRow.tsx diff --git a/public/app/features/users/InviteesTable.test.tsx b/public/app/features/invites/InviteesTable.test.tsx similarity index 91% rename from public/app/features/users/InviteesTable.test.tsx rename to public/app/features/invites/InviteesTable.test.tsx index ad464f81a2f..e776b52e714 100644 --- a/public/app/features/users/InviteesTable.test.tsx +++ b/public/app/features/invites/InviteesTable.test.tsx @@ -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 = { diff --git a/public/app/features/users/InviteesTable.tsx b/public/app/features/invites/InviteesTable.tsx similarity index 100% rename from public/app/features/users/InviteesTable.tsx rename to public/app/features/invites/InviteesTable.tsx diff --git a/public/app/features/users/SignupInvited.test.tsx b/public/app/features/invites/SignupInvited.test.tsx similarity index 100% rename from public/app/features/users/SignupInvited.test.tsx rename to public/app/features/invites/SignupInvited.test.tsx diff --git a/public/app/features/users/SignupInvited.tsx b/public/app/features/invites/SignupInvited.tsx similarity index 100% rename from public/app/features/users/SignupInvited.tsx rename to public/app/features/invites/SignupInvited.tsx diff --git a/public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap b/public/app/features/invites/__snapshots__/InviteesTable.test.tsx.snap similarity index 100% rename from public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap rename to public/app/features/invites/__snapshots__/InviteesTable.test.tsx.snap diff --git a/public/app/features/invites/state/actions.ts b/public/app/features/invites/state/actions.ts new file mode 100644 index 00000000000..0e7c891da25 --- /dev/null +++ b/public/app/features/invites/state/actions.ts @@ -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; +}); diff --git a/public/app/features/invites/state/reducers.test.ts b/public/app/features/invites/state/reducers.test.ts new file mode 100644 index 00000000000..e4563709220 --- /dev/null +++ b/public/app/features/invites/state/reducers.test.ts @@ -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() + .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() + .givenReducer(invitesReducer, fakeInitialState) + .whenActionIsDispatched(revokeInvite.fulfilled(invitees[0].code, '', '')) + .thenStateShouldEqual({ + entities: { + [invitees[1].code]: invitees[1], + }, + ids: [invitees[1].code], + status: 'succeeded', + }); + }); + }); +}); diff --git a/public/app/features/invites/state/reducers.ts b/public/app/features/invites/state/reducers.ts new file mode 100644 index 00000000000..bad26fdc938 --- /dev/null +++ b/public/app/features/invites/state/reducers.ts @@ -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, +}; diff --git a/public/app/features/invites/state/selectors.ts b/public/app/features/invites/state/selectors.ts new file mode 100644 index 00000000000..7a9468dbde9 --- /dev/null +++ b/public/app/features/invites/state/selectors.ts @@ -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; +}); diff --git a/public/app/features/org/UserInviteForm.tsx b/public/app/features/org/UserInviteForm.tsx index 9338884c4e8..3ab5073d6c9 100644 --- a/public/app/features/org/UserInviteForm.tsx +++ b/public/app/features/org/UserInviteForm.tsx @@ -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 ( diff --git a/public/app/features/org/api.ts b/public/app/features/org/api.ts deleted file mode 100644 index 18f8c8e901b..00000000000 --- a/public/app/features/org/api.ts +++ /dev/null @@ -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]); - } -}; diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx index 7e505218622..9e4765490fd 100644 --- a/public/app/features/users/UsersActionBar.tsx +++ b/public/app/features/users/UsersActionBar.tsx @@ -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 { 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, diff --git a/public/app/features/users/UsersListPage.test.tsx b/public/app/features/users/UsersListPage.test.tsx index 396a5e66a00..4907b7403a6 100644 --- a/public/app/features/users/UsersListPage.test.tsx +++ b/public/app/features/users/UsersListPage.test.tsx @@ -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(), diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx index 8f3dfd00fb7..1083b4f9ac1 100644 --- a/public/app/features/users/UsersListPage.tsx +++ b/public/app/features/users/UsersListPage.tsx @@ -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 { } async fetchInvitees() { - return await this.props.loadInvitees(); + return await this.props.fetchInvitees(); } onRoleChange = (role: OrgRole, user: OrgUser) => { diff --git a/public/app/features/users/state/actions.ts b/public/app/features/users/state/actions.ts index cf07354f1b5..8d36742c584 100644 --- a/public/app/features/users/state/actions.ts +++ b/public/app/features/users/state/actions.ts @@ -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 { @@ -12,17 +11,6 @@ export function loadUsers(): ThunkResult { }; } -export function loadInvitees(): ThunkResult { - 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 { return async (dispatch) => { await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role }); @@ -36,10 +24,3 @@ export function removeUser(userId: number): ThunkResult { dispatch(loadUsers()); }; } - -export function revokeInvite(code: string): ThunkResult { - return async (dispatch) => { - await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {}); - dispatch(loadInvitees()); - }; -} diff --git a/public/app/features/users/state/reducers.test.ts b/public/app/features/users/state/reducers.test.ts index 31912950686..6499959c449 100644 --- a/public/app/features/users/state/reducers.test.ts +++ b/public/app/features/users/state/reducers.test.ts @@ -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() - .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() diff --git a/public/app/features/users/state/reducers.ts b/public/app/features/users/state/reducers.ts index c43cbd17783..436feccdd78 100644 --- a/public/app/features/users/state/reducers.ts +++ b/public/app/features/users/state/reducers.ts @@ -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): UsersState => { return { ...state, hasFetched: true, users: action.payload }; }, - inviteesLoaded: (state, action: PayloadAction): UsersState => { - return { ...state, hasFetched: true, invitees: action.payload }; - }, setUsersSearchQuery: (state, action: PayloadAction): 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; diff --git a/public/app/features/users/state/selectors.ts b/public/app/features/users/state/selectors.ts index 361ff059c3e..19a200c69ff 100644 --- a/public/app/features/users/state/selectors.ts +++ b/public/app/features/users/state/selectors.ts @@ -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; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index e9893268a36..017739d0ce2 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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', }, diff --git a/public/app/types/store.ts b/public/app/types/store.ts index 3db5c2f6b11..e94d1b9e1a8 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -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>; @@ -10,3 +23,14 @@ export type StoreState = ReturnType>; export type ThunkResult = ThunkAction>; export type ThunkDispatch = GenericThunkDispatch; + +export type AppDispatch = ReturnType['dispatch']; +export const useDispatch = () => useDispatchUntyped(); +export const useSelector: TypedUseSelectorHook = useSelectorUntyped; + +type DefaultThunkApiConfig = { dispatch: AppDispatch; state: StoreState }; +export const createAsyncThunk = ( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator, + options?: AsyncThunkOptions +): AsyncThunk => createAsyncThunkUntyped(typePrefix, payloadCreator, options); diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 2983c53a781..157bc143c0b 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -64,7 +64,6 @@ export interface Invitee { export interface UsersState { users: OrgUser[]; - invitees: Invitee[]; searchQuery: string; searchPage: number; canInvite: boolean;