Compare commits

..

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
82149e61e3 feat(meter): migrate to new uplot API & use bar stacked chart 2026-06-29 21:36:07 -03:00
42 changed files with 260 additions and 2493 deletions

2
.github/CODEOWNERS vendored
View File

@@ -138,7 +138,7 @@ go.mod @therealpandey
/tests/integration/ @therealpandey
# e2e tests
/tests/e2e/ @SigNoz/frontend-maintainers
/tests/e2e/ @AshwinBhatkal
# Flagger Owners

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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');
});
});
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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 };
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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, '');

View File

@@ -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' },
},
];
}

View File

@@ -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();

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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);
}}

View File

@@ -13,9 +13,6 @@
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.title {
margin: 0;
font-size: var(--font-size-xl);

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -1,8 +0,0 @@
.actionButton {
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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}
/>
),
},
];
}

View File

@@ -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;
}

View File

@@ -1 +0,0 @@
export { default } from './ModelCostTabPanel';

View File

@@ -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;

View File

@@ -1,4 +0,0 @@
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}

View File

@@ -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}`;
};

View File

@@ -99,7 +99,8 @@
);
gap: 16px;
width: 100%;
height: fit-content;
height: 50vh;
min-height: 350px;
}
}
}

View File

@@ -1,27 +1,48 @@
import { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueries } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { isAxiosError } from 'axios';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { prepareBarPanelConfig } from 'container/DashboardContainer/visualization/panels/BarPanel/utils';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { MetricsLoading } from 'container/MetricsExplorer/MetricsLoading/MetricsLoading';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlQuery from 'hooks/useUrlQuery';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
const WIDGET_ID = 'meter-explorer-bar-chart';
interface TimeSeriesProps {
onFetchingStateChange?: (isFetching: boolean) => void;
@@ -32,9 +53,21 @@ function TimeSeries({
onFetchingStateChange,
isCancelled = false,
}: TimeSeriesProps): JSX.Element {
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
const location = useLocation();
const graphRef = useRef<HTMLDivElement>(null);
const { stagedQuery, currentQuery } = useQueryBuilder();
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const containerDimensions = useResizeObserver(graphRef);
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const {
selectedTime: globalSelectedTime,
maxTime,
@@ -128,48 +161,187 @@ function TimeSeries({
onFetchingStateChange?.(isFetching);
}, [isFetching, onFetchingStateChange]);
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
const responseData = useMemo(
() =>
data.map((datapoint) =>
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
),
[data, isValidToConvertToMs],
);
const responseData = useMemo(() => {
const data = queries.map(({ data }) => data) ?? [];
return data.map((datapoint) =>
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
);
}, [queries, isValidToConvertToMs]);
const hasMetricSelected = useMemo(
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
[currentQuery],
);
useEffect((): void => {
const { startTime, endTime } = getTimeRange();
setMinTimeScale(startTime);
setMaxTimeScale(endTime);
}, [maxTime, minTime, globalSelectedTime, responseData]);
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
const { maxTime, minTime } = GetMinMax('custom', [
startTimestamp,
endTimestamp,
]);
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
urlQuery.delete(QueryParams.relativeTime);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
},
[dispatch, location.pathname, urlQuery],
);
const handleBackNavigation = useCallback((): void => {
const searchParams = new URLSearchParams(window.location.search);
const startTime = searchParams.get(QueryParams.startTime);
const endTime = searchParams.get(QueryParams.endTime);
const relativeTime = searchParams.get(
QueryParams.relativeTime,
) as CustomTimeType;
if (relativeTime) {
dispatch(UpdateTimeInterval(relativeTime));
} else if (startTime && endTime && startTime !== endTime) {
dispatch(
UpdateTimeInterval('custom', [
parseInt(getTimeString(startTime), 10),
parseInt(getTimeString(endTime), 10),
]),
);
}
}, [dispatch]);
useEffect(() => {
window.addEventListener('popstate', handleBackNavigation);
return (): void => {
window.removeEventListener('popstate', handleBackNavigation);
};
}, [handleBackNavigation]);
const handleChartClick = useCallback((): void => {
// noop for explorer view
}, []);
const chartsData = useMemo(() => {
return responseData.map((response, index) => {
const apiResponse = response?.payload;
const widget: Widgets = {
id: `${WIDGET_ID}-${index}`,
panelTypes: PANEL_TYPES.BAR,
title: '',
description: '',
opacity: '1',
nullZeroValues: 'zero',
timePreferance: 'GLOBAL_TIME',
selectedLogFields: null,
selectedTracesFields: null,
query: currentQuery,
yAxisUnit: yAxisUnit || 'short',
stackedBarChart: true,
thresholds: [],
softMin: null,
softMax: null,
isLogScale: false,
customLegendColors: {},
legendPosition: LegendPosition.BOTTOM,
};
const config = prepareBarPanelConfig({
widget,
isDarkMode,
currentQuery,
onClick: handleChartClick,
onDragSelect,
apiResponse,
timezone,
panelMode: PanelMode.DASHBOARD_EDIT, // Use DASHBOARD_EDIT to avoid localStorage visibility preferences
minTimeScale,
maxTimeScale,
});
const chartData = apiResponse ? prepareChartData(apiResponse) : [];
return {
config,
chartData,
hasData: chartData.length > 0 && chartData[0]?.length > 0,
};
});
}, [
responseData,
currentQuery,
yAxisUnit,
isDarkMode,
handleChartClick,
onDragSelect,
timezone,
minTimeScale,
maxTimeScale,
]);
const isLoading = queries.some((q) => q.isLoading);
const isError = queries.some((q) => q.isError);
const hasAnyData = chartsData.some((chart) => chart.hasData);
return (
<div className="meter-time-series-container">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
<div className="time-series-container">
<div className="time-series-container" ref={graphRef}>
{!hasMetricSelected && <EmptyMetricsSearch />}
{isCancelled && hasMetricSelected && (
<QueryCancelledPlaceholder subText='Click "Run Query" to load metrics.' />
)}
{isLoading && hasMetricSelected && !isCancelled && <MetricsLoading />}
{!isCancelled &&
hasMetricSelected &&
responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
!isLoading &&
!isError &&
!hasAnyData && (
<EmptyMetricsSearch hasQueryResult={responseData[0] !== undefined} />
)}
{!isCancelled &&
hasMetricSelected &&
!isLoading &&
!isError &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 &&
chartsData.map(
(chart, index) =>
chart.hasData && (
<div
className="time-series-view-panel"
// oxlint-disable-next-line react/no-array-index-key -- query responses have no stable ID
key={`${WIDGET_ID}-${index}`}
>
<BarChart
config={chart.config}
legendConfig={{
position: LegendPosition.BOTTOM,
}}
data={chart.chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
isStackedBarChart
yAxisUnit={yAxisUnit || 'short'}
timezone={timezone}
/>
</div>
),
)}
</div>
</div>
);

View File

@@ -30,6 +30,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { stackSeries } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { isEmpty } from 'lodash-es';
@@ -57,6 +58,7 @@ function TimeSeriesView({
dataSource,
setWarning,
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -65,11 +67,23 @@ function TimeSeriesView({
const location = useLocation();
const { currentQuery } = useQueryBuilder();
const chartData = useMemo(
const rawChartData = useMemo(
() => getUPlotChartData(data?.payload),
[data?.payload],
);
const { chartData, stackedBands } = useMemo(() => {
if (!stackBarChart || !rawChartData || rawChartData.length < 2) {
return { chartData: rawChartData, stackedBands: null };
}
const noSeriesHidden = (): boolean => false;
const { data: stacked, bands } = stackSeries(
rawChartData as uPlot.AlignedData,
noSeriesHidden,
);
return { chartData: stacked, stackedBands: bands };
}, [rawChartData, stackBarChart]);
useEffect(() => {
if (data?.payload) {
setWarning?.(data?.warning);
@@ -189,7 +203,7 @@ function TimeSeriesView({
const { timezone } = useTimezone();
const chartOptions = getUPlotChartOptions({
const baseChartOptions = getUPlotChartOptions({
id: 'time-series-explorer',
onDragSelect,
yAxisUnit: yAxisUnit || '',
@@ -222,6 +236,14 @@ function TimeSeriesView({
},
});
const chartOptions = useMemo(
() =>
stackedBands
? { ...baseChartOptions, bands: stackedBands }
: baseChartOptions,
[baseChartOptions, stackedBands],
);
return (
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
@@ -282,6 +304,7 @@ interface TimeSeriesViewProps {
dataSource: DataSource;
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
panelType?: PANEL_TYPES;
stackBarChart?: boolean;
}
TimeSeriesView.defaultProps = {
@@ -290,6 +313,7 @@ TimeSeriesView.defaultProps = {
error: undefined,
setWarning: undefined,
panelType: PANEL_TYPES.TIME_SERIES,
stackBarChart: false,
};
export default TimeSeriesView;

View File

@@ -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' });
});
});

View File

@@ -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) {

View File

@@ -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),

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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
}