mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-30 03:40:43 +01:00
Compare commits
1 Commits
refactor/f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc83f91058 |
@@ -6,10 +6,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -128,13 +124,15 @@ export function useNavigateToExplorer(): (
|
||||
});
|
||||
}
|
||||
|
||||
applySerializedParams(serialize(preparedQuery), urlParams);
|
||||
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery));
|
||||
|
||||
const basePath =
|
||||
dataSource === DataSource.TRACES
|
||||
? ROUTES.TRACES_EXPLORER
|
||||
: ROUTES.LOGS_EXPLORER;
|
||||
const newExplorerPath = `${basePath}?${urlParams.toString()}`;
|
||||
const newExplorerPath = `${basePath}?${urlParams.toString()}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}`;
|
||||
|
||||
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
|
||||
},
|
||||
|
||||
163
frontend/src/components/InviteMembers/InviteMembers.module.scss
Normal file
163
frontend/src/components/InviteMembers/InviteMembers.module.scss
Normal file
@@ -0,0 +1,163 @@
|
||||
.inviteMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.headerCellEmail {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerCellRole {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.headerCellAction {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cellEmail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
|
||||
--input-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-hover-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-focus-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-disabled-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
input::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.cellRole {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
|
||||
:global(.roles-single-select) {
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
background-color: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cellAction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.addRow {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.callout {
|
||||
animation: shake 300ms ease-out;
|
||||
|
||||
&[data-type='success'] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.resultsList {
|
||||
margin: var(--spacing-2) 0 0 var(--spacing-8);
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
221
frontend/src/components/InviteMembers/InviteMembers.tsx
Normal file
221
frontend/src/components/InviteMembers/InviteMembers.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { CircleAlert, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import RolesSelect from 'components/RolesSelect/RolesSelect';
|
||||
|
||||
import styles from './InviteMembers.module.scss';
|
||||
import { InviteMembersProps } from './types';
|
||||
import { useInviteMembers } from './useInviteMembers';
|
||||
|
||||
function InviteMembers({
|
||||
className,
|
||||
initialRowCount = 3,
|
||||
minRows = 1,
|
||||
emailPlaceholder = 'e.g. john@signoz.io',
|
||||
showHeader = true,
|
||||
showAddButton = true,
|
||||
onSuccess,
|
||||
onPartialSuccess,
|
||||
onAllFailed,
|
||||
renderFooter,
|
||||
}: InviteMembersProps): JSX.Element {
|
||||
const {
|
||||
rows,
|
||||
emailValidity,
|
||||
hasInvalidEmails,
|
||||
hasInvalidRoles,
|
||||
isSubmitting,
|
||||
inviteResults,
|
||||
addRow,
|
||||
removeRow,
|
||||
updateEmail,
|
||||
updateRole,
|
||||
reset,
|
||||
submit,
|
||||
touchedRows,
|
||||
failedResults,
|
||||
successResults,
|
||||
} = useInviteMembers({
|
||||
initialRowCount,
|
||||
onSuccess,
|
||||
onPartialSuccess,
|
||||
onAllFailed,
|
||||
});
|
||||
|
||||
const canSubmit = !isSubmitting && touchedRows.length > 0;
|
||||
const canRemoveRow = rows.length > minRows;
|
||||
|
||||
const getValidationErrorMessage = (): string => {
|
||||
if (hasInvalidEmails && hasInvalidRoles) {
|
||||
return 'Please enter valid emails and select roles for team members';
|
||||
}
|
||||
if (hasInvalidEmails) {
|
||||
return 'Please enter valid emails for team members';
|
||||
}
|
||||
return 'Please select roles for team members';
|
||||
};
|
||||
|
||||
const hasValidationErrors = hasInvalidEmails || hasInvalidRoles;
|
||||
const hasResults = inviteResults !== null;
|
||||
const hasFailures = failedResults.length > 0;
|
||||
const hasSuccesses = successResults.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.inviteMembers, className)}>
|
||||
<div className={styles.table}>
|
||||
{showHeader && (
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.headerCellEmail}
|
||||
>
|
||||
Email address
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.headerCellRole}
|
||||
>
|
||||
Role
|
||||
</Typography.Text>
|
||||
<div className={styles.headerCellAction} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className={styles.row}>
|
||||
<div className={styles.cellEmail}>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={emailPlaceholder}
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
name={`invite-email-${row.id}`}
|
||||
autoComplete="email"
|
||||
data-testid={`invite-email-${row.id}`}
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<Typography.Text size="small" className={styles.errorText}>
|
||||
Invalid email address
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.cellRole}>
|
||||
<RolesSelect
|
||||
mode="single"
|
||||
value={row.roleId || undefined}
|
||||
onChange={(roleId): void => updateRole(row.id, roleId)}
|
||||
placeholder="Select role"
|
||||
allowClear={false}
|
||||
id={`invite-role-${row.id}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.cellAction}>
|
||||
{canRemoveRow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
data-testid={`invite-remove-${row.id}`}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAddButton && (
|
||||
<div className={styles.addRow}>
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={addRow}
|
||||
data-testid="invite-add-row"
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasValidationErrors && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className={styles.callout}
|
||||
data-testid="invite-validation-error"
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{hasResults && hasFailures && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className={styles.callout}
|
||||
data-testid="invite-api-error"
|
||||
>
|
||||
<div className={styles.results}>
|
||||
{hasSuccesses && (
|
||||
<Typography.Text size="small">
|
||||
{successResults.length} invite(s) sent successfully.
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text size="small">
|
||||
{failedResults.length} invite(s) failed:
|
||||
</Typography.Text>
|
||||
<ul className={styles.resultsList}>
|
||||
{failedResults.map((result) => (
|
||||
<li key={result.email}>
|
||||
<Typography.Text size="small">
|
||||
{result.email}: {result.error}
|
||||
</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{hasResults && !hasFailures && hasSuccesses && (
|
||||
<Callout
|
||||
type="success"
|
||||
size="small"
|
||||
showIcon
|
||||
className={styles.callout}
|
||||
data-testid="invite-success"
|
||||
>
|
||||
<Typography.Text size="small">
|
||||
{successResults.length} invite(s) sent successfully!
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{renderFooter?.({
|
||||
submit,
|
||||
reset,
|
||||
canSubmit,
|
||||
isSubmitting,
|
||||
touchedCount: touchedRows.length,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembers;
|
||||
@@ -0,0 +1,240 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import {
|
||||
CREATE_USER_ENDPOINT,
|
||||
createErrorHandler,
|
||||
createRolesHandler,
|
||||
createSuccessHandler,
|
||||
VALID_EMAIL,
|
||||
} from './testUtils';
|
||||
|
||||
describe('InviteMembers - Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('reset behavior', () => {
|
||||
it('clears all rows when reset is called', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={2}
|
||||
renderFooter={({ reset }): JSX.Element => (
|
||||
<button data-testid="reset-btn" onClick={reset}>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
|
||||
await user.click(screen.getByTestId('reset-btn'));
|
||||
|
||||
const resetInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(resetInputs).toHaveLength(2);
|
||||
resetInputs.forEach((input) => {
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears results on reset', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit, reset }): JSX.Element => (
|
||||
<>
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
<button data-testid="reset-btn" onClick={reset}>
|
||||
Reset
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
await expect(
|
||||
screen.findByTestId('invite-success'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('reset-btn'));
|
||||
|
||||
expect(screen.queryByTestId('invite-success')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('results cleared on edit', () => {
|
||||
it('clears API error when email is edited', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.type(emailInputs[0], 'x');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears API error when role is changed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const viewerElements = screen.getAllByText('Viewer');
|
||||
await user.click(viewerElements[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty submission', () => {
|
||||
it('does not submit when no rows are touched', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
onSuccess={onSuccess}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitting state', () => {
|
||||
it('disables submit while submitting', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit, isSubmitting }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
const submitBtn = screen.getByTestId('submit-btn');
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Submitting...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('trims email whitespace before submission', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const calls: { email: string }[] = [];
|
||||
|
||||
server.use(
|
||||
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
calls.push(body);
|
||||
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], ' alice@signoz.io ');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].email).toBe('alice@signoz.io');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import { createRolesHandler, createSuccessHandler } from './testUtils';
|
||||
|
||||
describe('InviteMembers - Rendering', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders default initial row count of 3', () => {
|
||||
render(<InviteMembers />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders custom initial row count', () => {
|
||||
render(<InviteMembers initialRowCount={5} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders header by default', () => {
|
||||
render(<InviteMembers />);
|
||||
|
||||
expect(screen.getByText('Email address')).toBeInTheDocument();
|
||||
expect(screen.getByText('Role')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides header when showHeader is false', () => {
|
||||
render(<InviteMembers showHeader={false} />);
|
||||
|
||||
expect(screen.queryByText('Email address')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Role')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders add button by default', () => {
|
||||
render(<InviteMembers />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /add another/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides add button when showAddButton is false', () => {
|
||||
render(<InviteMembers showAddButton={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /add another/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom email placeholder', () => {
|
||||
render(<InviteMembers emailPlaceholder="custom@placeholder.com" />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('custom@placeholder.com');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<InviteMembers className="custom-class" />);
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer via renderFooter prop', () => {
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ canSubmit }): JSX.Element => (
|
||||
<button data-testid="custom-footer" disabled={!canSubmit}>
|
||||
Custom Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-footer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-footer')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders role select for each row', () => {
|
||||
render(<InviteMembers initialRowCount={2} />);
|
||||
|
||||
const roleSelects = screen.getAllByText('Select role');
|
||||
expect(roleSelects).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import { createRolesHandler, createSuccessHandler } from './testUtils';
|
||||
|
||||
describe('InviteMembers - Row Management', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('adds a row when "Add another" is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={2} />);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('removes a row when trash button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={3} />);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
expect(removeButtons).toHaveLength(3);
|
||||
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('respects minRows constraint when removing rows', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={2} minRows={2} />);
|
||||
|
||||
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
|
||||
0,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
expect(removeButtons).toHaveLength(3);
|
||||
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
|
||||
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot remove rows below minRows=1 default', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={2} />);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(1);
|
||||
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves data in other rows when removing one', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={3} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], 'first@signoz.io');
|
||||
await user.type(emailInputs[2], 'third@signoz.io');
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[1]);
|
||||
|
||||
const remainingInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(remainingInputs).toHaveLength(2);
|
||||
expect(remainingInputs[0]).toHaveValue('first@signoz.io');
|
||||
expect(remainingInputs[1]).toHaveValue('third@signoz.io');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import {
|
||||
CREATE_USER_ENDPOINT,
|
||||
createErrorHandler,
|
||||
createRolesHandler,
|
||||
createSuccessHandler,
|
||||
createTrackingHandler,
|
||||
VALID_EMAIL,
|
||||
} from './testUtils';
|
||||
|
||||
describe('InviteMembers - Submission', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
it('calls createUser API for each touched row', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { handler, calls } = createTrackingHandler();
|
||||
server.use(handler);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toMatchObject({
|
||||
email: 'alice@signoz.io',
|
||||
userRoles: [{ id: 'role-viewer' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createUser API for multiple touched rows', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { handler, calls } = createTrackingHandler();
|
||||
server.use(handler);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.type(emailInputs[2], 'charlie@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
const adminOptions = await screen.findAllByText('Admin');
|
||||
await user.click(adminOptions[adminOptions.length - 1]);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
email: 'alice@signoz.io',
|
||||
userRoles: [{ id: 'role-viewer' }],
|
||||
});
|
||||
expect(calls[1]).toMatchObject({
|
||||
email: 'bob@signoz.io',
|
||||
userRoles: [{ id: 'role-editor' }],
|
||||
});
|
||||
expect(calls[2]).toMatchObject({
|
||||
email: 'charlie@signoz.io',
|
||||
userRoles: [{ id: 'role-admin' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('calls onSuccess when all invites succeed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
onSuccess={onSuccess}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onAllFailed when all invites fail', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onAllFailed = jest.fn();
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
onAllFailed={onAllFailed}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAllFailed).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: VALID_EMAIL,
|
||||
success: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onPartialSuccess when some invites succeed and some fail', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onPartialSuccess = jest.fn();
|
||||
const onSuccess = jest.fn();
|
||||
const onAllFailed = jest.fn();
|
||||
const apiCalls: string[] = [];
|
||||
let callCount = 0;
|
||||
|
||||
server.use(
|
||||
createRolesHandler(),
|
||||
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push(body.email);
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(409),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'already_exists',
|
||||
message: 'User already exists',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={2}
|
||||
onSuccess={onSuccess}
|
||||
onPartialSuccess={onPartialSuccess}
|
||||
onAllFailed={onAllFailed}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(apiCalls).toStrictEqual(['alice@signoz.io', 'bob@signoz.io']);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
expect(onAllFailed).not.toHaveBeenCalled();
|
||||
expect(onPartialSuccess).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'alice@signoz.io', success: true }),
|
||||
expect.objectContaining({
|
||||
email: 'bob@signoz.io',
|
||||
success: false,
|
||||
error: 'User already exists',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('1 invite(s) sent successfully.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('1 invite(s) failed:')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('bob@signoz.io: User already exists'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('result display', () => {
|
||||
it('shows success callout when all invites succeed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('invite-success'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error callout with failed emails when API fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(/1 invite\(s\) failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer props', () => {
|
||||
it('provides correct canSubmit state', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ canSubmit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" disabled={!canSubmit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('submit-btn')).toBeDisabled();
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
|
||||
expect(screen.getByTestId('submit-btn')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('provides touchedCount', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ touchedCount }): JSX.Element => (
|
||||
<span data-testid="touched-count">{touchedCount}</span>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('touched-count')).toHaveTextContent('0');
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], 'a@b.com');
|
||||
|
||||
expect(screen.getByTestId('touched-count')).toHaveTextContent('1');
|
||||
|
||||
await user.type(emailInputs[1], 'c@d.com');
|
||||
|
||||
expect(screen.getByTestId('touched-count')).toHaveTextContent('2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import {
|
||||
createRolesHandler,
|
||||
createSuccessHandler,
|
||||
INVALID_EMAIL,
|
||||
VALID_EMAIL,
|
||||
} from './testUtils';
|
||||
|
||||
describe('InviteMembers - Validation', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('email validation', () => {
|
||||
it('shows email validation error when email is invalid and role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows inline error for invalid email field', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Invalid email address'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears validation error when email is corrected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.clear(emailInputs[0]);
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('Please enter valid emails for team members'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('role validation', () => {
|
||||
it('shows role validation error when role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears role validation error when role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('Please select roles for team members'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined validation', () => {
|
||||
it('shows combined error when both email and role are invalid', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Please enter valid emails and select roles for team members',
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('touched rows', () => {
|
||||
it('only validates touched rows (rows with email or role)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('invite-validation-error'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
54
frontend/src/components/InviteMembers/__tests__/testUtils.ts
Normal file
54
frontend/src/components/InviteMembers/__tests__/testUtils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { RestHandler } from 'msw';
|
||||
import { rest } from 'mocks-server/server';
|
||||
|
||||
export const CREATE_USER_ENDPOINT = '*/api/v2/users';
|
||||
export const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
export const MOCK_ROLES = [
|
||||
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
|
||||
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
|
||||
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
|
||||
];
|
||||
|
||||
export const VALID_EMAIL = 'alice@signoz.io';
|
||||
export const INVALID_EMAIL = 'not-an-email';
|
||||
|
||||
export function createSuccessHandler(): RestHandler {
|
||||
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
|
||||
);
|
||||
}
|
||||
|
||||
export function createErrorHandler(
|
||||
code: string,
|
||||
message: string,
|
||||
status = 409,
|
||||
): RestHandler {
|
||||
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(status),
|
||||
ctx.json({
|
||||
errors: [{ code, msg: message }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createRolesHandler(): RestHandler {
|
||||
return rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
|
||||
);
|
||||
}
|
||||
|
||||
export function createTrackingHandler(): {
|
||||
handler: RestHandler;
|
||||
calls: unknown[];
|
||||
} {
|
||||
const calls: unknown[] = [];
|
||||
const handler = rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
calls.push(body);
|
||||
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
|
||||
});
|
||||
return { handler, calls };
|
||||
}
|
||||
64
frontend/src/components/InviteMembers/types.ts
Normal file
64
frontend/src/components/InviteMembers/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface InviteMemberRow {
|
||||
id: string;
|
||||
email: string;
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
export interface InviteResult {
|
||||
email: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FooterRenderProps {
|
||||
submit: () => Promise<InviteResult[]>;
|
||||
reset: () => void;
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
touchedCount: number;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersOptions {
|
||||
initialRowCount?: number;
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersReturn {
|
||||
rows: InviteMemberRow[];
|
||||
emailValidity: Record<string, boolean>;
|
||||
hasInvalidEmails: boolean;
|
||||
hasInvalidRoles: boolean;
|
||||
isSubmitting: boolean;
|
||||
inviteResults: InviteResult[] | null;
|
||||
|
||||
addRow: () => void;
|
||||
removeRow: (id: string) => void;
|
||||
updateEmail: (id: string, email: string) => void;
|
||||
updateRole: (id: string, roleId: string | undefined) => void;
|
||||
reset: () => void;
|
||||
submit: () => Promise<InviteResult[]>;
|
||||
|
||||
touchedRows: InviteMemberRow[];
|
||||
failedResults: InviteResult[];
|
||||
successResults: InviteResult[];
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
export interface InviteMembersProps {
|
||||
className?: string;
|
||||
initialRowCount?: number;
|
||||
minRows?: number;
|
||||
emailPlaceholder?: string;
|
||||
showHeader?: boolean;
|
||||
showAddButton?: boolean;
|
||||
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
|
||||
renderFooter?: (props: FooterRenderProps) => ReactNode;
|
||||
}
|
||||
245
frontend/src/components/InviteMembers/useInviteMembers.ts
Normal file
245
frontend/src/components/InviteMembers/useInviteMembers.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AxiosError } from 'axios';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { createUser } from 'api/generated/services/users';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
InviteMemberRow,
|
||||
InviteResult,
|
||||
UseInviteMembersOptions,
|
||||
UseInviteMembersReturn,
|
||||
} from './types';
|
||||
|
||||
const createEmptyRow = (): InviteMemberRow => ({
|
||||
id: uuid(),
|
||||
email: '',
|
||||
roleId: '',
|
||||
});
|
||||
|
||||
const isRowTouched = (row: InviteMemberRow): boolean =>
|
||||
row.email.trim() !== '' || row.roleId !== '';
|
||||
|
||||
export function useInviteMembers(
|
||||
options: UseInviteMembersOptions = {},
|
||||
): UseInviteMembersReturn {
|
||||
const {
|
||||
initialRowCount = 3,
|
||||
onSuccess,
|
||||
onPartialSuccess,
|
||||
onAllFailed,
|
||||
} = options;
|
||||
|
||||
const [rows, setRows] = useState<InviteMemberRow[]>(() =>
|
||||
Array.from({ length: initialRowCount }, () => createEmptyRow()),
|
||||
);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [inviteResults, setInviteResults] = useState<InviteResult[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const touchedRows = useMemo(() => rows.filter(isRowTouched), [rows]);
|
||||
|
||||
const failedResults = useMemo(
|
||||
() => inviteResults?.filter((r) => !r.success) ?? [],
|
||||
[inviteResults],
|
||||
);
|
||||
|
||||
const successResults = useMemo(
|
||||
() => inviteResults?.filter((r) => r.success) ?? [],
|
||||
[inviteResults],
|
||||
);
|
||||
|
||||
const debouncedValidateEmail = useMemo(
|
||||
() =>
|
||||
debounce((email: string, rowId: string) => {
|
||||
const isValid = EMAIL_REGEX.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
const validateAllRows = useCallback((): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touched = rows.filter(isRowTouched);
|
||||
|
||||
touched.forEach((row) => {
|
||||
const emailValid = EMAIL_REGEX.test(row.email);
|
||||
const roleValid = row.roleId !== '';
|
||||
|
||||
if (!emailValid || !row.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
updatedEmailValidity[row.id] = emailValid;
|
||||
});
|
||||
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
return isValid;
|
||||
}, [rows]);
|
||||
|
||||
const addRow = useCallback((): void => {
|
||||
setRows((prev) => [...prev, createEmptyRow()]);
|
||||
}, []);
|
||||
|
||||
const removeRow = useCallback((id: string): void => {
|
||||
setRows((prev) => prev.filter((r) => r.id !== id));
|
||||
setEmailValidity((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateEmail = useCallback(
|
||||
(id: string, email: string): void => {
|
||||
setRows((prev) => {
|
||||
const updated = cloneDeep(prev);
|
||||
const row = updated.find((r) => r.id === id);
|
||||
if (row) {
|
||||
row.email = email;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (hasInvalidEmails) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
if (emailValidity[id] === false) {
|
||||
setEmailValidity((prev) => ({ ...prev, [id]: true }));
|
||||
}
|
||||
if (inviteResults) {
|
||||
setInviteResults(null);
|
||||
}
|
||||
|
||||
debouncedValidateEmail(email, id);
|
||||
},
|
||||
[hasInvalidEmails, emailValidity, inviteResults, debouncedValidateEmail],
|
||||
);
|
||||
|
||||
const updateRole = useCallback(
|
||||
(id: string, roleId: string | undefined): void => {
|
||||
setRows((prev) => {
|
||||
const updated = cloneDeep(prev);
|
||||
const row = updated.find((r) => r.id === id);
|
||||
if (row) {
|
||||
row.roleId = roleId ?? '';
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (hasInvalidRoles) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
if (inviteResults) {
|
||||
setInviteResults(null);
|
||||
}
|
||||
},
|
||||
[hasInvalidRoles, inviteResults],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setRows(Array.from({ length: initialRowCount }, () => createEmptyRow()));
|
||||
setEmailValidity({});
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
setInviteResults(null);
|
||||
}, [initialRowCount]);
|
||||
|
||||
const submit = useCallback(async (): Promise<InviteResult[]> => {
|
||||
if (!validateAllRows()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const touched = rows.filter(isRowTouched);
|
||||
if (touched.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setInviteResults(null);
|
||||
|
||||
const results: InviteResult[] = [];
|
||||
|
||||
for (const row of touched) {
|
||||
try {
|
||||
await createUser({
|
||||
email: row.email.trim(),
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
userRoles: [{ id: row.roleId }],
|
||||
});
|
||||
results.push({ email: row.email, success: true });
|
||||
} catch (err) {
|
||||
const apiErr = convertToApiError(err as AxiosError<RenderErrorResponseDTO>);
|
||||
results.push({
|
||||
email: row.email,
|
||||
success: false,
|
||||
error: apiErr?.getErrorMessage() ?? 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setInviteResults(results);
|
||||
setIsSubmitting(false);
|
||||
|
||||
const failures = results.filter((r) => !r.success);
|
||||
const successes = results.filter((r) => r.success);
|
||||
|
||||
if (failures.length === 0) {
|
||||
onSuccess?.();
|
||||
} else if (successes.length > 0) {
|
||||
onPartialSuccess?.(results);
|
||||
} else {
|
||||
onAllFailed?.(results);
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [validateAllRows, rows, onSuccess, onPartialSuccess, onAllFailed]);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => !isSubmitting && touchedRows.length > 0,
|
||||
[isSubmitting, touchedRows.length],
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
emailValidity,
|
||||
hasInvalidEmails,
|
||||
hasInvalidRoles,
|
||||
isSubmitting,
|
||||
inviteResults,
|
||||
|
||||
addRow,
|
||||
removeRow,
|
||||
updateEmail,
|
||||
updateRole,
|
||||
reset,
|
||||
submit,
|
||||
|
||||
touchedRows,
|
||||
failedResults,
|
||||
successResults,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {
|
||||
@@ -253,7 +252,7 @@ function LogDetailInner({
|
||||
[QueryParams.activeLogId]: `"${log?.id}"`,
|
||||
[QueryParams.startTime]: minTime?.toString() || '',
|
||||
[QueryParams.endTime]: maxTime?.toString() || '',
|
||||
...serializeToParams(
|
||||
[QueryParams.compositeQuery]: JSON.stringify(
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
PANEL_TYPES.LIST,
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum QueryParams {
|
||||
q = 'q',
|
||||
activeLogId = 'activeLogId',
|
||||
timeRange = 'timeRange',
|
||||
compositeQuery = 'compositeQuery',
|
||||
panelTypes = 'panelTypes',
|
||||
pageSize = 'pageSize',
|
||||
viewMode = 'viewMode',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getAutoContexts } from '../getAutoContexts';
|
||||
|
||||
@@ -53,8 +51,8 @@ describe('getAutoContexts', () => {
|
||||
it('includes the query in alert edit context', () => {
|
||||
const ruleId = 'rule-edit';
|
||||
const query = { queryType: 'builder', builder: { queryData: [] } };
|
||||
const serializedParams = serialize(query as unknown as Query);
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${serializedParams.toString()}`;
|
||||
const compositeQuery = encodeURIComponent(JSON.stringify(query));
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
|
||||
|
||||
@@ -74,8 +72,8 @@ describe('getAutoContexts', () => {
|
||||
|
||||
it('includes the query in alert new context (no ruleId)', () => {
|
||||
const query = { queryType: 'builder', builder: { queryData: [] } };
|
||||
const serializedParams = serialize(query as unknown as Query);
|
||||
const search = `?${serializedParams.toString()}`;
|
||||
const compositeQuery = encodeURIComponent(JSON.stringify(query));
|
||||
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
|
||||
|
||||
@@ -191,24 +189,4 @@ describe('getAutoContexts', () => {
|
||||
),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('decodes the serialized composite query into metadata.query', () => {
|
||||
const query = { builder: { queryData: [] } } as unknown as Query;
|
||||
const search = `?${serialize(query).toString()}`;
|
||||
|
||||
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
|
||||
|
||||
expect(context.metadata?.query).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('omits metadata.query when no serialized query is in the URL', () => {
|
||||
// Detection no longer gates on the `compositeQuery` key — it routes
|
||||
// through `deserialize`/the adapter list — so non-query params (time
|
||||
// range, etc.) must not be mistaken for a query.
|
||||
const search = `?${QueryParams.startTime}=1700000000000&${QueryParams.endTime}=1700003600000`;
|
||||
|
||||
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
|
||||
|
||||
expect(context.metadata).not.toHaveProperty('query');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
undoExecution,
|
||||
} from 'api/ai-assistant/chat';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import {
|
||||
ArchiveRestore,
|
||||
@@ -363,8 +363,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] off-page → history.push', base);
|
||||
const params = serialize(normalized);
|
||||
deps.history.push(`${base}?${params.toString()}`);
|
||||
const encoded = encodeURIComponent(JSON.stringify(normalized));
|
||||
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
|
||||
}
|
||||
|
||||
/** Picks the right rollback API call for a given action kind. */
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getViewById } from 'api/saveView/getViewById';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -219,9 +218,7 @@ describe('buildExplorerNavigationUrl', () => {
|
||||
);
|
||||
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
|
||||
const params = new URLSearchParams(new URL(url, 'http://x').search);
|
||||
expect(deserialize(params)).not.toBeNull();
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,6 @@ import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
@@ -79,7 +75,10 @@ export function buildExplorerNavigationUrl(
|
||||
searchParams: Record<string, unknown>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
applySerializedParams(serialize(query), params);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
params.set(key, JSON.stringify(value));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
@@ -344,9 +343,15 @@ function collectSharedMetadata(
|
||||
out.timeRange = { start: startTime, end: endTime };
|
||||
}
|
||||
|
||||
const decodedQuery = deserialize(params);
|
||||
if (decodedQuery) {
|
||||
out.query = decodedQuery;
|
||||
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
|
||||
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
|
||||
if (compositeQueryRaw) {
|
||||
try {
|
||||
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
|
||||
} catch {
|
||||
// Malformed JSON in the URL — drop silently rather than throw
|
||||
// inside a context-collection helper.
|
||||
}
|
||||
}
|
||||
|
||||
// Saved view selectors (logs / traces explorer) and dashboard variables.
|
||||
|
||||
@@ -2,8 +2,8 @@ import { memo } from 'react';
|
||||
import { Card, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
@@ -28,7 +28,9 @@ function PanelTypeSelectionModal(): JSX.Element {
|
||||
const queryParams = {
|
||||
graphType: name,
|
||||
widgetId: id,
|
||||
...serializeToParams(PANEL_TYPES_INITIAL_QUERY[name]),
|
||||
[QueryParams.compositeQuery]: JSON.stringify(
|
||||
PANEL_TYPES_INITIAL_QUERY[name],
|
||||
),
|
||||
};
|
||||
|
||||
history.push(
|
||||
|
||||
@@ -62,8 +62,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useErrorNotification from 'hooks/useErrorNotification';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import { cloneDeep, isEqual, omit } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -176,7 +174,7 @@ function ExplorerOptions({
|
||||
|
||||
const handleConditionalQueryModification = useCallback(
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(defaultQuery: Query | null): Record<string, string> => {
|
||||
(defaultQuery: Query | null): string => {
|
||||
const queryToUse = defaultQuery || query;
|
||||
if (!queryToUse) {
|
||||
throw new Error('No query provided');
|
||||
@@ -186,7 +184,7 @@ function ExplorerOptions({
|
||||
StringOperators.NOOP &&
|
||||
sourcepage !== DataSource.LOGS
|
||||
) {
|
||||
return serializeToParams(queryToUse);
|
||||
return JSON.stringify(queryToUse);
|
||||
}
|
||||
|
||||
// Convert NOOP to COUNT for alerts and strip orderBy for logs
|
||||
@@ -210,7 +208,14 @@ function ExplorerOptions({
|
||||
);
|
||||
}
|
||||
|
||||
return serializeToParams(modifiedQuery);
|
||||
try {
|
||||
return JSON.stringify(modifiedQuery);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'Failed to stringify modified query: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
},
|
||||
[panelType, query, sourcepage],
|
||||
);
|
||||
@@ -233,9 +238,13 @@ function ExplorerOptions({
|
||||
});
|
||||
}
|
||||
|
||||
const serializedParams = handleConditionalQueryModification(defaultQuery);
|
||||
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
|
||||
|
||||
history.push(`${ROUTES.ALERTS_NEW}?${createQueryParams(serializedParams)}`);
|
||||
history.push(
|
||||
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
stringifiedQuery,
|
||||
)}`,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[handleConditionalQueryModification, history],
|
||||
|
||||
@@ -34,7 +34,6 @@ import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
||||
import { isEmpty, isEqual } from 'lodash-es';
|
||||
@@ -385,7 +384,7 @@ function FormAlertRules({
|
||||
|
||||
const onCancelHandler = useCallback(
|
||||
(e?: React.MouseEvent) => {
|
||||
clearSerializedParams(urlQuery);
|
||||
urlQuery.delete(QueryParams.compositeQuery);
|
||||
urlQuery.delete(QueryParams.panelTypes);
|
||||
urlQuery.delete(QueryParams.ruleId);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
@@ -611,7 +610,7 @@ function FormAlertRules({
|
||||
`${ruleId}`,
|
||||
]);
|
||||
|
||||
clearSerializedParams(urlQuery);
|
||||
urlQuery.delete(QueryParams.compositeQuery);
|
||||
urlQuery.delete(QueryParams.panelTypes);
|
||||
urlQuery.delete(QueryParams.ruleId);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
|
||||
@@ -23,10 +23,6 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
clearSerializedParams,
|
||||
serializeToParams,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import {
|
||||
@@ -216,7 +212,9 @@ function WidgetGraphComponent({
|
||||
[QueryParams.graphType]: clonedWidget?.panelTypes,
|
||||
[QueryParams.widgetId]: uuid,
|
||||
...(clonedWidget?.query && {
|
||||
...serializeToParams(clonedWidget.query),
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(clonedWidget.query),
|
||||
),
|
||||
}),
|
||||
};
|
||||
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
|
||||
@@ -257,7 +255,7 @@ function WidgetGraphComponent({
|
||||
const onToggleModelHandler = (): void => {
|
||||
const existingSearchParams = new URLSearchParams(search);
|
||||
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
||||
clearSerializedParams(existingSearchParams);
|
||||
existingSearchParams.delete(QueryParams.compositeQuery);
|
||||
existingSearchParams.delete(QueryParams.graphType);
|
||||
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
||||
if (queryResponse.data?.payload) {
|
||||
|
||||
@@ -29,10 +29,6 @@ import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { unparse } from 'papaparse';
|
||||
@@ -90,7 +86,10 @@ function WidgetHeader({
|
||||
const widgetId = widget.id;
|
||||
urlQuery.set(QueryParams.widgetId, widgetId);
|
||||
urlQuery.set(QueryParams.graphType, widget.panelTypes);
|
||||
applySerializedParams(serialize(widget.query), urlQuery);
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(widget.query)),
|
||||
);
|
||||
const generatedUrl = buildAbsolutePath({
|
||||
relativePath: 'new',
|
||||
urlQueryString: urlQuery.toString(),
|
||||
|
||||
@@ -7,10 +7,6 @@ import { useListRules } from 'api/generated/services/rules';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import history from 'lib/history';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
@@ -138,7 +134,10 @@ export default function AlertRules({
|
||||
const compositeQuery = mapQueryDataFromApi(
|
||||
toCompositeMetricQuery(record.condition.compositeQuery),
|
||||
);
|
||||
applySerializedParams(serialize(compositeQuery), params);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
|
||||
@@ -28,10 +28,6 @@ import {
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import {
|
||||
@@ -414,7 +410,7 @@ export default function K8sBaseDetails<T>({
|
||||
},
|
||||
};
|
||||
|
||||
applySerializedParams(serialize(compositeQuery as any), urlQuery);
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
@@ -439,7 +435,7 @@ export default function K8sBaseDetails<T>({
|
||||
},
|
||||
};
|
||||
|
||||
applySerializedParams(serialize(compositeQuery as any), urlQuery);
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useGetGlobalConfig } from 'api/generated/services/global';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -78,7 +77,6 @@ import {
|
||||
UpdateLimitProps,
|
||||
} from 'types/api/ingestionKeys/limits/types';
|
||||
import { PaginationProps } from 'types/api/ingestionKeys/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MeterAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getDaysUntilExpiry } from 'utils/timeUtils';
|
||||
@@ -898,6 +896,8 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
},
|
||||
};
|
||||
|
||||
const stringifiedQuery = JSON.stringify(query);
|
||||
|
||||
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
|
||||
thresholds[0].thresholdValue = thresholdValue;
|
||||
thresholds[0].unit = thresholdUnit;
|
||||
@@ -907,12 +907,17 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
|
||||
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
|
||||
|
||||
const params = serialize(query as Query);
|
||||
params.set(QueryParams.thresholds, JSON.stringify(thresholds));
|
||||
params.set(QueryParams.ruleName, ruleName);
|
||||
params.set(QueryParams.yAxisUnit, yAxisUnit);
|
||||
const URL = `${ROUTES.ALERTS_NEW}?${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(stringifiedQuery)}&${
|
||||
QueryParams.thresholds
|
||||
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
|
||||
QueryParams.ruleName
|
||||
}=${encodeURIComponent(ruleName)}&${
|
||||
QueryParams.yAxisUnit
|
||||
}=${encodeURIComponent(yAxisUnit)}`;
|
||||
|
||||
history.push(`${ROUTES.ALERTS_NEW}?${params.toString()}`);
|
||||
history.push(URL);
|
||||
};
|
||||
|
||||
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
fireEvent,
|
||||
@@ -133,19 +132,17 @@ describe('MultiIngestionSettings Page', () => {
|
||||
expect(thresholds[0].thresholdValue).toBe(1000);
|
||||
expect(thresholds[0].unit).toBe('{count}');
|
||||
|
||||
const compositeQuery = deserialize(urlParams);
|
||||
expect(compositeQuery).not.toBeNull();
|
||||
expect(compositeQuery?.unit).toBe('{count}');
|
||||
expect(compositeQuery?.builder.queryData).toBeDefined();
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.unit).toBe('{count}');
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
|
||||
const firstQueryData = compositeQuery?.builder.queryData[0];
|
||||
expect(firstQueryData?.filter?.expression).toContain(
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"signoz.workspace.key.id='k1'",
|
||||
);
|
||||
const firstAggregation = firstQueryData?.aggregations?.[0] as {
|
||||
metricName: string;
|
||||
};
|
||||
expect(firstAggregation.metricName).toBe(
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.metric.datapoint.count',
|
||||
);
|
||||
|
||||
@@ -216,18 +213,18 @@ describe('MultiIngestionSettings Page', () => {
|
||||
expect(thresholds[0].thresholdValue).toBe(400);
|
||||
expect(thresholds[0].unit).toBe('GiBy');
|
||||
|
||||
const compositeQuery = deserialize(urlParams);
|
||||
expect(compositeQuery).not.toBeNull();
|
||||
expect(compositeQuery?.unit).toBe('bytes');
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.unit).toBe('bytes');
|
||||
|
||||
const firstQueryData = compositeQuery?.builder.queryData[0];
|
||||
expect(firstQueryData?.filter?.expression).toContain(
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"signoz.workspace.key.id='k2'",
|
||||
);
|
||||
const firstAggregation = firstQueryData?.aggregations?.[0] as {
|
||||
metricName: string;
|
||||
};
|
||||
expect(firstAggregation.metricName).toBe('signoz.meter.log.size');
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.log.size',
|
||||
);
|
||||
|
||||
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
|
||||
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');
|
||||
|
||||
@@ -6,10 +6,6 @@ import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useTableRowClick } from 'hooks/useTableRowClick';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
@@ -35,7 +31,10 @@ export function useAlertRulesHandlers(
|
||||
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
|
||||
rule.alertType,
|
||||
);
|
||||
applySerializedParams(serialize(compositeQuery), params);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = rule.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
|
||||
@@ -14,10 +14,6 @@ import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
@@ -115,7 +111,10 @@ function ContextLogRenderer({
|
||||
(logId: string): void => {
|
||||
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
|
||||
|
||||
applySerializedParams(serialize(query), urlQuery);
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
|
||||
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
|
||||
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');
|
||||
|
||||
@@ -247,12 +247,16 @@ function Application(): JSX.Element {
|
||||
const avialableParams = routeConfig[ROUTES.TRACE];
|
||||
const queryString = getQueryString(avialableParams, urlParams);
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(
|
||||
JSON.stringify(apmToTraceQuery),
|
||||
);
|
||||
|
||||
const newPath = generateExplorerPath(
|
||||
isViewLogsClicked,
|
||||
urlParams,
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
apmToTraceQuery,
|
||||
JSONCompositeQuery,
|
||||
queryString,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,10 +8,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
|
||||
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
|
||||
@@ -64,18 +60,16 @@ export function generateExplorerPath(
|
||||
urlParams: URLSearchParams,
|
||||
servicename: string | undefined,
|
||||
selectedTraceTags: string,
|
||||
apmToTraceQuery: Query,
|
||||
JSONCompositeQuery: string,
|
||||
queryString: string[],
|
||||
): string {
|
||||
const basePath = isViewLogsClicked
|
||||
? ROUTES.LOGS_EXPLORER
|
||||
: ROUTES.TRACES_EXPLORER;
|
||||
|
||||
applySerializedParams(serialize(apmToTraceQuery), urlParams);
|
||||
|
||||
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${queryString.join(
|
||||
'&',
|
||||
)}`;
|
||||
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}&${queryString.join('&')}`;
|
||||
}
|
||||
|
||||
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
|
||||
@@ -111,12 +105,16 @@ export function onViewTracePopupClick({
|
||||
const avialableParams = routeConfig[ROUTES.TRACE];
|
||||
const queryString = getQueryString(avialableParams, urlParams);
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(
|
||||
JSON.stringify(apmToTraceQuery),
|
||||
);
|
||||
|
||||
const newPath = generateExplorerPath(
|
||||
isViewLogsClicked,
|
||||
urlParams,
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
apmToTraceQuery,
|
||||
JSONCompositeQuery,
|
||||
queryString,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import { TopOperationList } from './TopOperationsTable';
|
||||
@@ -30,11 +29,13 @@ export const navigateToTrace = ({
|
||||
);
|
||||
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
|
||||
|
||||
const newTraceExplorerPath = `${
|
||||
ROUTES.TRACES_EXPLORER
|
||||
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${serialize(
|
||||
apmToTraceQuery,
|
||||
).toString()}`;
|
||||
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}`;
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(withBasePath(newTraceExplorerPath), '_blank');
|
||||
|
||||
@@ -33,7 +33,6 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
@@ -792,7 +791,9 @@ function NewWidget({
|
||||
const queryParams = {
|
||||
[QueryParams.expandedWidgetId]: widgetId,
|
||||
[QueryParams.graphType]: graphType,
|
||||
...serializeToParams(currentQuery),
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(currentQuery),
|
||||
),
|
||||
[QueryParams.variables]: variables,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -50,7 +49,7 @@ const useBaseDrilldownNavigate = ({
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
...serializeToParams(viewQuery),
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
@@ -95,7 +94,7 @@ export function buildDrilldownUrl(
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
...serializeToParams(viewQuery),
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
|
||||
@@ -35,11 +35,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import {
|
||||
applySerializedParams,
|
||||
deserialize,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import AutoRefresh from '../AutoRefreshV2';
|
||||
@@ -283,7 +278,7 @@ function DateTimeSelection({
|
||||
return `Refreshed ${secondsDiff} sec ago`;
|
||||
}, [maxTime, minTime, selectedTime]);
|
||||
|
||||
const getUpdatedCompositeQuery = useCallback((): URLSearchParams => {
|
||||
const getUpdatedCompositeQuery = useCallback((): string => {
|
||||
let updatedCompositeQuery = cloneDeep(currentQuery);
|
||||
updatedCompositeQuery.id = uuid();
|
||||
// Remove the filters
|
||||
@@ -304,7 +299,7 @@ function DateTimeSelection({
|
||||
})),
|
||||
},
|
||||
};
|
||||
return serialize(updatedCompositeQuery);
|
||||
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
|
||||
}, [currentQuery]);
|
||||
|
||||
const onSelectHandler = useCallback(
|
||||
@@ -339,9 +334,9 @@ function DateTimeSelection({
|
||||
// Remove Hidden Filters from URL query parameters on time change
|
||||
urlQuery.delete(QueryParams.activeLogId);
|
||||
|
||||
const staledQuery = deserialize(urlQuery);
|
||||
if (staledQuery) {
|
||||
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
|
||||
if (urlQuery.has(QueryParams.compositeQuery)) {
|
||||
const updatedCompositeQuery = getUpdatedCompositeQuery();
|
||||
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
|
||||
}
|
||||
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
@@ -429,9 +424,9 @@ function DateTimeSelection({
|
||||
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
|
||||
const staledQuery = deserialize(urlQuery);
|
||||
if (staledQuery) {
|
||||
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
|
||||
if (urlQuery.has(QueryParams.compositeQuery)) {
|
||||
const updatedCompositeQuery = getUpdatedCompositeQuery();
|
||||
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
|
||||
}
|
||||
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
areUrlsEffectivelySame,
|
||||
isDefaultNavigation,
|
||||
} from 'hooks/useSafeNavigate.utils';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const BASE = 'http://localhost';
|
||||
|
||||
const urlFrom = (pathname: string, params?: URLSearchParams): URL => {
|
||||
const search = params?.toString();
|
||||
const query = search ? `?${search}` : '';
|
||||
return new URL(`${pathname}${query}`, BASE);
|
||||
};
|
||||
|
||||
/** Build params containing the serialized `compositeQuery` plus any extras. */
|
||||
const withQuery = (
|
||||
query: Query,
|
||||
extra: Record<string, string> = {},
|
||||
): URLSearchParams => {
|
||||
const params = serialize(query);
|
||||
Object.entries(extra).forEach(([key, value]) => params.set(key, value));
|
||||
return params;
|
||||
};
|
||||
|
||||
describe('areUrlsEffectivelySame', () => {
|
||||
it('returns false when pathnames differ', () => {
|
||||
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/traces'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true for two identical param-less URLs', () => {
|
||||
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/logs'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when only the compositeQuery is present and identical', () => {
|
||||
const params = withQuery(initialQueriesMap.logs);
|
||||
expect(
|
||||
areUrlsEffectivelySame(
|
||||
urlFrom('/logs', params),
|
||||
urlFrom('/logs', new URLSearchParams(params.toString())),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Regression: a matching compositeQuery must NOT mask differences in other
|
||||
// params. Previously every param was compared via the decoded query, so any
|
||||
// two URLs sharing a compositeQuery were judged identical.
|
||||
it('returns false when compositeQuery matches but another param differs', () => {
|
||||
const url1 = urlFrom(
|
||||
'/logs',
|
||||
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
|
||||
);
|
||||
const url2 = urlFrom(
|
||||
'/logs',
|
||||
withQuery(initialQueriesMap.logs, { startTime: '2000' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when compositeQuery matches but a param exists on only one URL', () => {
|
||||
const url1 = urlFrom(
|
||||
'/logs',
|
||||
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
|
||||
);
|
||||
const url2 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
|
||||
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores the volatile id when comparing compositeQuery', () => {
|
||||
const url1 = urlFrom(
|
||||
'/logs',
|
||||
withQuery({ ...initialQueriesMap.logs, id: 'id-1' }),
|
||||
);
|
||||
const url2 = urlFrom(
|
||||
'/logs',
|
||||
withQuery({ ...initialQueriesMap.logs, id: 'id-2' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(url1, url2)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when compositeQuery is semantically different', () => {
|
||||
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
|
||||
const url2 = urlFrom('/metrics', withQuery(initialQueriesMap.metrics));
|
||||
// Force same pathname so only the query differs.
|
||||
expect(
|
||||
areUrlsEffectivelySame(
|
||||
url1,
|
||||
urlFrom('/logs', new URLSearchParams(url2.search)),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when compositeQuery exists on only one URL', () => {
|
||||
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
|
||||
const url2 = urlFrom('/logs');
|
||||
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
|
||||
});
|
||||
|
||||
it('compares non-compositeQuery params directly when no compositeQuery is present', () => {
|
||||
const same1 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ startTime: '1', endTime: '2' }),
|
||||
);
|
||||
const same2 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ startTime: '1', endTime: '2' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(same1, same2)).toBe(true);
|
||||
|
||||
const diff = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ startTime: '1', endTime: '3' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(same1, diff)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to raw comparison when compositeQuery cannot be decoded', () => {
|
||||
const corrupt1 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
|
||||
);
|
||||
const corrupt2 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(corrupt1, corrupt2)).toBe(true);
|
||||
|
||||
const corrupt3 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ compositeQuery: '%7Bother' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(corrupt1, corrupt3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDefaultNavigation', () => {
|
||||
it('returns false for different pathnames', () => {
|
||||
expect(isDefaultNavigation(urlFrom('/logs'), urlFrom('/traces'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when a clean URL gains params', () => {
|
||||
expect(
|
||||
isDefaultNavigation(
|
||||
urlFrom('/logs'),
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when the target introduces a new param key', () => {
|
||||
expect(
|
||||
isDefaultNavigation(
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '1', endTime: '2' })),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the target has no new param keys', () => {
|
||||
expect(
|
||||
isDefaultNavigation(
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '9' })),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
@@ -12,7 +13,6 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -58,14 +58,9 @@ export const useActiveLog = (): UseActiveLog => {
|
||||
|
||||
const [activeLog, setActiveLog] = useState<ILog | null>(null);
|
||||
|
||||
// Close drawer/clear active log when query in URL changes. Track the decoded
|
||||
// query (not a single raw param) so it stays correct across serializer tiers
|
||||
// that explode the query into many keys.
|
||||
// Close drawer/clear active log when query in URL changes
|
||||
const urlQuery = useUrlQuery();
|
||||
const compositeQuery = useMemo(() => {
|
||||
const decoded = deserialize(urlQuery);
|
||||
return decoded ? JSON.stringify(decoded) : '';
|
||||
}, [urlQuery]);
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
|
||||
const prevQueryRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useMutation } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import useCreateAlerts from '../useCreateAlerts';
|
||||
@@ -80,14 +79,14 @@ const buildWidget = (queryType: EQueryType | undefined): Widgets =>
|
||||
},
|
||||
}) as unknown as Widgets;
|
||||
|
||||
const getCompositeQueryFromLastOpen = (): Query => {
|
||||
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
|
||||
const [url] = (window.open as jest.Mock).mock.calls[0];
|
||||
const query = new URLSearchParams((url as string).split('?')[1]);
|
||||
const composite = deserialize(query);
|
||||
if (!composite) {
|
||||
const raw = query.get(QueryParams.compositeQuery);
|
||||
if (!raw) {
|
||||
throw new Error('compositeQuery not found in URL');
|
||||
}
|
||||
return composite;
|
||||
return JSON.parse(decodeURIComponent(raw));
|
||||
};
|
||||
|
||||
describe('useCreateAlerts', () => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
|
||||
let mockUrlQuery = new URLSearchParams();
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => mockUrlQuery,
|
||||
}));
|
||||
|
||||
describe('useGetCompositeQueryParam', () => {
|
||||
it('decodes a legacy compositeQuery param', () => {
|
||||
mockUrlQuery = new URLSearchParams({
|
||||
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
|
||||
});
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam());
|
||||
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('returns null when the param is absent', () => {
|
||||
mockUrlQuery = new URLSearchParams();
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,6 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
@@ -90,7 +86,10 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
applySerializedParams(serialize(updatedQuery), params);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(updatedQuery)),
|
||||
);
|
||||
params.set(QueryParams.panelTypes, widget.panelTypes);
|
||||
params.set(QueryParams.version, ENTITY_VERSION_V5);
|
||||
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
|
||||
|
||||
@@ -1,10 +1,72 @@
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useMemo(() => deserialize(urlQuery), [urlQuery]);
|
||||
return useMemo(() => {
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
|
||||
let parsedCompositeQuery: Query | null = null;
|
||||
|
||||
try {
|
||||
if (!compositeQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
|
||||
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
|
||||
parsedCompositeQuery = JSON.parse(
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
parsedCompositeQuery.builder.queryData.map((query) => {
|
||||
const existingExpression = query.filter?.expression || '';
|
||||
const convertedQuery = { ...query };
|
||||
|
||||
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters || { items: [], op: 'AND' },
|
||||
existingExpression,
|
||||
);
|
||||
convertedQuery.filter = convertedFilter.filter;
|
||||
convertedQuery.filters = convertedFilter.filters;
|
||||
|
||||
// Convert having if needed
|
||||
if (Array.isArray(query.having)) {
|
||||
const convertedHaving = convertHavingToExpression(query.having);
|
||||
convertedQuery.having = convertedHaving;
|
||||
}
|
||||
|
||||
// Convert aggregation if needed
|
||||
if (!query.aggregations && query.aggregateOperator) {
|
||||
const convertedAggregation = convertAggregationToExpression({
|
||||
aggregateOperator: query.aggregateOperator,
|
||||
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
|
||||
dataSource: query.dataSource,
|
||||
timeAggregation: query.timeAggregation,
|
||||
spaceAggregation: query.spaceAggregation,
|
||||
reduceTo: query.reduceTo,
|
||||
temporality: query.temporality,
|
||||
}) as any; // Type assertion to handle union type
|
||||
convertedQuery.aggregations = convertedAggregation;
|
||||
}
|
||||
return convertedQuery;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
parsedCompositeQuery = null;
|
||||
}
|
||||
|
||||
return parsedCompositeQuery;
|
||||
}, [urlQuery]);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
areUrlsEffectivelySame,
|
||||
isDefaultNavigation,
|
||||
} from 'hooks/useSafeNavigate.utils';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
interface NavigateOptions {
|
||||
@@ -21,6 +18,77 @@ interface UseSafeNavigateProps {
|
||||
preventSameUrlNavigation?: boolean;
|
||||
}
|
||||
|
||||
const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
|
||||
if (url1.pathname !== url2.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params1 = new URLSearchParams(url1.search);
|
||||
const params2 = new URLSearchParams(url2.search);
|
||||
|
||||
const allParams = new Set([...params1.keys(), ...params2.keys()]);
|
||||
|
||||
return [...allParams].every((param) => {
|
||||
if (param === 'compositeQuery') {
|
||||
try {
|
||||
const query1 = params1.get('compositeQuery');
|
||||
const query2 = params2.get('compositeQuery');
|
||||
|
||||
if (!query1 || !query2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded1 = JSON.parse(decodeURIComponent(query1));
|
||||
const decoded2 = JSON.parse(decodeURIComponent(query2));
|
||||
|
||||
const filtered1 = cloneDeep(decoded1);
|
||||
const filtered2 = cloneDeep(decoded2);
|
||||
|
||||
delete filtered1.id;
|
||||
delete filtered2.id;
|
||||
|
||||
return isEqual(filtered1, filtered2);
|
||||
} catch (error) {
|
||||
console.warn('Error comparing compositeQuery:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return params1.get(param) === params2.get(param);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if this navigation is adding default/initial parameters
|
||||
* Returns true if:
|
||||
* 1. We're staying on the same page (same pathname)
|
||||
* 2. Either:
|
||||
* - Current URL has no params and target URL has params, or
|
||||
* - Target URL has new params that didn't exist in current URL
|
||||
*/
|
||||
const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
|
||||
// Different pathnames means it's not a default navigation
|
||||
if (currentUrl.pathname !== targetUrl.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentParams = new URLSearchParams(currentUrl.search);
|
||||
const targetParams = new URLSearchParams(targetUrl.search);
|
||||
|
||||
// Case 1: Clean URL getting params for the first time
|
||||
if (!currentParams.toString() && targetParams.toString()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case 2: Check for new params that didn't exist before
|
||||
const currentKeys = new Set(currentParams.keys());
|
||||
const targetKeys = new Set(targetParams.keys());
|
||||
|
||||
// Find keys that exist in target but not in current
|
||||
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
|
||||
|
||||
return newKeys.length > 0;
|
||||
};
|
||||
export const useSafeNavigate = (
|
||||
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
|
||||
preventSameUrlNavigation: true,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* Compare the (optional) `compositeQuery` param of two URLSearchParams
|
||||
* semantically. Its serialized form is not byte-stable — the volatile `id` and
|
||||
* the adapter choice both vary — so we decode and deep-compare, ignoring `id`.
|
||||
*
|
||||
* compositeQuery is not guaranteed to be present: absent on both sides counts
|
||||
* as equal, present on only one side counts as different. When either side is
|
||||
* present but can't be decoded, we fall back to comparing the raw values.
|
||||
*/
|
||||
const compositeQueriesEqual = (
|
||||
params1: URLSearchParams,
|
||||
params2: URLSearchParams,
|
||||
): boolean => {
|
||||
const raw1 = params1.get(COMPOSITE_QUERY_KEY);
|
||||
const raw2 = params2.get(COMPOSITE_QUERY_KEY);
|
||||
|
||||
if (!raw1 && !raw2) {
|
||||
return true;
|
||||
}
|
||||
if (!raw1 || !raw2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded1 = deserialize(params1);
|
||||
const decoded2 = deserialize(params2);
|
||||
|
||||
if (decoded1 && decoded2) {
|
||||
// Ignore the volatile `id` when comparing queries.
|
||||
const { id: _id1, ...rest1 } = decoded1;
|
||||
const { id: _id2, ...rest2 } = decoded2;
|
||||
|
||||
return isEqual(rest1, rest2);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error comparing compositeQuery:', error);
|
||||
}
|
||||
|
||||
// One or both could not be decoded — compare the raw encoded values.
|
||||
return raw1 === raw2;
|
||||
};
|
||||
|
||||
export const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
|
||||
if (url1.pathname !== url2.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params1 = new URLSearchParams(url1.search);
|
||||
const params2 = new URLSearchParams(url2.search);
|
||||
|
||||
// The compositeQuery is compared semantically (it round-trips through a
|
||||
// non-stable serialized form); every other param is compared by raw value.
|
||||
if (!compositeQueriesEqual(params1, params2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const otherKeys = new Set(
|
||||
[...params1.keys(), ...params2.keys()].filter(
|
||||
(key) => key !== COMPOSITE_QUERY_KEY,
|
||||
),
|
||||
);
|
||||
|
||||
return [...otherKeys].every((key) => params1.get(key) === params2.get(key));
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if this navigation is adding default/initial parameters
|
||||
* Returns true if:
|
||||
* 1. We're staying on the same page (same pathname)
|
||||
* 2. Either:
|
||||
* - Current URL has no params and target URL has params, or
|
||||
* - Target URL has new params that didn't exist in current URL
|
||||
*/
|
||||
export const isDefaultNavigation = (
|
||||
currentUrl: URL,
|
||||
targetUrl: URL,
|
||||
): boolean => {
|
||||
// Different pathnames means it's not a default navigation
|
||||
if (currentUrl.pathname !== targetUrl.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentParams = new URLSearchParams(currentUrl.search);
|
||||
const targetParams = new URLSearchParams(targetUrl.search);
|
||||
|
||||
// Case 1: Clean URL getting params for the first time
|
||||
if (!currentParams.toString() && targetParams.toString()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case 2: Check for new params that didn't exist before
|
||||
const currentKeys = new Set(currentParams.keys());
|
||||
const targetKeys = new Set(targetParams.keys());
|
||||
|
||||
// Find keys that exist in target but not in current
|
||||
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
|
||||
|
||||
return newKeys.length > 0;
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
|
||||
import {
|
||||
clearSerializedParams,
|
||||
deserialize,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
|
||||
describe('composite query serializer', () => {
|
||||
it('round-trips through serialize/deserialize', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const decoded = deserialize(serialize(query));
|
||||
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('returns null on corrupt input instead of throwing', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
|
||||
expect(deserialize(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty/missing value', () => {
|
||||
const params = new URLSearchParams();
|
||||
expect(deserialize(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves id field through roundtrip', () => {
|
||||
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
|
||||
const serialized = serialize(query);
|
||||
const decoded = deserialize(serialized);
|
||||
expect(decoded?.id).toBe('test-query-uuid-123');
|
||||
});
|
||||
|
||||
it('clearSerializedParams purges every serialized key, leaving others intact', () => {
|
||||
const params = serialize(initialQueriesMap.logs);
|
||||
params.set('panelTypes', 'list');
|
||||
clearSerializedParams(params);
|
||||
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
|
||||
expect(deserialize(params)).toBeNull();
|
||||
expect(params.get('panelTypes')).toBe('list');
|
||||
});
|
||||
|
||||
it('clearSerializedParams drops a corrupt legacy key via fallback', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
|
||||
params.set('panelTypes', 'list');
|
||||
clearSerializedParams(params);
|
||||
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
|
||||
expect(params.get('panelTypes')).toBe('list');
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
CompositeQueryAdapter,
|
||||
COMPOSITE_QUERY_KEY,
|
||||
} from 'lib/compositeQuery/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
function migrateLegacyFormat(parsed: Query): Query {
|
||||
if (!parsed?.builder?.queryData) {
|
||||
return parsed;
|
||||
}
|
||||
const next = parsed;
|
||||
next.builder.queryData = parsed.builder.queryData.map((query) => {
|
||||
const existingExpression = query.filter?.expression || '';
|
||||
const convertedQuery = { ...query };
|
||||
|
||||
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters || { items: [], op: 'AND' },
|
||||
existingExpression,
|
||||
);
|
||||
convertedQuery.filter = convertedFilter.filter;
|
||||
convertedQuery.filters = convertedFilter.filters;
|
||||
|
||||
if (Array.isArray(query.having)) {
|
||||
convertedQuery.having = convertHavingToExpression(query.having);
|
||||
}
|
||||
|
||||
if (!query.aggregations && query.aggregateOperator) {
|
||||
convertedQuery.aggregations = convertAggregationToExpression({
|
||||
aggregateOperator: query.aggregateOperator,
|
||||
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
|
||||
dataSource: query.dataSource,
|
||||
timeAggregation: query.timeAggregation,
|
||||
spaceAggregation: query.spaceAggregation,
|
||||
reduceTo: query.reduceTo,
|
||||
temporality: query.temporality,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
}
|
||||
return convertedQuery;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export const jsonAdapter: CompositeQueryAdapter = {
|
||||
name: 'json(legacy)',
|
||||
encode: (query) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(query));
|
||||
return params;
|
||||
},
|
||||
matches: () => true,
|
||||
decode: (params) => {
|
||||
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
|
||||
let parsed: Query;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
parsed = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
|
||||
}
|
||||
return migrateLegacyFormat(parsed);
|
||||
},
|
||||
};
|
||||
@@ -1,211 +0,0 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { jsonAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query =>
|
||||
jsonAdapter.decode(jsonAdapter.encode(query));
|
||||
|
||||
describe('jsonAdapter', () => {
|
||||
describe('round-trip', () => {
|
||||
it.each(['metrics', 'logs', 'traces'] as const)(
|
||||
'round-trips %s baseline preserving dataSource',
|
||||
(source) => {
|
||||
const query = initialQueriesMap[source];
|
||||
const decoded = roundTrip(query);
|
||||
expect(decoded.builder.queryData[0].dataSource).toBe(source);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('encoding', () => {
|
||||
it('encodes using single URL encoding via URLSearchParams', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const params = jsonAdapter.encode(query);
|
||||
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
|
||||
|
||||
// URLSearchParams.get() returns decoded value, so raw === JSON string
|
||||
expect(raw).toBe(JSON.stringify(query));
|
||||
expect(raw.startsWith('{')).toBe(true);
|
||||
|
||||
// Full URL shows single encoding
|
||||
const fullUrl = params.toString();
|
||||
expect(fullUrl).toContain('%7B'); // encoded {
|
||||
expect(fullUrl).not.toContain('%257B'); // NOT double-encoded
|
||||
});
|
||||
|
||||
it('decode handles single-encoded format (current)', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(query));
|
||||
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy double-encoded fallback', () => {
|
||||
it('decode handles double-encoded format (legacy URLs)', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
// Simulate legacy: JSON -> encodeURIComponent -> set as raw param
|
||||
const doubleEncoded = encodeURIComponent(JSON.stringify(query));
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
|
||||
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('double-encoded with special chars decodes correctly', () => {
|
||||
const queryWithSpecialChars = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
key: { key: 'message', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'hello world & foo=bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const doubleEncoded = encodeURIComponent(
|
||||
JSON.stringify(queryWithSpecialChars),
|
||||
);
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
|
||||
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
const filter = decoded.builder.queryData[0].filters?.items[0];
|
||||
expect(filter?.value).toBe('hello world & foo=bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plus-sign handling', () => {
|
||||
it('plus signs in double-encoded URLs decode as spaces', () => {
|
||||
// In URL encoding, + represents space. Legacy URLs may have this.
|
||||
const query = { queryType: 'builder', test: 'hello world' };
|
||||
// Manually create double-encoded with + for space
|
||||
const jsonStr = JSON.stringify(query);
|
||||
const encoded = encodeURIComponent(jsonStr).replace(/%20/g, '+');
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, encoded);
|
||||
|
||||
const decoded = jsonAdapter.decode(params) as any;
|
||||
expect(decoded.test).toBe('hello world');
|
||||
});
|
||||
|
||||
it('plus signs in filter values preserved after decode', () => {
|
||||
// Value literally contains + (not space)
|
||||
const queryWithPlus = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
key: { key: 'expr', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: '1+2=3',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Current format (single encode) - + becomes %2B
|
||||
const params = jsonAdapter.encode(queryWithPlus as Query);
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded.builder.queryData[0].filters?.items[0]?.value).toBe('1+2=3');
|
||||
});
|
||||
|
||||
it('legacy double-encoded + in values preserved', () => {
|
||||
const queryWithPlus = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [{ key: { key: 'x' }, op: '=', value: 'a+b' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'x',
|
||||
unit: '',
|
||||
};
|
||||
// Double encode: + in JSON becomes %2B, then %252B
|
||||
const doubleEncoded = encodeURIComponent(JSON.stringify(queryWithPlus));
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
|
||||
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded.builder.queryData[0].filters?.items[0]?.value).toBe('a+b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('matches any value (catch-all fallback)', () => {
|
||||
const params1 = new URLSearchParams();
|
||||
params1.set(COMPOSITE_QUERY_KEY, '%7B%22queryType%22%3A%22builder%22%7D');
|
||||
expect(jsonAdapter.matches(params1)).toBe(true);
|
||||
|
||||
const params2 = new URLSearchParams();
|
||||
params2.set(COMPOSITE_QUERY_KEY, 'z1~abc');
|
||||
expect(jsonAdapter.matches(params2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration', () => {
|
||||
it('migrates old format (filters -> filter.expression)', () => {
|
||||
const legacy = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { op: 'AND', items: [] },
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: '', dataType: '', type: '' },
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'x',
|
||||
unit: '',
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(legacy));
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded.builder.queryData[0].filter).toBeDefined();
|
||||
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
|
||||
import {
|
||||
COMPOSITE_QUERY_KEY,
|
||||
CompositeQueryAdapter,
|
||||
} from 'lib/compositeQuery/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
// Order matters for decode: most-specific (tagged) adapters first
|
||||
const ADAPTERS: CompositeQueryAdapter[] = [jsonAdapter];
|
||||
|
||||
// Pick the adapter that owns a given URL. json's `matches` is always true, so
|
||||
// it serves as the final fallback when no tagged adapter claims the params.
|
||||
function adapterFor(params: URLSearchParams): CompositeQueryAdapter {
|
||||
return ADAPTERS.find((adapter) => adapter.matches(params)) ?? jsonAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a query to the shortest available URLSearchParams.
|
||||
*/
|
||||
export function serialize(query: Query): URLSearchParams {
|
||||
return ADAPTERS[0].encode(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode URLSearchParams back to a Query. Total: returns null on any failure.
|
||||
*/
|
||||
export function deserialize(params: URLSearchParams): Query | null {
|
||||
const hasParams = Array.from(params.keys()).length > 0;
|
||||
if (!hasParams) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return adapterFor(params).decode(params);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all params from source into target URLSearchParams.
|
||||
*/
|
||||
export function applySerializedParams(
|
||||
source: URLSearchParams,
|
||||
target: URLSearchParams,
|
||||
): void {
|
||||
source.forEach((value, key) => target.set(key, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every serialized-query param from target URLSearchParams. Use instead
|
||||
* of `target.delete('compositeQuery')` so a stale query is fully purged even
|
||||
* for adapters that explode a query into many content-dependent keys (e.g.
|
||||
* `query0.ds`, `query0.fl.it.0.key.key`) which can't be listed statically.
|
||||
*
|
||||
* Keys are discovered by round-trip: decode the current params with their
|
||||
* owning adapter, re-encode, then delete exactly the keys encoding produces.
|
||||
* If the params don't decode (absent/corrupt), fall back to dropping the legacy
|
||||
* single key so a stale `compositeQuery` is still cleared.
|
||||
*/
|
||||
export function clearSerializedParams(target: URLSearchParams): void {
|
||||
const adapter = adapterFor(target);
|
||||
try {
|
||||
adapter.encode(adapter.decode(target)).forEach((_value, key) => {
|
||||
target.delete(key);
|
||||
});
|
||||
} catch {
|
||||
target.delete(COMPOSITE_QUERY_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a query to a plain record of all URL params it produces. Use when
|
||||
* building a query-param object manually (e.g. for `createQueryParams`), so the
|
||||
* call site carries every param the adapter emits — not just `compositeQuery`.
|
||||
* Spread it: `{ ...serializeToParams(query), startTime, endTime }`.
|
||||
*/
|
||||
export function serializeToParams(query: Query): Record<string, string> {
|
||||
return Object.fromEntries(serialize(query));
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const COMPOSITE_QUERY_KEY = 'compositeQuery';
|
||||
|
||||
/**
|
||||
* A serialization tier. `encode` returns URLSearchParams (default key =
|
||||
* `compositeQuery`). `matches` checks if params belong to this adapter.
|
||||
* `decode` receives URLSearchParams and returns Query.
|
||||
*/
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): URLSearchParams;
|
||||
matches(params: URLSearchParams): boolean;
|
||||
decode(params: URLSearchParams): Query;
|
||||
}
|
||||
@@ -176,6 +176,15 @@ export const handlers = [
|
||||
res(ctx.status(200), ctx.json(getDashboardById)),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/users', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: { id: 'user-123' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v1/invite', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
|
||||
@@ -15,7 +15,6 @@ import EditRulesContainer from 'container/EditRules';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
|
||||
import history from 'lib/history';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
@@ -50,7 +49,7 @@ function EditRules(): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const clickHandler = (): void => {
|
||||
clearSerializedParams(params);
|
||||
params.delete(QueryParams.compositeQuery);
|
||||
params.delete(QueryParams.panelTypes);
|
||||
params.delete(QueryParams.ruleId);
|
||||
params.delete(QueryParams.relativeTime);
|
||||
|
||||
@@ -28,10 +28,6 @@ import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
@@ -250,7 +246,7 @@ function SpanDetailsContent({
|
||||
};
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
applySerializedParams(serialize(compositeQuery as any), searchParams);
|
||||
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
|
||||
searchParams.set(QueryParams.startTime, startTimeMs.toString());
|
||||
searchParams.set(QueryParams.endTime, endTimeMs.toString());
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -139,7 +138,7 @@ function SpanLogs({
|
||||
[QueryParams.activeLogId]: `"${log.id}"`,
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
...serializeToParams(updatedQuery),
|
||||
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
|
||||
};
|
||||
|
||||
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
|
||||
|
||||
@@ -38,10 +38,6 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
@@ -994,7 +990,10 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
}
|
||||
|
||||
applySerializedParams(serialize(currentGeneratedQuery), urlQuery);
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
|
||||
);
|
||||
|
||||
if (searchParams) {
|
||||
Object.keys(searchParams).forEach((param) =>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { generatePath } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
type GenerateExportToDashboardLinkParams = {
|
||||
@@ -22,4 +21,6 @@ export const generateExportToDashboardLink = ({
|
||||
dashboardId,
|
||||
})}/new?${QueryParams.graphType}=${panelType}&${
|
||||
QueryParams.widgetId
|
||||
}=${widgetId}&${serialize(query).toString()}`;
|
||||
}=${widgetId}&${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
JSON.stringify(query),
|
||||
)}`;
|
||||
|
||||
Reference in New Issue
Block a user