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
@@ -0,0 +1,40 @@
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);
@@ -0,0 +1,31 @@
import React from 'react';
import { shallow } from 'enzyme';
import InviteesTable, { Props } from './InviteesTable';
import { Invitee } from 'app/types';
import { getMockInvitees } from '../users/__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();
});
});
@@ -0,0 +1,31 @@
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>
);
}
}
@@ -0,0 +1,132 @@
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',
});
});
});
});
@@ -0,0 +1,108 @@
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;
@@ -0,0 +1,180 @@
// 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>
`;
@@ -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;
});
@@ -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',
});
});
});
});
@@ -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,
};
@@ -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;
});