Compare commits

...

4 Commits

Author SHA1 Message Date
Vinícius Lourenço
8d13cbfc56 refactor(getting-started): use new invite members component 2026-06-26 15:01:08 -03:00
Vinícius Lourenço
84626d3fc3 test(invite-members): add tests for the component 2026-06-26 14:34:51 -03:00
Vinícius Lourenço
5d3eb6c663 feat(invite-members): add component to handle invite member logic 2026-06-26 14:09:32 -03:00
Vinícius Lourenço
80cf6ec229 feat(invite-members): add hook for invite members component 2026-06-26 14:09:27 -03:00
15 changed files with 1829 additions and 422 deletions

View File

@@ -0,0 +1,163 @@
.inviteMembers {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
}
.table {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.header {
display: flex;
gap: var(--spacing-8);
align-items: center;
width: 100%;
}
.headerCellEmail {
flex: 1 1 0;
min-width: 0;
}
.headerCellRole {
flex: 0 0 160px;
width: 160px;
}
.headerCellAction {
flex: 0 0 32px;
width: 32px;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
width: 100%;
}
.row {
display: flex;
gap: var(--spacing-8);
align-items: flex-start;
width: 100%;
}
.cellEmail {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 1 1 0;
min-width: 0;
--input-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-hover-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-focus-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-disabled-background: var(
--invite-members-field-background,
var(--l2-background)
);
input::placeholder {
color: var(--l3-foreground);
}
}
.cellRole {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 0 0 160px;
width: 160px;
:global(.roles-single-select) {
width: 100%;
:global(.ant-select-selector) {
background-color: var(
--invite-members-field-background,
var(--l2-background)
) !important;
}
}
}
.cellAction {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 0 0 32px;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
}
.errorText {
color: var(--danger-background);
}
.addRow {
display: flex;
justify-content: flex-start;
margin-top: var(--spacing-2);
}
.callout {
animation: shake 300ms ease-out;
&[data-type='success'] {
animation: none;
}
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.resultsList {
margin: var(--spacing-2) 0 0 var(--spacing-8);
padding: 0;
li {
margin-bottom: var(--spacing-1);
}
}
@keyframes shake {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -0,0 +1,221 @@
import { CircleAlert, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import RolesSelect from 'components/RolesSelect/RolesSelect';
import styles from './InviteMembers.module.scss';
import { InviteMembersProps } from './types';
import { useInviteMembers } from './useInviteMembers';
function InviteMembers({
className,
initialRowCount = 3,
minRows = 1,
emailPlaceholder = 'e.g. john@signoz.io',
showHeader = true,
showAddButton = true,
onSuccess,
onPartialSuccess,
onAllFailed,
renderFooter,
}: InviteMembersProps): JSX.Element {
const {
rows,
emailValidity,
hasInvalidEmails,
hasInvalidRoles,
isSubmitting,
inviteResults,
addRow,
removeRow,
updateEmail,
updateRole,
reset,
submit,
touchedRows,
failedResults,
successResults,
} = useInviteMembers({
initialRowCount,
onSuccess,
onPartialSuccess,
onAllFailed,
});
const canSubmit = !isSubmitting && touchedRows.length > 0;
const canRemoveRow = rows.length > minRows;
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
};
const hasValidationErrors = hasInvalidEmails || hasInvalidRoles;
const hasResults = inviteResults !== null;
const hasFailures = failedResults.length > 0;
const hasSuccesses = successResults.length > 0;
return (
<div className={cx(styles.inviteMembers, className)}>
<div className={styles.table}>
{showHeader && (
<div className={styles.header}>
<Typography.Text
size="base"
weight="semibold"
className={styles.headerCellEmail}
>
Email address
</Typography.Text>
<Typography.Text
size="base"
weight="semibold"
className={styles.headerCellRole}
>
Role
</Typography.Text>
<div className={styles.headerCellAction} />
</div>
)}
<div className={styles.rows}>
{rows.map((row) => (
<div key={row.id} className={styles.row}>
<div className={styles.cellEmail}>
<Input
type="email"
placeholder={emailPlaceholder}
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
name={`invite-email-${row.id}`}
autoComplete="email"
data-testid={`invite-email-${row.id}`}
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<Typography.Text size="small" className={styles.errorText}>
Invalid email address
</Typography.Text>
)}
</div>
<div className={styles.cellRole}>
<RolesSelect
mode="single"
value={row.roleId || undefined}
onChange={(roleId): void => updateRole(row.id, roleId)}
placeholder="Select role"
allowClear={false}
id={`invite-role-${row.id}`}
/>
</div>
<div className={styles.cellAction}>
{canRemoveRow && (
<Button
variant="ghost"
color="destructive"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
data-testid={`invite-remove-${row.id}`}
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
))}
</div>
{showAddButton && (
<div className={styles.addRow}>
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={12} />}
onClick={addRow}
data-testid="invite-add-row"
>
Add another
</Button>
</div>
)}
</div>
{hasValidationErrors && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-validation-error"
>
{getValidationErrorMessage()}
</Callout>
)}
{hasResults && hasFailures && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-api-error"
>
<div className={styles.results}>
{hasSuccesses && (
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully.
</Typography.Text>
)}
<Typography.Text size="small">
{failedResults.length} invite(s) failed:
</Typography.Text>
<ul className={styles.resultsList}>
{failedResults.map((result) => (
<li key={result.email}>
<Typography.Text size="small">
{result.email}: {result.error}
</Typography.Text>
</li>
))}
</ul>
</div>
</Callout>
)}
{hasResults && !hasFailures && hasSuccesses && (
<Callout
type="success"
size="small"
showIcon
className={styles.callout}
data-testid="invite-success"
>
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully!
</Typography.Text>
</Callout>
)}
{renderFooter?.({
submit,
reset,
canSubmit,
isSubmitting,
touchedCount: touchedRows.length,
})}
</div>
);
}
export default InviteMembers;

View File

@@ -0,0 +1,240 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
CREATE_USER_ENDPOINT,
createErrorHandler,
createRolesHandler,
createSuccessHandler,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Edge Cases', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('reset behavior', () => {
it('clears all rows when reset is called', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={2}
renderFooter={({ reset }): JSX.Element => (
<button data-testid="reset-btn" onClick={reset}>
Reset
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getByTestId('reset-btn'));
const resetInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(resetInputs).toHaveLength(2);
resetInputs.forEach((input) => {
expect(input).toHaveValue('');
});
});
it('clears results on reset', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit, reset }): JSX.Element => (
<>
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
<button data-testid="reset-btn" onClick={reset}>
Reset
</button>
</>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-success'),
).resolves.toBeInTheDocument();
await user.click(screen.getByTestId('reset-btn'));
expect(screen.queryByTestId('invite-success')).not.toBeInTheDocument();
});
});
describe('results cleared on edit', () => {
it('clears API error when email is edited', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
await user.type(emailInputs[0], 'x');
await waitFor(() => {
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
});
});
it('clears API error when role is changed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
const viewerElements = screen.getAllByText('Viewer');
await user.click(viewerElements[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await waitFor(() => {
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
});
});
});
describe('empty submission', () => {
it('does not submit when no rows are touched', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSuccess = jest.fn();
render(
<InviteMembers
onSuccess={onSuccess}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
await user.click(screen.getByTestId('submit-btn'));
expect(onSuccess).not.toHaveBeenCalled();
});
});
describe('submitting state', () => {
it('disables submit while submitting', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit, isSubmitting }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit} disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
const submitBtn = screen.getByTestId('submit-btn');
await user.click(submitBtn);
await waitFor(() => {
expect(screen.queryByText('Submitting...')).not.toBeInTheDocument();
});
});
});
describe('whitespace handling', () => {
it('trims email whitespace before submission', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const calls: { email: string }[] = [];
server.use(
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
calls.push(body);
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
}),
);
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], ' alice@signoz.io ');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(1);
expect(calls[0].email).toBe('alice@signoz.io');
});
});
});
});

View File

@@ -0,0 +1,95 @@
import { server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import { createRolesHandler, createSuccessHandler } from './testUtils';
describe('InviteMembers - Rendering', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
it('renders default initial row count of 3', () => {
render(<InviteMembers />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(emailInputs).toHaveLength(3);
});
it('renders custom initial row count', () => {
render(<InviteMembers initialRowCount={5} />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(emailInputs).toHaveLength(5);
});
it('renders header by default', () => {
render(<InviteMembers />);
expect(screen.getByText('Email address')).toBeInTheDocument();
expect(screen.getByText('Role')).toBeInTheDocument();
});
it('hides header when showHeader is false', () => {
render(<InviteMembers showHeader={false} />);
expect(screen.queryByText('Email address')).not.toBeInTheDocument();
expect(screen.queryByText('Role')).not.toBeInTheDocument();
});
it('renders add button by default', () => {
render(<InviteMembers />);
expect(
screen.getByRole('button', { name: /add another/i }),
).toBeInTheDocument();
});
it('hides add button when showAddButton is false', () => {
render(<InviteMembers showAddButton={false} />);
expect(
screen.queryByRole('button', { name: /add another/i }),
).not.toBeInTheDocument();
});
it('renders custom email placeholder', () => {
render(<InviteMembers emailPlaceholder="custom@placeholder.com" />);
const emailInputs = screen.getAllByPlaceholderText('custom@placeholder.com');
expect(emailInputs).toHaveLength(3);
});
it('applies custom className', () => {
const { container } = render(<InviteMembers className="custom-class" />);
expect(container.querySelector('.custom-class')).toBeInTheDocument();
});
it('renders footer via renderFooter prop', () => {
render(
<InviteMembers
renderFooter={({ canSubmit }): JSX.Element => (
<button data-testid="custom-footer" disabled={!canSubmit}>
Custom Submit
</button>
)}
/>,
);
expect(screen.getByTestId('custom-footer')).toBeInTheDocument();
expect(screen.getByTestId('custom-footer')).toBeDisabled();
});
it('renders role select for each row', () => {
render(<InviteMembers initialRowCount={2} />);
const roleSelects = screen.getAllByText('Select role');
expect(roleSelects).toHaveLength(2);
});
});

View File

@@ -0,0 +1,95 @@
import { server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import { createRolesHandler, createSuccessHandler } from './testUtils';
describe('InviteMembers - Row Management', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
it('adds a row when "Add another" is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} />);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(3);
});
it('removes a row when trash button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={3} />);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
expect(removeButtons).toHaveLength(3);
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
});
it('respects minRows constraint when removing rows', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} minRows={2} />);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
await user.click(screen.getByRole('button', { name: /add another/i }));
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
expect(removeButtons).toHaveLength(3);
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
});
it('cannot remove rows below minRows=1 default', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} />);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(1);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
});
it('preserves data in other rows when removing one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={3} />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'first@signoz.io');
await user.type(emailInputs[2], 'third@signoz.io');
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[1]);
const remainingInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(remainingInputs).toHaveLength(2);
expect(remainingInputs[0]).toHaveValue('first@signoz.io');
expect(remainingInputs[1]).toHaveValue('third@signoz.io');
});
});

View File

@@ -0,0 +1,362 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
CREATE_USER_ENDPOINT,
createErrorHandler,
createRolesHandler,
createSuccessHandler,
createTrackingHandler,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Submission', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('API calls', () => {
it('calls createUser API for each touched row', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { handler, calls } = createTrackingHandler();
server.use(handler);
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
email: 'alice@signoz.io',
userRoles: [{ id: 'role-viewer' }],
});
});
});
it('calls createUser API for multiple touched rows', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { handler, calls } = createTrackingHandler();
server.use(handler);
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.type(emailInputs[2], 'charlie@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const adminOptions = await screen.findAllByText('Admin');
await user.click(adminOptions[adminOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(3);
});
expect(calls[0]).toMatchObject({
email: 'alice@signoz.io',
userRoles: [{ id: 'role-viewer' }],
});
expect(calls[1]).toMatchObject({
email: 'bob@signoz.io',
userRoles: [{ id: 'role-editor' }],
});
expect(calls[2]).toMatchObject({
email: 'charlie@signoz.io',
userRoles: [{ id: 'role-admin' }],
});
});
});
describe('callbacks', () => {
it('calls onSuccess when all invites succeed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSuccess = jest.fn();
render(
<InviteMembers
onSuccess={onSuccess}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled();
});
});
it('calls onAllFailed when all invites fail', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onAllFailed = jest.fn();
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
onAllFailed={onAllFailed}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(onAllFailed).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
email: VALID_EMAIL,
success: false,
}),
]),
);
});
});
it('calls onPartialSuccess when some invites succeed and some fail', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onPartialSuccess = jest.fn();
const onSuccess = jest.fn();
const onAllFailed = jest.fn();
const apiCalls: string[] = [];
let callCount = 0;
server.use(
createRolesHandler(),
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
apiCalls.push(body.email);
callCount++;
if (callCount === 1) {
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
}
return res(
ctx.status(409),
ctx.json({
error: {
code: 'already_exists',
message: 'User already exists',
},
}),
);
}),
);
render(
<InviteMembers
initialRowCount={2}
onSuccess={onSuccess}
onPartialSuccess={onPartialSuccess}
onAllFailed={onAllFailed}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(apiCalls).toHaveLength(2);
});
expect(apiCalls).toStrictEqual(['alice@signoz.io', 'bob@signoz.io']);
expect(onSuccess).not.toHaveBeenCalled();
expect(onAllFailed).not.toHaveBeenCalled();
expect(onPartialSuccess).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ email: 'alice@signoz.io', success: true }),
expect.objectContaining({
email: 'bob@signoz.io',
success: false,
error: 'User already exists',
}),
]),
);
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
expect(
screen.getByText('1 invite(s) sent successfully.'),
).toBeInTheDocument();
expect(screen.getByText('1 invite(s) failed:')).toBeInTheDocument();
expect(
screen.getByText('bob@signoz.io: User already exists'),
).toBeInTheDocument();
});
});
describe('result display', () => {
it('shows success callout when all invites succeed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-success'),
).resolves.toBeInTheDocument();
});
it('shows error callout with failed emails when API fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
expect(screen.getByText(/1 invite\(s\) failed/)).toBeInTheDocument();
});
});
describe('footer props', () => {
it('provides correct canSubmit state', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ canSubmit }): JSX.Element => (
<button data-testid="submit-btn" disabled={!canSubmit}>
Submit
</button>
)}
/>,
);
expect(screen.getByTestId('submit-btn')).toBeDisabled();
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
expect(screen.getByTestId('submit-btn')).not.toBeDisabled();
});
it('provides touchedCount', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ touchedCount }): JSX.Element => (
<span data-testid="touched-count">{touchedCount}</span>
)}
/>,
);
expect(screen.getByTestId('touched-count')).toHaveTextContent('0');
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'a@b.com');
expect(screen.getByTestId('touched-count')).toHaveTextContent('1');
await user.type(emailInputs[1], 'c@d.com');
expect(screen.getByTestId('touched-count')).toHaveTextContent('2');
});
});
});

View File

@@ -0,0 +1,217 @@
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
createRolesHandler,
createSuccessHandler,
INVALID_EMAIL,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Validation', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('email validation', () => {
it('shows email validation error when email is invalid and role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
});
it('shows inline error for invalid email field', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Invalid email address'),
).resolves.toBeInTheDocument();
});
it('clears validation error when email is corrected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
await user.clear(emailInputs[0]);
await user.type(emailInputs[0], VALID_EMAIL);
await waitFor(() => {
expect(
screen.queryByText('Please enter valid emails for team members'),
).not.toBeInTheDocument();
});
});
});
describe('role validation', () => {
it('shows role validation error when role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
});
it('clears role validation error when role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await waitFor(() => {
expect(
screen.queryByText('Please select roles for team members'),
).not.toBeInTheDocument();
});
});
});
describe('combined validation', () => {
it('shows combined error when both email and role are invalid', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText(
'Please enter valid emails and select roles for team members',
),
).resolves.toBeInTheDocument();
});
});
describe('touched rows', () => {
it('only validates touched rows (rows with email or role)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
expect(
screen.queryByTestId('invite-validation-error'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,54 @@
import { RestHandler } from 'msw';
import { rest } from 'mocks-server/server';
export const CREATE_USER_ENDPOINT = '*/api/v2/users';
export const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
export const MOCK_ROLES = [
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
];
export const VALID_EMAIL = 'alice@signoz.io';
export const INVALID_EMAIL = 'not-an-email';
export function createSuccessHandler(): RestHandler {
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
);
}
export function createErrorHandler(
code: string,
message: string,
status = 409,
): RestHandler {
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(status),
ctx.json({
errors: [{ code, msg: message }],
}),
),
);
}
export function createRolesHandler(): RestHandler {
return rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
);
}
export function createTrackingHandler(): {
handler: RestHandler;
calls: unknown[];
} {
const calls: unknown[] = [];
const handler = rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
calls.push(body);
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
});
return { handler, calls };
}

View File

@@ -0,0 +1,64 @@
import { ReactNode } from 'react';
export interface InviteMemberRow {
id: string;
email: string;
roleId: string;
}
export interface InviteResult {
email: string;
success: boolean;
error?: string;
}
export interface FooterRenderProps {
submit: () => Promise<InviteResult[]>;
reset: () => void;
canSubmit: boolean;
isSubmitting: boolean;
touchedCount: number;
}
export interface UseInviteMembersOptions {
initialRowCount?: number;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
}
export interface UseInviteMembersReturn {
rows: InviteMemberRow[];
emailValidity: Record<string, boolean>;
hasInvalidEmails: boolean;
hasInvalidRoles: boolean;
isSubmitting: boolean;
inviteResults: InviteResult[] | null;
addRow: () => void;
removeRow: (id: string) => void;
updateEmail: (id: string, email: string) => void;
updateRole: (id: string, roleId: string | undefined) => void;
reset: () => void;
submit: () => Promise<InviteResult[]>;
touchedRows: InviteMemberRow[];
failedResults: InviteResult[];
successResults: InviteResult[];
canSubmit: boolean;
}
export interface InviteMembersProps {
className?: string;
initialRowCount?: number;
minRows?: number;
emailPlaceholder?: string;
showHeader?: boolean;
showAddButton?: boolean;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
renderFooter?: (props: FooterRenderProps) => ReactNode;
}

View File

@@ -0,0 +1,245 @@
import { useCallback, useMemo, useState } from 'react';
import { AxiosError } from 'axios';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { createUser } from 'api/generated/services/users';
import { cloneDeep, debounce } from 'lodash-es';
import { EMAIL_REGEX } from 'utils/app';
import { getBaseUrl } from 'utils/basePath';
import { v4 as uuid } from 'uuid';
import {
InviteMemberRow,
InviteResult,
UseInviteMembersOptions,
UseInviteMembersReturn,
} from './types';
const createEmptyRow = (): InviteMemberRow => ({
id: uuid(),
email: '',
roleId: '',
});
const isRowTouched = (row: InviteMemberRow): boolean =>
row.email.trim() !== '' || row.roleId !== '';
export function useInviteMembers(
options: UseInviteMembersOptions = {},
): UseInviteMembersReturn {
const {
initialRowCount = 3,
onSuccess,
onPartialSuccess,
onAllFailed,
} = options;
const [rows, setRows] = useState<InviteMemberRow[]>(() =>
Array.from({ length: initialRowCount }, () => createEmptyRow()),
);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [inviteResults, setInviteResults] = useState<InviteResult[] | null>(
null,
);
const touchedRows = useMemo(() => rows.filter(isRowTouched), [rows]);
const failedResults = useMemo(
() => inviteResults?.filter((r) => !r.success) ?? [],
[inviteResults],
);
const successResults = useMemo(
() => inviteResults?.filter((r) => r.success) ?? [],
[inviteResults],
);
const debouncedValidateEmail = useMemo(
() =>
debounce((email: string, rowId: string) => {
const isValid = EMAIL_REGEX.test(email);
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
}, 500),
[],
);
const validateAllRows = useCallback((): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touched = rows.filter(isRowTouched);
touched.forEach((row) => {
const emailValid = EMAIL_REGEX.test(row.email);
const roleValid = row.roleId !== '';
if (!emailValid || !row.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
updatedEmailValidity[row.id] = emailValid;
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
}, [rows]);
const addRow = useCallback((): void => {
setRows((prev) => [...prev, createEmptyRow()]);
}, []);
const removeRow = useCallback((id: string): void => {
setRows((prev) => prev.filter((r) => r.id !== id));
setEmailValidity((prev) => {
const updated = { ...prev };
delete updated[id];
return updated;
});
}, []);
const updateEmail = useCallback(
(id: string, email: string): void => {
setRows((prev) => {
const updated = cloneDeep(prev);
const row = updated.find((r) => r.id === id);
if (row) {
row.email = email;
}
return updated;
});
if (hasInvalidEmails) {
setHasInvalidEmails(false);
}
if (emailValidity[id] === false) {
setEmailValidity((prev) => ({ ...prev, [id]: true }));
}
if (inviteResults) {
setInviteResults(null);
}
debouncedValidateEmail(email, id);
},
[hasInvalidEmails, emailValidity, inviteResults, debouncedValidateEmail],
);
const updateRole = useCallback(
(id: string, roleId: string | undefined): void => {
setRows((prev) => {
const updated = cloneDeep(prev);
const row = updated.find((r) => r.id === id);
if (row) {
row.roleId = roleId ?? '';
}
return updated;
});
if (hasInvalidRoles) {
setHasInvalidRoles(false);
}
if (inviteResults) {
setInviteResults(null);
}
},
[hasInvalidRoles, inviteResults],
);
const reset = useCallback((): void => {
setRows(Array.from({ length: initialRowCount }, () => createEmptyRow()));
setEmailValidity({});
setHasInvalidEmails(false);
setHasInvalidRoles(false);
setInviteResults(null);
}, [initialRowCount]);
const submit = useCallback(async (): Promise<InviteResult[]> => {
if (!validateAllRows()) {
return [];
}
const touched = rows.filter(isRowTouched);
if (touched.length === 0) {
return [];
}
setIsSubmitting(true);
setInviteResults(null);
const results: InviteResult[] = [];
for (const row of touched) {
try {
await createUser({
email: row.email.trim(),
frontendBaseUrl: getBaseUrl(),
userRoles: [{ id: row.roleId }],
});
results.push({ email: row.email, success: true });
} catch (err) {
const apiErr = convertToApiError(err as AxiosError<RenderErrorResponseDTO>);
results.push({
email: row.email,
success: false,
error: apiErr?.getErrorMessage() ?? 'Unknown error',
});
}
}
setInviteResults(results);
setIsSubmitting(false);
const failures = results.filter((r) => !r.success);
const successes = results.filter((r) => r.success);
if (failures.length === 0) {
onSuccess?.();
} else if (successes.length > 0) {
onPartialSuccess?.(results);
} else {
onAllFailed?.(results);
}
return results;
}, [validateAllRows, rows, onSuccess, onPartialSuccess, onAllFailed]);
const canSubmit = useMemo(
() => !isSubmitting && touchedRows.length > 0,
[isSubmitting, touchedRows.length],
);
return {
rows,
emailValidity,
hasInvalidEmails,
hasInvalidRoles,
isSubmitting,
inviteResults,
addRow,
removeRow,
updateEmail,
updateRole,
reset,
submit,
touchedRows,
failedResults,
successResults,
canSubmit,
};
}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
import { ArrowRight, Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
import {
Button,
Flex,
@@ -10,6 +10,7 @@ import {
Space,
Steps,
} from 'antd';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
@@ -25,7 +26,7 @@ import { isModifierKeyPressed } from 'utils/app';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import OnboardingIngestionDetails from '../IngestionDetails/IngestionDetails';
import InviteTeamMembers from '../InviteTeamMembers/InviteTeamMembers';
import InviteMembers from 'components/InviteMembers/InviteMembers';
import onboardingConfigWithLinks from '../onboarding-configs/onboarding-config-with-links';
import '../OnboardingV2.styles.scss';
@@ -117,6 +118,10 @@ const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
GET_HELP_BUTTON_CLICKED: 'Get help clicked',
GET_EXPERT_ASSISTANCE_BUTTON_CLICKED: 'Get expert assistance clicked',
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Invite team member clicked',
INVITE_TEAM_MEMBER_SEND_CLICKED: 'Send invites clicked',
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
CLOSE_ONBOARDING_CLICKED: 'Close onboarding clicked',
DATA_SOURCE_REQUESTED: 'Datasource requested',
DATA_SOURCE_SEARCHED: 'Searched',
@@ -1094,12 +1099,55 @@ function OnboardingAddDataSource(): JSX.Element {
destroyOnClose
>
<div className="invite-team-member-modal-content">
<InviteTeamMembers
isLoading={false}
teamMembers={null}
setTeamMembers={(): void => {}}
onNext={(): void => setShowInviteTeamMembersModal(false)}
onClose={(): void => setShowInviteTeamMembersModal(false)}
<InviteMembers
onSuccess={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
{},
);
setShowInviteTeamMembersModal(false);
toast.success('Invites sent successfully', { position: 'top-center' });
}}
onPartialSuccess={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_PARTIAL_SUCCESS}`,
{},
);
}}
onAllFailed={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
{},
);
}}
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
<div className="invite-team-member-modal-footer">
<Button
type="default"
className="periscope-btn"
onClick={(): void => setShowInviteTeamMembersModal(false)}
>
Cancel
</Button>
<Button
type="primary"
className="periscope-btn primary"
onClick={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SEND_CLICKED}`,
{},
);
void submit();
}}
disabled={!canSubmit}
loading={isSubmitting}
>
Send Invites
<ArrowRight size={14} />
</Button>
</div>
)}
/>
</div>
</Modal>

View File

@@ -1,116 +0,0 @@
.team-member-container {
display: flex;
align-items: center;
.invite-team-members-form {
padding: 16px 0px;
}
.team-member-email-input {
width: 80%;
background-color: var(--l1-background);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-input,
.ant-input-group-addon {
background-color: var(--l1-background) !important;
border-right: 0px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
.team-member-role-select {
width: 20%;
.ant-select-selector {
border: 1px solid var(--l1-border);
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
.remove-team-member-button {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
.invite-team-members-container {
display: flex;
flex-direction: column;
gap: 8px;
.invite-team-members-add-another-member-container {
margin: 16px 0px;
display: flex;
justify-content: flex-end;
}
.next-prev-container {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.error-message-container,
.success-message-container,
.partially-sent-invites-container {
border-radius: 4px;
width: 100%;
display: flex;
align-items: center;
.error-message,
.success-message {
font-size: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 8px;
}
}
.invite-users-error-message-container,
.invite-users-success-message-container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.success-message {
color: var(--bg-success-500, #00b37e);
}
}
.partially-sent-invites-container {
margin-top: 16px;
padding: 8px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
box-sizing: border-box;
.partially-sent-invites-message {
color: var(--bg-warning-500, #fbbd23);
font-size: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 8px;
}
}
}

View File

@@ -1,298 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/v1/invite/bulk/create';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, debounce, isEmpty } from 'lodash-es';
import {
ArrowRight,
CircleCheck,
Plus,
TriangleAlert,
X,
} from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { v4 as uuid } from 'uuid';
import './InviteTeamMembers.styles.scss';
interface TeamMember {
email: string;
role: string;
name: string;
frontendBaseUrl: string;
id: string;
}
interface InviteTeamMembersProps {
isLoading: boolean;
teamMembers: TeamMember[] | null;
setTeamMembers: (teamMembers: TeamMember[]) => void;
onNext: () => void;
onClose: () => void;
}
const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
BASE: 'Onboarding V3',
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Send invites clicked',
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
};
function InviteTeamMembers({
isLoading,
teamMembers,
setTeamMembers,
onNext,
onClose,
}: InviteTeamMembersProps): JSX.Element {
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
TeamMember[] | null
>(teamMembers);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const { notifications } = useNotifications();
const defaultTeamMember: TeamMember = {
email: '',
role: 'EDITOR',
name: '',
frontendBaseUrl: getBaseUrl(),
id: '',
};
useEffect(() => {
if (isEmpty(teamMembers)) {
const teamMember = {
...defaultTeamMember,
id: uuid(),
};
setTeamMembersToInvite([teamMember]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [teamMembers]);
const handleAddTeamMember = (): void => {
const newTeamMember = {
...defaultTeamMember,
id: uuid(),
};
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
};
const handleRemoveTeamMember = (id: string): void => {
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
};
// Validation function to check all users
const validateAllUsers = (): boolean => {
let isValid = true;
const updatedValidity: Record<string, boolean> = {};
teamMembersToInvite?.forEach((member) => {
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
if (!emailValid || !member.email) {
isValid = false;
setHasInvalidEmails(true);
}
updatedValidity[member.id!] = emailValid;
});
setEmailValidity(updatedValidity);
return isValid;
};
const handleInviteUsersSuccess = (): void => {
logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
{
teamMembers: teamMembersToInvite,
},
);
setTimeout(() => {
onNext();
}, 1000);
};
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
inviteUsers,
{
onSuccess: (): void => {
handleInviteUsersSuccess();
notifications.success({
message: 'Invites sent successfully!',
});
},
onError: (error: APIError): void => {
logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
{
teamMembers: teamMembersToInvite,
error,
},
);
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
},
},
);
const handleNext = (): void => {
if (validateAllUsers()) {
setTeamMembers(teamMembersToInvite || []);
logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_BUTTON_CLICKED}`,
{
teamMembers: teamMembersToInvite,
},
);
setHasInvalidEmails(false);
sendInvites({
invites: teamMembersToInvite || [],
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedValidateEmail = useCallback(
debounce((email: string, memberId: string) => {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
}, 500),
[],
);
const handleEmailChange = (
e: React.ChangeEvent<HTMLInputElement>,
member: TeamMember,
): void => {
const { value } = e.target;
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate) {
memberToUpdate.email = value;
setTeamMembersToInvite(updatedMembers);
debouncedValidateEmail(value, member.id!);
}
};
const handleRoleChange = (role: string, member: TeamMember): void => {
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate) {
memberToUpdate.role = role;
setTeamMembersToInvite(updatedMembers);
}
};
return (
<div className="invite-team-members-container">
<div className="invite-team-members-form">
<div className="form-group">
<div className="invite-team-members-container">
{teamMembersToInvite?.map((member) => (
<div className="team-member-container" key={member.id}>
<Input
placeholder="your-teammate@org.com"
value={member.email}
type="email"
required
autoFocus
autoComplete="off"
className="team-member-email-input"
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
handleEmailChange(e, member)
}
addonAfter={
emailValidity[member.id!] === undefined ? null : emailValidity[
member.id!
] ? (
<CircleCheck size={14} color={Color.BG_FOREST_500} />
) : (
<TriangleAlert size={14} color={Color.BG_SIENNA_500} />
)
}
/>
<Select
defaultValue={member.role}
onChange={(value): void => handleRoleChange(value, member)}
className="team-member-role-select"
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
{teamMembersToInvite?.length > 1 && (
<Button
type="default"
className="remove-team-member-button periscope-btn"
icon={<X size={14} />}
onClick={(): void => handleRemoveTeamMember(member.id)}
/>
)}
</div>
))}
</div>
<div className="invite-team-members-add-another-member-container">
<Button
type="primary"
className="add-another-member-button periscope-btn"
icon={<Plus size={14} />}
onClick={handleAddTeamMember}
>
Member
</Button>
</div>
</div>
{hasInvalidEmails && (
<div className="error-message-container">
<Typography.Text className="error-message" color="danger">
<TriangleAlert size={14} /> Please enter valid emails for all team
members
</Typography.Text>
</div>
)}
</div>
<div className="next-prev-container">
<Button
type="default"
className="next-button periscope-btn"
onClick={onClose}
>
<X size={14} />
Cancel
</Button>
<Button
type="primary"
className="next-button periscope-btn primary"
onClick={handleNext}
loading={isSendingInvites || isLoading}
>
Send Invites
<ArrowRight size={14} />
</Button>
</div>
</div>
);
}
export default InviteTeamMembers;

View File

@@ -1220,6 +1220,14 @@
.request-data-source-modal-input {
margin-top: 8px;
}
.invite-team-member-modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
border-top: 1px solid var(--l1-border);
padding-top: var(--spacing-6);
}
}
.request-data-source-modal {

View File

@@ -176,6 +176,15 @@ export const handlers = [
res(ctx.status(200), ctx.json(getDashboardById)),
),
rest.post('http://localhost/api/v2/users', (_, res, ctx) =>
res(
ctx.status(201),
ctx.json({
data: { id: 'user-123' },
}),
),
),
rest.post('http://localhost/api/v1/invite', (_, res, ctx) =>
res(
ctx.status(200),