mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-30 11:50:43 +01:00
Compare commits
1 Commits
feat/code-
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe6182efca |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -138,7 +138,7 @@ go.mod @therealpandey
|
||||
/tests/integration/ @therealpandey
|
||||
|
||||
# e2e tests
|
||||
/tests/e2e/ @SigNoz/frontend-maintainers
|
||||
/tests/e2e/ @AshwinBhatkal
|
||||
|
||||
# Flagger Owners
|
||||
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
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;
|
||||
@@ -1,240 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,362 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -32,10 +32,13 @@ export function useRoles(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role.id ?? '',
|
||||
value: role[valueField] ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -82,6 +85,7 @@ interface BaseProps {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
disabled?: boolean;
|
||||
valueField?: 'id' | 'name';
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
@@ -113,7 +117,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles);
|
||||
const options = getRoleOptions(roles, props.valueField || 'id');
|
||||
|
||||
const {
|
||||
mode,
|
||||
|
||||
@@ -48,48 +48,6 @@ describe('getAutoContexts', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the query in alert edit context', () => {
|
||||
const ruleId = 'rule-edit';
|
||||
const query = { queryType: 'builder', builder: { queryData: [] } };
|
||||
const compositeQuery = encodeURIComponent(JSON.stringify(query));
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: {
|
||||
page: 'alert_edit',
|
||||
ruleId,
|
||||
query,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the query in alert new context (no ruleId)', () => {
|
||||
const query = { queryType: 'builder', builder: { queryData: [] } };
|
||||
const compositeQuery = encodeURIComponent(JSON.stringify(query));
|
||||
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'alert_new',
|
||||
query,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns triggered alerts context on alert history without ruleId', () => {
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');
|
||||
|
||||
|
||||
@@ -124,9 +124,7 @@ export function getAutoContexts(
|
||||
}
|
||||
}
|
||||
|
||||
// Alert edit — `/alerts/edit?ruleId=…`. The form syncs its query-builder
|
||||
// state to the URL (`useShareBuilderUrl`), so shared metadata carries the
|
||||
// alert's query + time range, mirroring the dashboard panel editor.
|
||||
// Alert edit — `/alerts/edit?ruleId=…`.
|
||||
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
|
||||
const ruleId = params.get(QueryParams.ruleId);
|
||||
if (ruleId) {
|
||||
@@ -135,21 +133,19 @@ export function getAutoContexts(
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: { page: 'alert_edit', ruleId, ...sharedMetadata },
|
||||
metadata: { page: 'alert_edit', ruleId },
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Alert new — `/alerts/new`. No rule id yet (draft), but the query-builder
|
||||
// state is on the URL, so shared metadata carries the in-progress query.
|
||||
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alert_new', ...sharedMetadata },
|
||||
metadata: { page: 'alert_new' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -26,12 +26,7 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
|
||||
describe('Should check if the edit alert channel is properly displayed', () => {
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={editAlertChannelInitialValue}
|
||||
/>,
|
||||
);
|
||||
render(<EditAlertChannels initialValue={editAlertChannelInitialValue} />);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import EditAlertChannels from 'container/EditAlertChannels';
|
||||
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: { success: jest.fn(), error: jest.fn() },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
|
||||
}));
|
||||
|
||||
interface EditRequest {
|
||||
id: string;
|
||||
body: { name: string; slack_configs: { send_resolved: boolean }[] };
|
||||
}
|
||||
|
||||
// Captures the PUT /channels/:id request the edit form fires, so assertions can
|
||||
// run against the real HTTP payload instead of a hand-mocked api client.
|
||||
function mockEditChannel(): { calls: EditRequest[] } {
|
||||
const result: { calls: EditRequest[] } = { calls: [] };
|
||||
server.use(
|
||||
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
|
||||
result.calls.push({
|
||||
id: req.params.id as string,
|
||||
body: await req.json(),
|
||||
});
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: 'channel updated' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('EditAlertChannels save', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('sends the channelId in the edit request (regression: empty id)', async () => {
|
||||
const edit = mockEditChannel();
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={editAlertChannelInitialValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() => expect(edit.calls).toHaveLength(1));
|
||||
expect(edit.calls[0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('persists send_resolved toggle in the edit request', async () => {
|
||||
const edit = mockEditChannel();
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={editAlertChannelInitialValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
|
||||
expect(sendResolved).toBeChecked();
|
||||
|
||||
await user.click(sendResolved);
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() => expect(edit.calls).toHaveLength(1));
|
||||
expect(edit.calls[0].id).toBe('3');
|
||||
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,6 @@ import APIError from 'types/api/error';
|
||||
|
||||
function EditAlertChannels({
|
||||
initialValue,
|
||||
channelId: id,
|
||||
}: EditAlertChannelsProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -54,6 +53,11 @@ function EditAlertChannels({
|
||||
const [testingState, setTestingState] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
// Extract channelId from URL pathname since useParams doesn't work in nested routing
|
||||
const { pathname } = window.location;
|
||||
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
|
||||
const id = channelIdMatch ? channelIdMatch[1] : '';
|
||||
|
||||
const [type, setType] = useState<ChannelType>(
|
||||
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
|
||||
);
|
||||
@@ -516,7 +520,6 @@ interface EditAlertChannelsProps {
|
||||
initialValue: {
|
||||
[x: string]: unknown;
|
||||
};
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export default EditAlertChannels;
|
||||
|
||||
@@ -136,7 +136,6 @@ function FormAlertChannels({
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
data-testid="save-channel-button"
|
||||
disabled={savingState}
|
||||
loading={savingState}
|
||||
type="primary"
|
||||
@@ -145,7 +144,6 @@ function FormAlertChannels({
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="test-channel-button"
|
||||
disabled={testingState}
|
||||
loading={testingState}
|
||||
onClick={(): void => onTestHandler(type)}
|
||||
@@ -153,7 +151,6 @@ function FormAlertChannels({
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="return-button"
|
||||
onClick={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
|
||||
function LLMObservabilityModelPricing(): JSX.Element {
|
||||
@@ -12,34 +8,12 @@ function LLMObservabilityModelPricing(): JSX.Element {
|
||||
>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<Typography.Text as="h1" size="large" weight="semibold">
|
||||
Configuration
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
<h1 className={styles.title}>Configuration</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Model pricing and cost estimation settings
|
||||
</Typography.Text>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
key: 'model-costs',
|
||||
label: 'Model costs',
|
||||
children: <ModelCostTabPanel />,
|
||||
},
|
||||
{
|
||||
// Unpriced-models tab lands in a later PR.
|
||||
key: 'unpriced-models',
|
||||
label: 'Unpriced models',
|
||||
disabled: true,
|
||||
children: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.pageError {
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border-radius: var(--radius-2);
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
|
||||
color: var(--text-cherry-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTableParams } from 'components/TanStackTableView';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
|
||||
import styles from './ModelCostTabPanel.module.scss';
|
||||
import ModelCostsTable from './components/ModelCostsTable';
|
||||
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
function ModelCostTabPanel(): JSX.Element {
|
||||
const { page, limit } = useTableParams(
|
||||
{ page: PAGE_KEY, limit: LIMIT_KEY },
|
||||
{ page: 1, limit: PAGE_SIZE },
|
||||
);
|
||||
|
||||
// Search + source filters are intentionally omitted for now — the list API
|
||||
// doesn't honour them yet. They'll be reintroduced here once it does.
|
||||
const listParams: ListLLMPricingRulesParams = {
|
||||
offset: (page - 1) * limit,
|
||||
limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
|
||||
|
||||
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
|
||||
() => data?.data?.items || [],
|
||||
[data],
|
||||
);
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load pricing rules. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
|
||||
<ModelCostsTable
|
||||
rules={rules}
|
||||
isLoading={isLoading}
|
||||
total={total}
|
||||
selectedRuleId={null}
|
||||
canManage={false}
|
||||
onEdit={(): void => undefined}
|
||||
onDelete={(): void => undefined}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<Typography.Text color="muted" size="small">
|
||||
All prices per 1M tokens (USD)
|
||||
</Typography.Text>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostTabPanel;
|
||||
@@ -1,8 +0,0 @@
|
||||
.actionButton {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './ModelCostActionsMenu.module.scss';
|
||||
|
||||
interface ModelCostActionsMenuProps {
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO;
|
||||
canManage: boolean;
|
||||
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
}
|
||||
|
||||
// Per-row kebab menu for the model-costs table. Only manage users get actions
|
||||
// (Edit + Delete); view-only users have nothing to act on, so the cell stays
|
||||
// empty rather than showing a single-item menu.
|
||||
function ModelCostActionsMenu({
|
||||
rule,
|
||||
canManage,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ModelCostActionsMenuProps): JSX.Element | null {
|
||||
const menuItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
onClick: (): void => onEdit(rule),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
onClick: (): void => onDelete(rule),
|
||||
},
|
||||
],
|
||||
[onEdit, onDelete, rule],
|
||||
);
|
||||
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.actionButton}
|
||||
testId={`model-cost-actions-${rule.id}`}
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostActionsMenu;
|
||||
@@ -1,20 +0,0 @@
|
||||
.modelCostsTable {
|
||||
margin-top: var(--spacing-8);
|
||||
--tanstack-table-row-height: 48px;
|
||||
height: calc(100vh - 250px);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(table) tbody tr {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.modelCostsEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: var(--spacing-8);
|
||||
min-height: 400px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import {
|
||||
LIMIT_KEY,
|
||||
PAGE_KEY,
|
||||
PAGE_SIZE,
|
||||
SKELETON_ROW_COUNT,
|
||||
} from '../../../constants';
|
||||
import styles from './ModelCostsTable.module.scss';
|
||||
import { getModelCostsColumns } from './TableConfig';
|
||||
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface ModelCostsTableProps {
|
||||
rules: LlmpricingruletypesLLMPricingRuleDTO[];
|
||||
isLoading: boolean;
|
||||
total: number;
|
||||
selectedRuleId: string | null;
|
||||
canManage: boolean;
|
||||
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
}
|
||||
|
||||
// The table owns its own pagination URL state (page/limit) via enableQueryParams;
|
||||
// ModelCostsTab reads the same keys to build the list request. Virtual scroll is
|
||||
// disabled: a plain table renders fine at our page sizes (up to 100 rows) and the
|
||||
// fixed-height scroll viewport (.modelCostsTable) keeps large pages scrolling
|
||||
// inside the table.
|
||||
function ModelCostsTable({
|
||||
rules,
|
||||
isLoading,
|
||||
total,
|
||||
selectedRuleId,
|
||||
canManage,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ModelCostsTableProps): JSX.Element {
|
||||
const columns = useMemo(
|
||||
() => getModelCostsColumns({ canManage, onEdit, onDelete }),
|
||||
[canManage, onEdit, onDelete],
|
||||
);
|
||||
|
||||
if (!isLoading && rules.length === 0) {
|
||||
return (
|
||||
<div className={styles.modelCostsEmpty} data-testid="model-costs-empty">
|
||||
No model costs yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable<LlmpricingruletypesLLMPricingRuleDTO>
|
||||
className={styles.modelCostsTable}
|
||||
data={rules}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
skeletonRowCount={SKELETON_ROW_COUNT}
|
||||
getRowKey={(row): string => row.id}
|
||||
isRowActive={(row): boolean => row.id === selectedRuleId}
|
||||
disableVirtualScroll
|
||||
testId="model-costs-table"
|
||||
enableQueryParams={{ page: PAGE_KEY, limit: LIMIT_KEY }}
|
||||
pagination={{
|
||||
total,
|
||||
defaultLimit: PAGE_SIZE,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: 'models',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostsTable;
|
||||
@@ -1 +0,0 @@
|
||||
export { getModelCostsColumns } from './table.config';
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { startCase } from 'lodash-es';
|
||||
|
||||
import styles from './tableConfig.module.scss';
|
||||
import ModelCostActionsMenu from '../ModelCostActionsMenu';
|
||||
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
} from '../../../../utils';
|
||||
|
||||
interface ColumnsConfig {
|
||||
canManage: boolean;
|
||||
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
}
|
||||
|
||||
// Column definitions for the model-costs TanStackTable. Sorting is intentionally
|
||||
// off across the board — the list API only accepts offset/limit, so there's no
|
||||
// server-side ordering to back a sortable header yet.
|
||||
export function getModelCostsColumns({
|
||||
canManage,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ColumnsConfig): TableColumnDef<LlmpricingruletypesLLMPricingRuleDTO>[] {
|
||||
return [
|
||||
{
|
||||
id: 'model',
|
||||
header: 'Model',
|
||||
accessorFn: (row): string => row.modelName ?? '',
|
||||
// Flexes to absorb spare width alongside Extra buckets so the row fills
|
||||
// the container instead of leaving a gap on the right.
|
||||
width: { min: 240, default: '100%' },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<div className={styles.modelCell}>
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
truncate={1}
|
||||
testId={`model-cell-name-${row.id}`}
|
||||
>
|
||||
{row.modelName}
|
||||
</Typography.Text>
|
||||
|
||||
<Typography.Text truncate={1}>{getCanonicalId(row)}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
header: 'Provider',
|
||||
accessorKey: 'provider',
|
||||
width: { min: 140 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): string => row.provider ?? '',
|
||||
},
|
||||
{
|
||||
id: 'input',
|
||||
header: 'Input / 1M',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text>
|
||||
{formatPricePerMillion(row.pricing?.input)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
header: 'Output / 1M',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text>
|
||||
{formatPricePerMillion(row.pricing?.output)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'extraBuckets',
|
||||
header: 'Extra buckets',
|
||||
width: { min: 200, default: '100%' },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => {
|
||||
const buckets = getExtraBuckets(row);
|
||||
if (buckets.length === 0) {
|
||||
return (
|
||||
<Typography.Text color="muted" as="span">
|
||||
—
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.extraBuckets}>
|
||||
{buckets.map((bucket) => (
|
||||
<Badge
|
||||
key={bucket.key}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className={styles.extraBucketsChip}
|
||||
>
|
||||
<Typography.Text as="span" size="small">
|
||||
{startCase(bucket.key)}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" size="small" weight="semibold">
|
||||
{formatPricePerMillion(bucket.pricePerMillion)}
|
||||
</Typography.Text>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'Source',
|
||||
width: { min: 130 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Badge
|
||||
color={row.isOverride ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
className={styles.sourceBadge}
|
||||
data-testid={`source-badge-${row.id}`}
|
||||
>
|
||||
{getSourceLabel(row)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lastSeen',
|
||||
header: 'Last seen',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): string => getRelativeLastSeen(row),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
width: { fixed: '56px', ignoreLastColumnFill: true },
|
||||
pin: 'right',
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element | null => (
|
||||
<ModelCostActionsMenu
|
||||
rule={row}
|
||||
canManage={canManage}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
.modelCell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extraBuckets {
|
||||
display: flex;
|
||||
// Keep chips on a single line so the row stays at the table's fixed row
|
||||
// height; the column flexes to 100% so there's room for both.
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.extraBucketsChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './ModelCostsTable';
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './ModelCostTabPanel';
|
||||
@@ -1,6 +0,0 @@
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
export const PAGE_KEY = 'page';
|
||||
export const LIMIT_KEY = 'limit';
|
||||
|
||||
export const SKELETON_ROW_COUNT = PAGE_SIZE;
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ExtraBucket {
|
||||
key: string;
|
||||
pricePerMillion: number;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import type { ExtraBucket } from './types';
|
||||
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const getRelativeTime = (
|
||||
timestamp: string | number | Date | null | undefined,
|
||||
): string => {
|
||||
const parsed = timestamp != null ? dayjs(timestamp) : null;
|
||||
return parsed?.isValid() ? parsed.fromNow() : '—';
|
||||
};
|
||||
|
||||
// ─── Display helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return '—';
|
||||
}
|
||||
// 2dp is enough for per-1M pricing. we can update this later we models have sub-cent pricing.
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const getExtraBuckets = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): ExtraBucket[] => {
|
||||
const cache = rule.pricing?.cache;
|
||||
if (!cache) {
|
||||
return [];
|
||||
}
|
||||
const buckets: ExtraBucket[] = [];
|
||||
if (typeof cache.read === 'number' && cache.read > 0) {
|
||||
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
|
||||
}
|
||||
if (typeof cache.write === 'number' && cache.write > 0) {
|
||||
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
export const getSourceLabel = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
|
||||
|
||||
export const getRelativeLastSeen = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
|
||||
|
||||
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
|
||||
// are lower-cased so the id is consistently normalised (providers/models can
|
||||
// arrive with mixed casing).
|
||||
export const getCanonicalId = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): string => {
|
||||
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
|
||||
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
|
||||
return `${provider}:${model}`;
|
||||
};
|
||||
@@ -79,7 +79,7 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
input,
|
||||
input:not(.ant-select-selection-search-input),
|
||||
textarea {
|
||||
height: 32px;
|
||||
background: var(--l2-background) !important;
|
||||
|
||||
@@ -111,31 +111,9 @@
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
&.ant-select {
|
||||
.ant-select-selector {
|
||||
height: 32px;
|
||||
background: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l2-foreground) !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--l2-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
.ant-select-selection-search {
|
||||
inset-inline-start: var(--padding-2) !important;
|
||||
inset-inline-end: var(--padding-2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +163,7 @@
|
||||
|
||||
&--role {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +250,7 @@
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input {
|
||||
input:not(.ant-select-selection-search-input) {
|
||||
height: 32px;
|
||||
background: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
|
||||
@@ -11,23 +11,20 @@ import {
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Collapse, Form, Select, Tooltip } from 'antd';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
|
||||
|
||||
import './RoleMappingSection.styles.scss';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'VIEWER', label: 'VIEWER' },
|
||||
{ value: 'EDITOR', label: 'EDITOR' },
|
||||
{ value: 'ADMIN', label: 'ADMIN' },
|
||||
];
|
||||
|
||||
interface RoleMappingSectionProps {
|
||||
fieldNamePrefix: string[];
|
||||
isExpanded?: boolean;
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
const SIGNOZ_VIEWER_ROLE = 'signoz-viewer';
|
||||
|
||||
function RoleMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
@@ -38,6 +35,7 @@ function RoleMappingSection({
|
||||
[...fieldNamePrefix, 'useRoleAttribute'],
|
||||
form,
|
||||
);
|
||||
const { roles, isLoading, isError, error, refetch } = useRoles();
|
||||
|
||||
// Support both controlled and uncontrolled modes
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
@@ -108,19 +106,26 @@ function RoleMappingSection({
|
||||
<div className="role-mapping-section__field-group">
|
||||
<label className="role-mapping-section__label" htmlFor="default-role">
|
||||
Default Role
|
||||
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
|
||||
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "signoz-viewer"'>
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'defaultRole']}
|
||||
className="role-mapping-section__form-item"
|
||||
initialValue="VIEWER"
|
||||
initialValue={SIGNOZ_VIEWER_ROLE}
|
||||
>
|
||||
<Select
|
||||
<RolesSelect
|
||||
id="default-role"
|
||||
options={ROLE_OPTIONS}
|
||||
valueField="name"
|
||||
roles={roles}
|
||||
loading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={refetch}
|
||||
className="role-mapping-section__select"
|
||||
allowClear={false}
|
||||
getPopupContainer={(): HTMLElement => document.body}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
@@ -140,7 +145,7 @@ function RoleMappingSection({
|
||||
Use Role Attribute Directly
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
|
||||
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role name (e.g. signoz-viewer, signoz-editor, signoz-admin, or a custom role).">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -174,11 +179,17 @@ function RoleMappingSection({
|
||||
name={[field.name, 'role']}
|
||||
className="role-mapping-section__field role-mapping-section__field--role"
|
||||
rules={[{ required: true, message: 'Role is required' }]}
|
||||
initialValue="VIEWER"
|
||||
initialValue={SIGNOZ_VIEWER_ROLE}
|
||||
>
|
||||
<Select
|
||||
options={ROLE_OPTIONS}
|
||||
className="role-mapping-section__select"
|
||||
<RolesSelect
|
||||
valueField="name"
|
||||
roles={roles}
|
||||
loading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={refetch}
|
||||
allowClear={false}
|
||||
getPopupContainer={(): HTMLElement => document.body}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -197,7 +208,9 @@ function RoleMappingSection({
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
|
||||
onClick={(): void =>
|
||||
add({ groupName: '', role: SIGNOZ_VIEWER_ROLE })
|
||||
}
|
||||
prefix={<Plus size={14} />}
|
||||
>
|
||||
Add Group Mapping
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
|
||||
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
|
||||
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
|
||||
// which mocks @signozhq/ui/switch for the same reason.
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
allRoles,
|
||||
listRolesSuccessResponse,
|
||||
managedRoles,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
|
||||
import CreateEdit from '../CreateEdit/CreateEdit';
|
||||
import {
|
||||
AUTH_DOMAINS_UPDATE_ENDPOINT,
|
||||
mockDomainWithDirectRoleAttribute,
|
||||
mockDomainWithRoleMapping,
|
||||
mockSamlAuthDomain,
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
|
||||
// The @signozhq/ui Button uses Radix Slot and has CSS infinite animations that
|
||||
// prevent form.validateFields() from resolving inside act(). Replacing with a
|
||||
// simple native button avoids the issue.
|
||||
jest.mock('@signozhq/ui/button', () => ({
|
||||
...jest.requireActual('@signozhq/ui/button'),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
'aria-label': ariaLabel,
|
||||
prefix,
|
||||
suffix,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{prefix}
|
||||
{children}
|
||||
{suffix}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// These are heavy real-timer integration tests (antd Select dropdown render +
|
||||
// form.validateFields() + a react-query mutation, all driven through userEvent).
|
||||
// Under a CPU-saturated parallel `jest` run the wall-clock roughly triples, which
|
||||
// pushes the longest tests past the 5000ms default and makes them flaky. Give the
|
||||
// whole file a wider budget (matches LogsPanelComponent.test.tsx).
|
||||
jest.setTimeout(20000);
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
type User = ReturnType<typeof userEvent.setup>;
|
||||
|
||||
// antd renders pointer-events:none on parts of its Select, so disable the
|
||||
// userEvent pointer-events guard (mirrors CreateEdit.test.tsx).
|
||||
const setupUser = (): User => userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
function getRole(name: string): (typeof managedRoles)[number] {
|
||||
const role = managedRoles.find((r) => r.name === name);
|
||||
if (!role) {
|
||||
throw new Error(`missing mock role: ${name}`);
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
const viewerRole = getRole('signoz-viewer');
|
||||
const editorRole = getRole('signoz-editor');
|
||||
|
||||
function mockRoles(
|
||||
response: Record<string, unknown> = listRolesSuccessResponse,
|
||||
status = 200,
|
||||
): { count: () => number } {
|
||||
let requested = 0;
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_req, res, ctx) => {
|
||||
requested += 1;
|
||||
return res(ctx.status(status), ctx.json(response));
|
||||
}),
|
||||
);
|
||||
return { count: (): number => requested };
|
||||
}
|
||||
|
||||
function captureUpdatePayload(): { get: () => any } {
|
||||
let payload: unknown = null;
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
return { get: (): any => payload };
|
||||
}
|
||||
|
||||
const expandRoleMapping = (user: User): Promise<void> =>
|
||||
user.click(screen.getByText(/role mapping \(advanced\)/i));
|
||||
|
||||
const openDefaultRoleSelect = (user: User): Promise<void> =>
|
||||
user.click(screen.getByLabelText(/default role/i));
|
||||
|
||||
const saveChanges = (user: User): Promise<void> =>
|
||||
user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
describe('CreateEdit — role mapping uses API roles', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('fetches the roles list from the API when the form mounts', async () => {
|
||||
const roles = mockRoles();
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithDirectRoleAttribute}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('renders the default-role options from the API (managed + custom), not the old hardcoded VIEWER/EDITOR/ADMIN', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles();
|
||||
|
||||
// mockSamlAuthDomain has no stored defaultRole, so nothing stale (e.g.
|
||||
// "VIEWER") is rendered as a selected tag to pollute the title lookups.
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
// Open the Select and wait for the async roles fetch to populate it.
|
||||
await openDefaultRoleSelect(user);
|
||||
await screen.findByTitle(allRoles[0].name);
|
||||
|
||||
// Every role returned by the API is offered as an option, including the
|
||||
// custom (non-managed) roles — the whole point of the refactor. Use
|
||||
// getAllByTitle: the preselected default role also renders its name on
|
||||
// the selection item, so a role may legitimately appear more than once.
|
||||
allRoles.forEach((role) => {
|
||||
expect(screen.getAllByTitle(role.name).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The old hardcoded uppercase role values must NOT appear as options.
|
||||
expect(screen.queryByTitle('VIEWER')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('EDITOR')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('ADMIN')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the selected role name (not the role id) as defaultRole', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles();
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithDirectRoleAttribute}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
await openDefaultRoleSelect(user);
|
||||
await user.click(await screen.findByTitle(editorRole.name));
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
// SSO role mapping matches roles by name, so the payload carries the
|
||||
// role *name*, not the opaque id.
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
|
||||
expect(payload.get().config.roleMapping.defaultRole).not.toBe(editorRole.id);
|
||||
});
|
||||
|
||||
it('defaults a fresh role mapping to the signoz-viewer role name', async () => {
|
||||
const user = setupUser();
|
||||
const roles = mockRoles();
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
// mockSamlAuthDomain has no roleMapping, so the defaultRole field falls
|
||||
// back to the Form.Item initialValue (viewerRole.name). That initialValue
|
||||
// is only applied when the field mounts, so the roles fetch MUST resolve
|
||||
// before the panel is expanded — otherwise viewerRole is still undefined.
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
|
||||
// Flush the react-query commit so `useRoles` exposes the loaded roles
|
||||
// before the collapse panel (and thus the default-role field) mounts.
|
||||
await screen.findByText(/edit saml authentication/i);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
await screen.findByText(/default role/i);
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
|
||||
expect(payload.get().config.roleMapping.defaultRole).not.toBe(viewerRole.id);
|
||||
});
|
||||
|
||||
it('still defaults to signoz-viewer when the roles fetch returns empty', async () => {
|
||||
const user = setupUser();
|
||||
// signoz-viewer is a managed role that always exists server-side, so even
|
||||
// a degenerate/empty roles response must not strip the hardcoded default.
|
||||
mockRoles({ status: 'success', data: [] });
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Section still renders without crashing even though the fetch was empty.
|
||||
await expandRoleMapping(user);
|
||||
await expect(screen.findByText(/default role/i)).resolves.toBeInTheDocument();
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
// The Form.Item initialValue (signoz-viewer) survives an empty roles list.
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
|
||||
});
|
||||
|
||||
it('loads a stored role mapping by role name and round-trips it on save', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles();
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
// mockDomainWithRoleMapping stores defaultRole "signoz-editor" plus three
|
||||
// group mappings, all keyed by role *name*. Editing must surface each
|
||||
// stored value as the matching option and submit it unchanged — the
|
||||
// backward-compatible read path for already-saved SSO domains.
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithRoleMapping}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
// The stored default role renders as a real selection, not a raw token.
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTitle(editorRole.name).length).toBeGreaterThan(0),
|
||||
);
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
|
||||
expect(payload.get().config.roleMapping.groupMappings).toStrictEqual({
|
||||
'admin-group': 'signoz-admin',
|
||||
'dev-team': 'signoz-editor',
|
||||
viewers: 'signoz-viewer',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error state in the default-role select when the roles request fails', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles(
|
||||
{ error: { code: 'internal_error', message: 'boom', url: '' } },
|
||||
500,
|
||||
);
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
// Open the select and confirm the error UI (with retry) is surfaced
|
||||
// instead of crashing the form. The error message comes straight from
|
||||
// the failed request; the Retry affordance is always present.
|
||||
await openDefaultRoleSelect(user);
|
||||
|
||||
await expect(screen.findByTitle('Retry')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('boom')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -186,9 +186,9 @@ describe('CreateEdit — payload sanitization', () => {
|
||||
|
||||
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
|
||||
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
'admin-group': 'signoz-admin',
|
||||
'dev-team': 'signoz-editor',
|
||||
viewers: 'signoz-viewer',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,12 +75,12 @@ export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'EDITOR',
|
||||
defaultRole: 'signoz-editor',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: {
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
'admin-group': 'signoz-admin',
|
||||
'dev-team': 'signoz-editor',
|
||||
viewers: 'signoz-viewer',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -103,7 +103,7 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
|
||||
clientSecret: 'direct-role-client-secret',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'VIEWER',
|
||||
defaultRole: 'signoz-viewer',
|
||||
useRoleAttribute: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -177,8 +177,7 @@ describe('Tooltip', () => {
|
||||
renderTooltip({ uPlotInstance, content });
|
||||
|
||||
const list = screen.getByTestId('uplot-tooltip-list');
|
||||
// Measured height (200) + the scroll viewport's vertical padding (16)
|
||||
expect(list).toHaveStyle({ height: '216px' });
|
||||
expect(list).toHaveStyle({ height: '200px' });
|
||||
});
|
||||
|
||||
it('sets tooltip list height based on content length when Virtuoso reports 0 height', () => {
|
||||
@@ -189,8 +188,8 @@ describe('Tooltip', () => {
|
||||
renderTooltip({ uPlotInstance, content });
|
||||
|
||||
const list = screen.getByTestId('uplot-tooltip-list');
|
||||
// Falls back to content length (2 * 38 = 76) + vertical padding (16) = 92px
|
||||
expect(list).toHaveStyle({ height: '92px' });
|
||||
// Falls back to content length: 2 items * 38px = 76px
|
||||
expect(list).toHaveStyle({ height: '76px' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,11 +13,6 @@ import Styles from './TooltipList.module.scss';
|
||||
// Fallback per-item height before Virtuoso reports the real total.
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
// Vertical padding (spacing-4 top + bottom) the SCSS applies to the scroll
|
||||
// viewport. Virtuoso's reported height covers only the items, so it must be
|
||||
// added back — otherwise the box is short by this amount, clipping the last
|
||||
// row and showing a scrollbar even when every row would fit.
|
||||
const LIST_VERTICAL_PADDING = 16;
|
||||
|
||||
interface TooltipListProps {
|
||||
id: string;
|
||||
@@ -35,13 +30,13 @@ export default function TooltipList({
|
||||
// Use the measured height from Virtuoso when available; fall back to a
|
||||
// per-item estimate on first render. Math.ceil prevents a 1 px
|
||||
// subpixel rounding gap from triggering a spurious scrollbar.
|
||||
const height = useMemo(() => {
|
||||
const contentHeight =
|
||||
totalListHeight > 0 ? totalListHeight : content.length * TOOLTIP_ITEM_HEIGHT;
|
||||
return Math.ceil(
|
||||
Math.min(contentHeight + LIST_VERTICAL_PADDING, LIST_MAX_HEIGHT),
|
||||
);
|
||||
}, [totalListHeight, content.length]);
|
||||
const height = useMemo(
|
||||
() =>
|
||||
totalListHeight > 0
|
||||
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
|
||||
: Math.min(content.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT),
|
||||
[totalListHeight, content.length],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!isScrollEventTriggered.current) {
|
||||
|
||||
@@ -176,15 +176,6 @@ 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),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import get from 'api/channels/get';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
@@ -25,10 +24,10 @@ import './ChannelsEdit.styles.scss';
|
||||
function ChannelsEdit(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const channelId = matchPath<{ channelId: string }>(pathname, {
|
||||
path: ROUTES.CHANNELS_EDIT,
|
||||
})?.params?.channelId;
|
||||
// Extract channelId from URL pathname
|
||||
const { pathname } = window.location;
|
||||
const channelIdMatch = pathname.match(/\/alerts\/channels\/edit\/([^/]+)/);
|
||||
const channelId = channelIdMatch ? channelIdMatch[1] : undefined;
|
||||
|
||||
const { isFetching, isError, data, error } = useQuery<
|
||||
SuccessResponseV2<Channels>,
|
||||
@@ -148,7 +147,6 @@ function ChannelsEdit(): JSX.Element {
|
||||
<div className="edit-alert-channels-container">
|
||||
<EditAlertChannels
|
||||
{...{
|
||||
channelId: channelId || '',
|
||||
initialValue: {
|
||||
...target.channel,
|
||||
type: target.type,
|
||||
|
||||
@@ -130,7 +130,6 @@ func Error(rw http.ResponseWriter, cause error) {
|
||||
rw.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(d.Seconds()))))
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.WriteHeader(httpCode)
|
||||
_, _ = rw.Write(body)
|
||||
}
|
||||
|
||||
@@ -221,8 +221,8 @@ func (role *PostableRole) UnmarshalJSON(data []byte) error {
|
||||
|
||||
func (role *UpdatableRole) UnmarshalJSON(data []byte) error {
|
||||
shadow := struct {
|
||||
Description *string `json:"description"`
|
||||
TransactionGroups *json.RawMessage `json:"transactionGroups"`
|
||||
Description *string `json:"description"`
|
||||
TransactionGroups TransactionGroups `json:"transactionGroups"`
|
||||
}{}
|
||||
|
||||
if err := json.Unmarshal(data, &shadow); err != nil {
|
||||
@@ -237,13 +237,8 @@ func (role *UpdatableRole) UnmarshalJSON(data []byte) error {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "transactionGroups is required").WithAdditional("send an empty array to clear the role's transaction groups")
|
||||
}
|
||||
|
||||
transactionGroups, err := NewTransactionGroups(*shadow.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
role.Description = *shadow.Description
|
||||
role.TransactionGroups = transactionGroups
|
||||
role.TransactionGroups = shadow.TransactionGroups
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user