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
24 changed files with 183 additions and 93 deletions
-40
View File
@@ -1,40 +0,0 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Invitee } from 'app/types';
import { revokeInvite } from './state/actions';
import { Button, ClipboardButton } from '@grafana/ui';
const mapDispatchToProps = {
revokeInvite,
};
const connector = connect(null, mapDispatchToProps);
interface OwnProps {
invitee: Invitee;
}
export type Props = OwnProps & ConnectedProps<typeof connector>;
class InviteeRow extends PureComponent<Props> {
render() {
const { invitee, revokeInvite } = this.props;
return (
<tr>
<td>{invitee.email}</td>
<td>{invitee.name}</td>
<td className="text-right">
<ClipboardButton variant="secondary" size="sm" getText={() => invitee.url}>
Copy Invite
</ClipboardButton>
&nbsp;
</td>
<td>
<Button variant="destructive" size="sm" icon="times" onClick={() => revokeInvite(invitee.code)} />
</td>
</tr>
);
}
}
export default connector(InviteeRow);
@@ -1,31 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import InviteesTable, { Props } from './InviteesTable';
import { Invitee } from 'app/types';
import { getMockInvitees } from './__mocks__/userMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
invitees: [] as Invitee[],
};
Object.assign(props, propOverrides);
return shallow(<InviteesTable {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render invitees', () => {
const wrapper = setup({
invitees: getMockInvitees(5),
});
expect(wrapper).toMatchSnapshot();
});
});
@@ -1,31 +0,0 @@
import React, { PureComponent } from 'react';
import { Invitee } from 'app/types';
import InviteeRow from './InviteeRow';
export interface Props {
invitees: Invitee[];
}
export default class InviteesTable extends PureComponent<Props> {
render() {
const { invitees } = this.props;
return (
<table className="filter-table form-inline">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th />
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{invitees.map((invitee, index) => {
return <InviteeRow key={`${invitee.id}-${index}`} invitee={invitee} />;
})}
</tbody>
</table>
);
}
}
@@ -1,132 +0,0 @@
import React from 'react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignupInvitedPage, Props } from './SignupInvited';
import { backendSrv } from '../../core/services/backend_srv';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
jest.mock('app/core/core', () => ({
contextSrv: {
user: { orgName: 'Invited to Org Name' },
},
}));
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getBackendSrv: () => backendSrv,
}));
const defaultGet = {
email: 'some.user@localhost',
name: 'Some User',
invitedBy: 'Invited By User',
username: 'someuser',
};
async function setupTestContext({ get = defaultGet }: { get?: typeof defaultGet | null } = {}) {
jest.clearAllMocks();
const getSpy = jest.spyOn(backendSrv, 'get');
getSpy.mockResolvedValue(get);
const postSpy = jest.spyOn(backendSrv, 'post');
postSpy.mockResolvedValue([]);
const props: Props = {
...getRouteComponentProps({
match: {
params: { code: 'some code' },
} as any,
}),
};
render(<SignupInvitedPage {...props} />);
await waitFor(() => expect(getSpy).toHaveBeenCalled());
expect(getSpy).toHaveBeenCalledTimes(1);
return { getSpy, postSpy };
}
describe('SignupInvitedPage', () => {
describe('when initialized but invite data has not been retrieved yet', () => {
it('then it should not render', async () => {
await setupTestContext({ get: null });
expect(screen.queryByText(/email/i)).not.toBeInTheDocument();
});
});
describe('when initialized and invite data has been retrieved', () => {
it('then the greeting should be correct', async () => {
await setupTestContext();
expect(
screen.getByRole('heading', {
name: /hello some user\./i,
})
).toBeInTheDocument();
});
it('then the invited by should be correct', async () => {
await setupTestContext();
const view = screen.getByText(
/has invited you to join grafana and the organization please complete the following and choose a password to accept your invitation and continue:/i
);
expect(within(view).getByText(/invited by user/i)).toBeInTheDocument();
});
it('then the organization invited to should be correct', async () => {
await setupTestContext();
const view = screen.getByText(
/has invited you to join grafana and the organization please complete the following and choose a password to accept your invitation and continue:/i
);
expect(within(view).getByText(/invited to org name/i)).toBeInTheDocument();
});
it('then the form should include form data', async () => {
await setupTestContext();
expect(screen.getByPlaceholderText(/email@example\.com/i)).toHaveValue('some.user@localhost');
expect(screen.getByPlaceholderText(/name \(optional\)/i)).toHaveValue('Some User');
expect(screen.getByPlaceholderText(/username/i)).toHaveValue('some.user@localhost');
expect(screen.getByPlaceholderText(/password/i)).toHaveValue('');
});
});
describe('when user submits the form and the required fields are not filled in', () => {
it('then required fields should show error messages and nothing should be posted', async () => {
const { postSpy } = await setupTestContext({ get: { email: '', invitedBy: '', name: '', username: '' } });
userEvent.click(screen.getByRole('button', { name: /sign up/i }));
await waitFor(() => expect(screen.getByText(/email is required/i)).toBeInTheDocument());
expect(screen.getByText(/username is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
expect(postSpy).toHaveBeenCalledTimes(0);
});
});
describe('when user submits the form and the required fields are filled in', () => {
it('then correct form data should be posted', async () => {
const { postSpy } = await setupTestContext();
userEvent.type(screen.getByPlaceholderText(/password/i), 'pass@word1');
userEvent.click(screen.getByRole('button', { name: /sign up/i }));
await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(1));
expect(postSpy).toHaveBeenCalledWith('/api/user/invite/complete', {
email: 'some.user@localhost',
name: 'Some User',
username: 'some.user@localhost',
password: 'pass@word1',
inviteCode: 'some code',
});
});
});
});
-108
View File
@@ -1,108 +0,0 @@
import React, { FC, useState } from 'react';
import { getBackendSrv } from '@grafana/runtime';
import { Button, Field, Form, Input } from '@grafana/ui';
import { useAsync } from 'react-use';
import Page from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { getConfig } from 'app/core/config';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
interface FormModel {
email: string;
name?: string;
username: string;
password?: string;
}
const navModel = {
main: {
icon: 'grafana',
text: 'Invite',
subTitle: 'Register your Grafana account',
breadcrumbs: [{ title: 'Login', url: 'login' }],
},
node: {
text: '',
},
};
export interface Props extends GrafanaRouteComponentProps<{ code: string }> {}
export const SignupInvitedPage: FC<Props> = ({ match }) => {
const code = match.params.code;
const [initFormModel, setInitFormModel] = useState<FormModel>();
const [greeting, setGreeting] = useState<string>();
const [invitedBy, setInvitedBy] = useState<string>();
useAsync(async () => {
const invite = await getBackendSrv().get(`/api/user/invite/${code}`);
setInitFormModel({
email: invite.email,
name: invite.name,
username: invite.email,
});
setGreeting(invite.name || invite.email || invite.username);
setInvitedBy(invite.invitedBy);
}, [code]);
const onSubmit = async (formData: FormModel) => {
await getBackendSrv().post('/api/user/invite/complete', { ...formData, inviteCode: code });
window.location.href = getConfig().appSubUrl + '/';
};
if (!initFormModel) {
return null;
}
return (
<Page navModel={navModel}>
<Page.Contents>
<h3 className="page-sub-heading">Hello {greeting || 'there'}.</h3>
<div className="modal-tagline p-b-2">
<em>{invitedBy || 'Someone'}</em> has invited you to join Grafana and the organization{' '}
<span className="highlight-word">{contextSrv.user.orgName}</span>
<br />
Please complete the following and choose a password to accept your invitation and continue:
</div>
<Form defaultValues={initFormModel} onSubmit={onSubmit}>
{({ register, errors }) => (
<>
<Field invalid={!!errors.email} error={errors.email && errors.email.message} label="Email">
<Input
placeholder="email@example.com"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^\S+@\S+$/,
message: 'Email is invalid',
},
})}
/>
</Field>
<Field invalid={!!errors.name} error={errors.name && errors.name.message} label="Name">
<Input placeholder="Name (optional)" {...register('name')} />
</Field>
<Field invalid={!!errors.username} error={errors.username && errors.username.message} label="Username">
<Input {...register('username', { required: 'Username is required' })} placeholder="Username" />
</Field>
<Field invalid={!!errors.password} error={errors.password && errors.password.message} label="Password">
<Input
{...register('password', { required: 'Password is required' })}
type="password"
placeholder="Password"
/>
</Field>
<Button type="submit">Sign up</Button>
</>
)}
</Form>
</Page.Contents>
</Page>
);
};
export default SignupInvitedPage;
+3 -2
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,
@@ -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(),
+9 -6
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) => {
@@ -1,180 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<table
className="filter-table form-inline"
>
<thead>
<tr>
<th>
Email
</th>
<th>
Name
</th>
<th />
<th
style={
Object {
"width": "34px",
}
}
/>
</tr>
</thead>
<tbody />
</table>
`;
exports[`Render should render invitees 1`] = `
<table
className="filter-table form-inline"
>
<thead>
<tr>
<th>
Email
</th>
<th>
Name
</th>
<th />
<th
style={
Object {
"width": "34px",
}
}
/>
</tr>
</thead>
<tbody>
<Connect(InviteeRow)
invitee={
Object {
"code": "asdfasdfsadf-0",
"createdOn": "2018-10-02",
"email": "invitee-0@test.com",
"emailSent": true,
"emailSentOn": "2018-10-02",
"id": 0,
"invitedByEmail": "admin@grafana.com",
"invitedByLogin": "admin",
"invitedByName": "admin",
"name": "invitee-0",
"orgId": 1,
"role": "viewer",
"status": "not accepted",
"url": "localhost/invite/0",
}
}
key="0-0"
/>
<Connect(InviteeRow)
invitee={
Object {
"code": "asdfasdfsadf-1",
"createdOn": "2018-10-02",
"email": "invitee-1@test.com",
"emailSent": true,
"emailSentOn": "2018-10-02",
"id": 1,
"invitedByEmail": "admin@grafana.com",
"invitedByLogin": "admin",
"invitedByName": "admin",
"name": "invitee-1",
"orgId": 1,
"role": "viewer",
"status": "not accepted",
"url": "localhost/invite/1",
}
}
key="1-1"
/>
<Connect(InviteeRow)
invitee={
Object {
"code": "asdfasdfsadf-2",
"createdOn": "2018-10-02",
"email": "invitee-2@test.com",
"emailSent": true,
"emailSentOn": "2018-10-02",
"id": 2,
"invitedByEmail": "admin@grafana.com",
"invitedByLogin": "admin",
"invitedByName": "admin",
"name": "invitee-2",
"orgId": 1,
"role": "viewer",
"status": "not accepted",
"url": "localhost/invite/2",
}
}
key="2-2"
/>
<Connect(InviteeRow)
invitee={
Object {
"code": "asdfasdfsadf-3",
"createdOn": "2018-10-02",
"email": "invitee-3@test.com",
"emailSent": true,
"emailSentOn": "2018-10-02",
"id": 3,
"invitedByEmail": "admin@grafana.com",
"invitedByLogin": "admin",
"invitedByName": "admin",
"name": "invitee-3",
"orgId": 1,
"role": "viewer",
"status": "not accepted",
"url": "localhost/invite/3",
}
}
key="3-3"
/>
<Connect(InviteeRow)
invitee={
Object {
"code": "asdfasdfsadf-4",
"createdOn": "2018-10-02",
"email": "invitee-4@test.com",
"emailSent": true,
"emailSentOn": "2018-10-02",
"id": 4,
"invitedByEmail": "admin@grafana.com",
"invitedByLogin": "admin",
"invitedByName": "admin",
"name": "invitee-4",
"orgId": 1,
"role": "viewer",
"status": "not accepted",
"url": "localhost/invite/4",
}
}
key="4-4"
/>
<Connect(InviteeRow)
invitee={
Object {
"code": "asdfasdfsadf-5",
"createdOn": "2018-10-02",
"email": "invitee-5@test.com",
"emailSent": true,
"emailSentOn": "2018-10-02",
"id": 5,
"invitedByEmail": "admin@grafana.com",
"invitedByLogin": "admin",
"invitedByName": "admin",
"name": "invitee-5",
"orgId": 1,
"role": "viewer",
"status": "not accepted",
"url": "localhost/invite/5",
}
}
key="5-5"
/>
</tbody>
</table>
`;
+2 -21
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());
};
}
@@ -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>()
+2 -6
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;
@@ -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;