mirror of
https://github.com/grafana/grafana.git
synced 2026-09-05 04:40:13 -05:00
Redux: Factor Invites out to separate slice (#45552)
This commit is contained in:
@@ -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>
|
||||
|
||||
</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;
|
||||
});
|
||||
Reference in New Issue
Block a user