Compare commits

..

3 Commits

Author SHA1 Message Date
nikhilmantri0902
43509681fa chore: remove comments 2026-02-22 17:35:49 +05:30
nikhilmantri0902
ff5fcc0e98 chore: remove explicit URL decoding that causes crashes on K8s parameters 2026-02-21 11:21:37 +05:30
nikhilmantri0902
122d88c4d2 chore: fixed all failing sites where double decoding is happening in infra monitoring 2026-02-21 11:07:50 +05:30
13 changed files with 83 additions and 551 deletions

View File

@@ -27,7 +27,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -103,9 +103,10 @@ function K8sClustersList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -105,9 +105,10 @@ function K8sDaemonSetsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -106,9 +106,10 @@ function K8sDeploymentsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -101,9 +101,10 @@ function K8sJobsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -10,6 +10,7 @@ import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteRe
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { safeParseJSON } from './commonUtils';
import { INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory } from './constants'; import { INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory } from './constants';
import K8sFiltersSidePanel from './K8sFiltersSidePanel/K8sFiltersSidePanel'; import K8sFiltersSidePanel from './K8sFiltersSidePanel/K8sFiltersSidePanel';
import { IEntityColumn } from './utils'; import { IEntityColumn } from './utils';
@@ -58,9 +59,10 @@ function K8sHeader({
const urlFilters = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS); const urlFilters = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS);
let { filters } = currentQuery.builder.queryData[0]; let { filters } = currentQuery.builder.queryData[0];
if (urlFilters) { if (urlFilters) {
const decoded = decodeURIComponent(urlFilters); const parsed = safeParseJSON<IBuilderQuery['filters']>(urlFilters);
const parsed = JSON.parse(decoded); if (parsed) {
filters = parsed; filters = parsed;
}
} }
return { return {
...currentQuery, ...currentQuery,

View File

@@ -27,7 +27,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -104,9 +104,10 @@ function K8sNamespacesList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -27,7 +27,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -99,9 +99,10 @@ function K8sNodesList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -29,7 +29,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -92,9 +92,10 @@ function K8sPodsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -105,9 +105,10 @@ function K8sStatefulSetsList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -28,7 +28,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features'; import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App'; import { useAppContext } from '../../../providers/App/App';
import { getOrderByFromParams } from '../commonUtils'; import { getOrderByFromParams, safeParseJSON } from '../commonUtils';
import { import {
GetK8sEntityToAggregateAttribute, GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS, INFRA_MONITORING_K8S_PARAMS_KEYS,
@@ -105,9 +105,10 @@ function K8sVolumesList({
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => { const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>(() => {
const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
if (groupBy) { if (groupBy) {
const decoded = decodeURIComponent(groupBy); const parsed = safeParseJSON<IBuilderQuery['groupBy']>(groupBy);
const parsed = JSON.parse(decoded); if (parsed) {
return parsed as IBuilderQuery['groupBy']; return parsed;
}
} }
return []; return [];
}); });

View File

@@ -5,6 +5,7 @@
/* eslint-disable prefer-destructuring */ /* eslint-disable prefer-destructuring */
import { useMemo } from 'react'; import { useMemo } from 'react';
import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Table, Tooltip, Typography } from 'antd'; import { Table, Tooltip, Typography } from 'antd';
import { Progress } from 'antd/lib'; import { Progress } from 'antd/lib';
@@ -260,6 +261,19 @@ export const filterDuplicateFilters = (
return uniqueFilters; return uniqueFilters;
}; };
export const safeParseJSON = <T,>(value: string): T | null => {
if (!value) {
return null;
}
try {
return JSON.parse(value) as T;
} catch (e) {
console.error('Error parsing JSON from URL parameter:', e);
// TODO: Should we capture this error in Sentry?
return null;
}
};
export const getOrderByFromParams = ( export const getOrderByFromParams = (
searchParams: URLSearchParams, searchParams: URLSearchParams,
returnNullAsDefault = false, returnNullAsDefault = false,
@@ -271,9 +285,12 @@ export const getOrderByFromParams = (
INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY, INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
); );
if (orderByFromParams) { if (orderByFromParams) {
const decoded = decodeURIComponent(orderByFromParams); const parsed = safeParseJSON<{ columnName: string; order: 'asc' | 'desc' }>(
const parsed = JSON.parse(decoded); orderByFromParams,
return parsed as { columnName: string; order: 'asc' | 'desc' }; );
if (parsed) {
return parsed;
}
} }
if (returnNullAsDefault) { if (returnNullAsDefault) {
return null; return null;
@@ -287,13 +304,7 @@ export const getFiltersFromParams = (
): IBuilderQuery['filters'] | null => { ): IBuilderQuery['filters'] | null => {
const filtersFromParams = searchParams.get(queryKey); const filtersFromParams = searchParams.get(queryKey);
if (filtersFromParams) { if (filtersFromParams) {
try { return safeParseJSON<IBuilderQuery['filters']>(filtersFromParams);
const decoded = decodeURIComponent(filtersFromParams);
const parsed = JSON.parse(decoded);
return parsed as IBuilderQuery['filters'];
} catch (error) {
return null;
}
} }
return null; return null;
}; };

View File

@@ -8,7 +8,7 @@ import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/v1/invite/bulk/create'; import inviteUsers from 'api/v1/invite/bulk/create';
import AuthError from 'components/AuthError/AuthError'; import AuthError from 'components/AuthError/AuthError';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, debounce } from 'lodash-es'; import { cloneDeep, debounce, isEmpty } from 'lodash-es';
import { import {
ArrowRight, ArrowRight,
ChevronDown, ChevronDown,
@@ -65,7 +65,7 @@ function InviteTeamMembers({
}; };
useEffect(() => { useEffect(() => {
if (teamMembers === null) { if (isEmpty(teamMembers)) {
const initialTeamMembers = Array.from({ length: 3 }, () => ({ const initialTeamMembers = Array.from({ length: 3 }, () => ({
...defaultTeamMember, ...defaultTeamMember,
id: uuid(), id: uuid(),
@@ -88,10 +88,7 @@ function InviteTeamMembers({
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id)); setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
}; };
const isMemberTouched = (member: TeamMember): boolean => // Validation function to check all users
member.email.trim() !== '' ||
Boolean(member.role && member.role.trim() !== '');
const validateAllUsers = (): boolean => { const validateAllUsers = (): boolean => {
let isValid = true; let isValid = true;
let hasEmailErrors = false; let hasEmailErrors = false;
@@ -99,9 +96,7 @@ function InviteTeamMembers({
const updatedEmailValidity: Record<string, boolean> = {}; const updatedEmailValidity: Record<string, boolean> = {};
const touchedMembers = teamMembersToInvite?.filter(isMemberTouched) ?? []; teamMembersToInvite?.forEach((member) => {
touchedMembers?.forEach((member) => {
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email); const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
const roleValid = Boolean(member.role && member.role.trim() !== ''); const roleValid = Boolean(member.role && member.role.trim() !== '');
@@ -155,12 +150,12 @@ function InviteTeamMembers({
const handleNext = (): void => { const handleNext = (): void => {
if (validateAllUsers()) { if (validateAllUsers()) {
setTeamMembers(teamMembersToInvite?.filter(isMemberTouched) ?? []); setTeamMembers(teamMembersToInvite || []);
setHasInvalidEmails(false); setHasInvalidEmails(false);
setHasInvalidRoles(false); setHasInvalidRoles(false);
setInviteError(null); setInviteError(null);
sendInvites({ sendInvites({
invites: teamMembersToInvite?.filter(isMemberTouched) ?? [], invites: teamMembersToInvite || [],
}); });
} }
}; };
@@ -235,12 +230,12 @@ function InviteTeamMembers({
const getValidationErrorMessage = (): string => { const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) { if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members'; return 'Please enter valid emails and select roles for all team members';
} }
if (hasInvalidEmails) { if (hasInvalidEmails) {
return 'Please enter valid emails for team members'; return 'Please enter valid emails for all team members';
} }
return 'Please select roles for team members'; return 'Please select roles for all team members';
}; };
const handleDoLater = (): void => { const handleDoLater = (): void => {
@@ -251,10 +246,7 @@ function InviteTeamMembers({
onNext(); onNext();
}; };
const hasInvites =
(teamMembersToInvite?.filter(isMemberTouched) ?? []).length > 0;
const isButtonDisabled = isSendingInvites || isLoading; const isButtonDisabled = isSendingInvites || isLoading;
const isInviteButtonDisabled = isButtonDisabled || !hasInvites;
return ( return (
<div className="questions-container"> <div className="questions-container">
@@ -364,11 +356,9 @@ function InviteTeamMembers({
<Button <Button
variant="solid" variant="solid"
color="primary" color="primary"
className={`onboarding-next-button ${ className={`onboarding-next-button ${isButtonDisabled ? 'disabled' : ''}`}
isInviteButtonDisabled ? 'disabled' : ''
}`}
onClick={handleNext} onClick={handleNext}
disabled={isInviteButtonDisabled} disabled={isButtonDisabled}
suffixIcon={ suffixIcon={
isButtonDisabled ? ( isButtonDisabled ? (
<Loader2 className="animate-spin" size={12} /> <Loader2 className="animate-spin" size={12} />
@@ -377,7 +367,7 @@ function InviteTeamMembers({
) )
} }
> >
Send Invites Complete
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -1,480 +0,0 @@
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import InviteTeamMembers from '../InviteTeamMembers';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
(args: { message: string }) => void
>;
const mockNotificationError = jest.fn() as jest.MockedFunction<
(args: { message: string }) => void
>;
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
success: mockNotificationSuccess,
error: mockNotificationError,
},
}),
}));
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk';
interface TeamMember {
email: string;
role: string;
name: string;
frontendBaseUrl: string;
id: string;
}
interface InviteRequestBody {
invites: { email: string; role: string }[];
}
interface RenderProps {
isLoading?: boolean;
teamMembers?: TeamMember[] | null;
}
const mockOnNext = jest.fn() as jest.MockedFunction<() => void>;
const mockSetTeamMembers = jest.fn() as jest.MockedFunction<
(members: TeamMember[]) => void
>;
function renderComponent({
isLoading = false,
teamMembers = null,
}: RenderProps = {}): ReturnType<typeof render> {
return render(
<InviteTeamMembers
isLoading={isLoading}
teamMembers={teamMembers}
setTeamMembers={mockSetTeamMembers}
onNext={mockOnNext}
/>,
);
}
async function selectRole(
user: ReturnType<typeof userEvent.setup>,
selectIndex: number,
optionLabel: string,
): Promise<void> {
const placeholders = screen.getAllByText(/select roles/i);
await user.click(placeholders[selectIndex]);
const optionContent = await screen.findByText(optionLabel);
fireEvent.click(optionContent);
}
describe('InviteTeamMembers', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
});
afterEach(() => {
jest.useRealTimers();
server.resetHandlers();
});
describe('Initial rendering', () => {
it('renders the page header, column labels, default rows, and action buttons', () => {
renderComponent();
expect(
screen.getByRole('heading', { name: /invite your team/i }),
).toBeInTheDocument();
expect(
screen.getByText(/signoz is a lot more useful with collaborators/i),
).toBeInTheDocument();
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(3);
expect(screen.getByText('Email address')).toBeInTheDocument();
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /send invites/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /i'll do this later/i }),
).toBeInTheDocument();
});
it('disables both action buttons while isLoading is true', () => {
renderComponent({ isLoading: true });
expect(screen.getByRole('button', { name: /send invites/i })).toBeDisabled();
expect(
screen.getByRole('button', { name: /i'll do this later/i }),
).toBeDisabled();
});
});
describe('Row management', () => {
it('adds a new empty row when "Add another" is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(3);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(4);
});
it('removes the correct row when its trash icon is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const emailInputs = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(emailInputs[0], 'first@example.com');
await screen.findByDisplayValue('first@example.com');
await user.click(
screen.getAllByRole('button', { name: /remove team member/i })[0],
);
await waitFor(() => {
expect(
screen.queryByDisplayValue('first@example.com'),
).not.toBeInTheDocument();
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(2);
});
});
it('hides remove buttons when only one row remains', async () => {
renderComponent();
const user = userEvent.setup({ pointerEventsCheck: 0 });
let removeButtons = screen.getAllByRole('button', {
name: /remove team member/i,
});
while (removeButtons.length > 0) {
await user.click(removeButtons[0]);
removeButtons = screen.queryAllByRole('button', {
name: /remove team member/i,
});
}
expect(
screen.queryByRole('button', { name: /remove team member/i }),
).not.toBeInTheDocument();
});
});
describe('Inline email validation', () => {
it('shows an inline error after typing an invalid email and clears it when a valid email is entered', async () => {
jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
});
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'not-an-email');
jest.advanceTimersByTime(600);
await waitFor(() => {
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'good@example.com');
jest.advanceTimersByTime(600);
await waitFor(() => {
expect(
screen.queryByText(/invalid email address/i),
).not.toBeInTheDocument();
});
});
it('does not show an inline error when the field is cleared back to empty', async () => {
jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
});
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'a');
await user.clear(firstInput);
jest.advanceTimersByTime(600);
await waitFor(() => {
expect(
screen.queryByText(/invalid email address/i),
).not.toBeInTheDocument();
});
});
});
describe('Validation callout on Complete', () => {
it('shows the correct callout message for each combination of email/role validity', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const removeButtons = screen.getAllByRole('button', {
name: /remove team member/i,
});
await user.click(removeButtons[0]);
await user.click(
screen.getAllByRole('button', { name: /remove team member/i })[0],
);
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'bad-email');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(
/please enter valid emails and select roles for team members/i,
),
).toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
});
await selectRole(user, 0, 'Viewer');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(/please enter valid emails for team members/i),
).toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails and select roles/i),
).not.toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'valid@example.com');
await user.click(screen.getByRole('button', { name: /add another/i }));
const allInputs = screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i);
await user.type(allInputs[1], 'norole@example.com');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(/please select roles for team members/i),
).toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails and select roles/i),
).not.toBeInTheDocument();
});
});
it('treats whitespace as untouched, clears the callout on fix-and-resubmit, and clears role error on role select', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const removeButtons = screen.getAllByRole('button', {
name: /remove team member/i,
});
await user.click(removeButtons[0]);
await user.click(
screen.getAllByRole('button', { name: /remove team member/i })[0],
);
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, ' ');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.queryByText(/please enter valid emails/i),
).not.toBeInTheDocument();
expect(screen.queryByText(/please select roles/i)).not.toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'bad-email');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(
/please enter valid emails and select roles for team members/i,
),
).toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'good@example.com');
await selectRole(user, 0, 'Admin');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.queryByText(/please enter valid emails and select roles/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
});
await waitFor(() => expect(mockOnNext).toHaveBeenCalledTimes(1), {
timeout: 1200,
});
});
it('disables the Send Invites button when all rows are untouched (empty)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const sendInvitesBtn = screen.getByRole('button', { name: /send invites/i });
expect(sendInvitesBtn).toBeDisabled();
// Type something to make a row touched
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'a');
expect(sendInvitesBtn).not.toBeDisabled();
});
});
describe('API integration', () => {
it('only sends touched (non-empty) rows — empty rows are excluded from the invite payload', async () => {
let capturedBody: InviteRequestBody | null = null;
server.use(
rest.post(INVITE_USERS_ENDPOINT, async (req, res, ctx) => {
capturedBody = await req.json<InviteRequestBody>();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'only@example.com');
await selectRole(user, 0, 'Admin');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(capturedBody).not.toBeNull();
expect(capturedBody?.invites).toHaveLength(1);
expect(capturedBody?.invites[0]).toMatchObject({
email: 'only@example.com',
role: 'ADMIN',
});
});
await waitFor(() => expect(mockOnNext).toHaveBeenCalled(), {
timeout: 1200,
});
});
it('calls the invite API, shows a success notification, and calls onNext after the 1 s delay', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'alice@example.com');
await selectRole(user, 0, 'Admin');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(mockNotificationSuccess).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Invites sent successfully!' }),
);
});
await waitFor(
() => {
expect(mockOnNext).toHaveBeenCalledTimes(1);
},
{ timeout: 1200 },
);
});
it('renders an API error container when the invite request fails', async () => {
server.use(
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({
errors: [{ code: 'INTERNAL_ERROR', msg: 'Something went wrong' }],
}),
),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'fail@example.com');
await selectRole(user, 0, 'Viewer');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(document.querySelector('.auth-error-container')).toBeInTheDocument();
});
await user.type(firstInput, 'x');
await waitFor(() => {
expect(
document.querySelector('.auth-error-container'),
).not.toBeInTheDocument();
});
});
});
});