mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-30 20:00:44 +01:00
Compare commits
5 Commits
refactor/f
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3385776cce | ||
|
|
f5286d69f6 | ||
|
|
bcbac9a15c | ||
|
|
43038c59b5 | ||
|
|
fc83f91058 |
163
frontend/src/components/InviteMembers/InviteMembers.module.scss
Normal file
163
frontend/src/components/InviteMembers/InviteMembers.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
221
frontend/src/components/InviteMembers/InviteMembers.tsx
Normal file
221
frontend/src/components/InviteMembers/InviteMembers.tsx
Normal 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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
54
frontend/src/components/InviteMembers/__tests__/testUtils.ts
Normal file
54
frontend/src/components/InviteMembers/__tests__/testUtils.ts
Normal 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 };
|
||||
}
|
||||
64
frontend/src/components/InviteMembers/types.ts
Normal file
64
frontend/src/components/InviteMembers/types.ts
Normal 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;
|
||||
}
|
||||
245
frontend/src/components/InviteMembers/useInviteMembers.ts
Normal file
245
frontend/src/components/InviteMembers/useInviteMembers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.root {
|
||||
.panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9,13 +9,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
.panelBody {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
background: var(--l1-background);
|
||||
font-size: 14px;
|
||||
@@ -33,14 +34,17 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Single scroll: when the summary sits above (non-docked modes) it scrolls away
|
||||
// and the tab section pins to the top; tab content scrolls inside `.tabsScroll`.
|
||||
.tabsSection {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
|
||||
// TabsRoot — direct child of tabs-section
|
||||
> div {
|
||||
@@ -75,79 +79,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.spanRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.spanInfo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.spanInfoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.highlightedOptions {
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
|
||||
// KeyValueLabel uses a global `.key-value-label` root; constrain it
|
||||
// inside the two-column grid so values can ellipsize cleanly.
|
||||
:global(.key-value-label) {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.serviceDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusMessageBadge {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.traceId {
|
||||
color: var(--accent-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.traceIdCopy {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
// Tooltip is rendered in a portal but the SpanDetailsPanel can be docked as a
|
||||
// FloatingPanel (z-index 999), which would otherwise sit on top of the default
|
||||
// tooltip (z-index 50). Bump the tooltip above the panel.
|
||||
|
||||
@@ -5,20 +5,11 @@ import {
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import {
|
||||
Bookmark,
|
||||
CalendarClock,
|
||||
ChartColumnBig,
|
||||
Link2,
|
||||
List,
|
||||
ScrollText,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { Bookmark, ChartColumnBig, List, ScrollText } from '@signozhq/icons';
|
||||
import { Skeleton } from 'antd';
|
||||
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
|
||||
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
@@ -41,14 +32,13 @@ import {
|
||||
} from 'pages/TraceDetailsV3/utils';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { getLeafKeyFromPath } from 'periscope/components/PrettyView/utils';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
// KEY_ATTRIBUTE_KEYS, // uncomment when key attributes section is re-enabled
|
||||
SpanDetailVariant,
|
||||
@@ -57,17 +47,10 @@ import {
|
||||
import DockModeSwitcher from './DockModeSwitcher';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
LinkedSpansToggle,
|
||||
useLinkedSpans,
|
||||
} from './LinkedSpans/LinkedSpans';
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
import Events from './Events/Events';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanSummary from './SpanSummary';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
|
||||
@@ -80,6 +63,10 @@ interface SpanDetailsPanelProps {
|
||||
traceEndTime?: number;
|
||||
}
|
||||
|
||||
// At/above this panel width the summary moves inside the Overview tab (bottom
|
||||
// dock, or a floating/right panel widened to match). ~right-dock max width.
|
||||
const WIDE_PANEL_BREAKPOINT = 720;
|
||||
|
||||
function SpanDetailsContent({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
@@ -90,6 +77,7 @@ function SpanDetailsContent({
|
||||
traceEndTime?: number;
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const [bodyRef, { width: bodyWidth }] = useMeasure<HTMLDivElement>();
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
|
||||
const handleTabChange = useCallback(
|
||||
@@ -101,8 +89,6 @@ function SpanDetailsContent({
|
||||
},
|
||||
[logTraceEvent, selectedSpan.span_id],
|
||||
);
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
// One-time conversion of any V2-format value still living in the
|
||||
// `span_details_pinned_attributes` user pref into V3 nested-path format.
|
||||
@@ -281,113 +267,20 @@ function SpanDetailsContent({
|
||||
// .map((key) => ({ key, value: allAttrs[key] }));
|
||||
// }, [selectedSpan]);
|
||||
|
||||
// Width-driven: when the panel is wide, the summary moves inside the Overview
|
||||
// tab; when narrow it stays above the tabs.
|
||||
const isWide = bodyWidth >= WIDE_PANEL_BREAKPOINT;
|
||||
const summary = (
|
||||
<SpanSummary
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detailsSection}>
|
||||
<div className={styles.spanRow}>
|
||||
<KeyValueLabel
|
||||
badgeKey="Span name"
|
||||
badgeValue={selectedSpan.name}
|
||||
maxCharacters={50}
|
||||
/>
|
||||
<SpanPercentileBadge
|
||||
loading={percentile.loading}
|
||||
percentileValue={percentile.percentileValue}
|
||||
duration={percentile.duration}
|
||||
spanPercentileData={percentile.spanPercentileData}
|
||||
isOpen={percentile.isOpen}
|
||||
toggleOpen={percentile.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
|
||||
|
||||
{/* Span info: exec time + start time */}
|
||||
<div className={styles.spanInfo}>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<Timer size={14} />
|
||||
<span>
|
||||
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
|
||||
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
|
||||
<>
|
||||
{' — '}
|
||||
<strong>
|
||||
{(
|
||||
(selectedSpan.duration_nano * 100) /
|
||||
((traceEndTime - traceStartTime) * 1e6)
|
||||
).toFixed(2)}
|
||||
%
|
||||
</strong>
|
||||
{' of total exec time'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<CalendarClock size={14} />
|
||||
<span>
|
||||
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<Link2 size={14} />
|
||||
<LinkedSpansToggle
|
||||
count={linkedSpans.count}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
toggleOpen={linkedSpans.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkedSpansPanel
|
||||
linkedSpans={linkedSpans.linkedSpans}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
/>
|
||||
|
||||
{/* Step 6: HighlightedOptions */}
|
||||
<div className={styles.highlightedOptions}>
|
||||
{HIGHLIGHTED_OPTIONS.map((option) => {
|
||||
const rendered = option.render(selectedSpan);
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<KeyValueLabel
|
||||
key={option.key}
|
||||
badgeKey={option.label}
|
||||
badgeValue={rendered}
|
||||
direction="column"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step 7: KeyAttributes — commented out, pinning in PrettyView covers this.
|
||||
{keyAttributes.length > 0 && (
|
||||
<div className="span-details-panel__key-attributes">
|
||||
<div className="span-details-panel__key-attributes-label">
|
||||
KEY ATTRIBUTES
|
||||
</div>
|
||||
<div className="span-details-panel__key-attributes-chips">
|
||||
{keyAttributes.map(({ key, value }) => (
|
||||
<ActionMenu
|
||||
key={key}
|
||||
items={buildKeyAttrMenu(key, value)}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<div>
|
||||
<KeyValueLabel badgeKey={key} badgeValue={value} />
|
||||
</div>
|
||||
</ActionMenu>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* Step 8: MiniTraceContext */}
|
||||
</div>
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
@@ -411,6 +304,7 @@ function SpanDetailsContent({
|
||||
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
@@ -535,7 +429,7 @@ function SpanDetailsPanel({
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.panelBody}>
|
||||
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
|
||||
</div>
|
||||
)}
|
||||
@@ -546,7 +440,7 @@ function SpanDetailsPanel({
|
||||
variant === SpanDetailVariant.DOCKED ||
|
||||
variant === SpanDetailVariant.DOCKED_RIGHT
|
||||
) {
|
||||
return <div className={styles.root}>{content}</div>;
|
||||
return <div className={styles.panel}>{content}</div>;
|
||||
}
|
||||
|
||||
if (variant === SpanDetailVariant.DRAWER) {
|
||||
@@ -554,7 +448,7 @@ function SpanDetailsPanel({
|
||||
<DetailsPanelDrawer
|
||||
isOpen={panelState.isOpen}
|
||||
onClose={panelState.close}
|
||||
className={styles.root}
|
||||
className={styles.panel}
|
||||
>
|
||||
{content}
|
||||
</DetailsPanelDrawer>
|
||||
@@ -564,7 +458,7 @@ function SpanDetailsPanel({
|
||||
return (
|
||||
<FloatingPanel
|
||||
isOpen={panelState.isOpen}
|
||||
className={styles.root}
|
||||
className={styles.panel}
|
||||
width={PANEL_WIDTH}
|
||||
minWidth={480}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
backdrop-filter: blur(20px);
|
||||
margin: 8px 16px;
|
||||
margin: 8px 0 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -16,7 +17,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -162,20 +163,20 @@
|
||||
|
||||
.resourceSelector {
|
||||
overflow: hidden;
|
||||
width: calc(100% + 16px);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: -8px;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.resourceSelectorHeader {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.resourceSelectorInput {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
.spanRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.spanInfo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.spanInfoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.highlightedOptions {
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
|
||||
// Constrain KeyValueLabel inside the grid so values can ellipsize cleanly.
|
||||
:global(.key-value-label) {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.serviceDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Badges stay fit-content so short values shrink and long ones truncate.
|
||||
.serviceBadge,
|
||||
.statusMessageBadge {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Truncating text inside a badge (service name, status message).
|
||||
.badgeEllipsisText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { CalendarClock, Link2, Timer } from '@signozhq/icons';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
LinkedSpansToggle,
|
||||
useLinkedSpans,
|
||||
} from './LinkedSpans/LinkedSpans';
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
|
||||
import styles from './SpanSummary.module.scss';
|
||||
|
||||
interface SpanSummaryProps {
|
||||
selectedSpan: SpanV3;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
}
|
||||
|
||||
// Summary block shown above (narrow) / beside (wide) the tabs: span name +
|
||||
// percentile, exec time / timestamp / linked spans, and the highlighted options.
|
||||
function SpanSummary({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.spanRow}>
|
||||
<KeyValueLabel
|
||||
badgeKey="Span name"
|
||||
badgeValue={selectedSpan.name}
|
||||
maxCharacters={50}
|
||||
/>
|
||||
<SpanPercentileBadge
|
||||
loading={percentile.loading}
|
||||
percentileValue={percentile.percentileValue}
|
||||
duration={percentile.duration}
|
||||
spanPercentileData={percentile.spanPercentileData}
|
||||
isOpen={percentile.isOpen}
|
||||
toggleOpen={percentile.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
|
||||
|
||||
<div className={styles.spanInfo}>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<Timer size={14} />
|
||||
<span>
|
||||
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
|
||||
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
|
||||
<>
|
||||
{' — '}
|
||||
<strong>
|
||||
{(
|
||||
(selectedSpan.duration_nano * 100) /
|
||||
((traceEndTime - traceStartTime) * 1e6)
|
||||
).toFixed(2)}
|
||||
%
|
||||
</strong>
|
||||
{' of total exec time'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<CalendarClock size={14} />
|
||||
<span>
|
||||
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<Link2 size={14} />
|
||||
<LinkedSpansToggle
|
||||
count={linkedSpans.count}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
toggleOpen={linkedSpans.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkedSpansPanel
|
||||
linkedSpans={linkedSpans.linkedSpans}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
/>
|
||||
|
||||
<div className={styles.highlightedOptions}>
|
||||
{HIGHLIGHTED_OPTIONS.map((option) => {
|
||||
const rendered = option.render(selectedSpan);
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<KeyValueLabel
|
||||
key={option.key}
|
||||
badgeKey={option.label}
|
||||
badgeValue={rendered}
|
||||
direction="column"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanSummary;
|
||||
@@ -0,0 +1,18 @@
|
||||
.traceId {
|
||||
color: var(--accent-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.traceIdCopy {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
import styles from './TraceIdField.module.scss';
|
||||
|
||||
interface TraceIdFieldProps {
|
||||
span: SpanV3;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import ExpandableValue from 'periscope/components/ExpandableValue';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
import styles from './SpanSummary.module.scss';
|
||||
import { TraceIdField } from './TraceIdField';
|
||||
|
||||
interface HighlightedOption {
|
||||
@@ -18,9 +18,11 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
label: 'SERVICE',
|
||||
render: (span): ReactNode | null =>
|
||||
span['service.name'] ? (
|
||||
<Badge color="vanilla">
|
||||
<Badge color="vanilla" className={styles.serviceBadge}>
|
||||
<span className={styles.serviceDot} />
|
||||
{span['service.name']}
|
||||
<span className={styles.badgeEllipsisText} title={span['service.name']}>
|
||||
{span['service.name']}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null,
|
||||
},
|
||||
@@ -50,12 +52,8 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
render: (span): ReactNode | null =>
|
||||
span.status_message ? (
|
||||
<ExpandableValue value={span.status_message} title="Status message">
|
||||
<Badge
|
||||
color="vanilla"
|
||||
textEllipsis="end"
|
||||
className={styles.statusMessageBadge}
|
||||
>
|
||||
{span.status_message}
|
||||
<Badge color="vanilla" className={styles.statusMessageBadge}>
|
||||
<span className={styles.badgeEllipsisText}>{span.status_message}</span>
|
||||
</Badge>
|
||||
</ExpandableValue>
|
||||
) : null,
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
.rightDock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
min-width: 0;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Shared Ant Collapse chrome reset for both the flamegraph and waterfall
|
||||
|
||||
Reference in New Issue
Block a user