Compare commits

..

3 Commits

Author SHA1 Message Date
Abhi Kumar
0b42daf39f test: added tests for table/value/list panels 2026-05-18 10:51:33 +05:30
Abhi kumar
9cba7e88ec Merge branch 'main' into e2e/dashboard-create-flow 2026-05-18 00:19:17 +05:30
Abhi Kumar
e4949379e2 test: added e2e tests for dashboard create flow 2026-05-18 00:11:35 +05:30
46 changed files with 2271 additions and 853 deletions

View File

@@ -166,8 +166,6 @@ function createMockAppContext(
userPreferences: [],
hostsData: null,
isLoggedIn: true,
isNoAuthMode: false,
isPreflightLoading: false,
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
isFetchingUser: false,
isFetchingActiveLicense: false,

View File

@@ -59,7 +59,6 @@ function App(): JSX.Element {
isLoggedIn: isLoggedInState,
featureFlags,
org,
isPreflightLoading,
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
@@ -387,10 +386,6 @@ function App(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCloudUser, isEnterpriseSelfHostedUser]);
if (isPreflightLoading) {
return <Spinner tip="Loading..." />;
}
// if the user is in logged in state
if (isLoggedInState) {
// if the setup calls are loading then return a spinner

View File

@@ -1,72 +0,0 @@
import axios from 'axios';
import { getIsNoAuthMode } from 'utils/noAuthMode';
import { interceptorRejected } from '../index';
jest.mock('utils/noAuthMode', () => ({
getIsNoAuthMode: jest.fn(),
}));
jest.mock('api/v2/sessions/rotate/post', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('AppRoutes/utils', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../utils', () => ({
Logout: jest.fn(),
}));
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
const post = require('api/v2/sessions/rotate/post').default;
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
const { Logout } = require('../utils');
describe('interceptorRejected — no-auth mode', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(axios, 'isAxiosError').mockReturnValue(true);
});
it('does NOT call rotate or Logout when no-auth mode is enabled on 401', async () => {
(getIsNoAuthMode as jest.Mock).mockReturnValue(true);
const error = {
isAxiosError: true,
response: {
status: 401,
config: { url: '/dashboards', method: 'get' },
},
config: { url: '/dashboards', headers: {} },
};
await interceptorRejected(error as any).catch(() => {});
expect(post).not.toHaveBeenCalled();
expect(Logout).not.toHaveBeenCalled();
});
it('DOES attempt rotate when no-auth mode is disabled on 401', async () => {
(getIsNoAuthMode as jest.Mock).mockReturnValue(false);
(post as jest.Mock).mockResolvedValue({
data: { accessToken: 'a', refreshToken: 'b' },
});
const error = {
isAxiosError: true,
response: {
status: 401,
config: { url: '/dashboards', method: 'get' },
},
config: { url: '/dashboards', headers: {} },
};
await interceptorRejected(error as any).catch(() => {});
expect(post).toHaveBeenCalled();
});
});

View File

@@ -13,7 +13,6 @@ import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import { getIsNoAuthMode } from 'utils/noAuthMode';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
import { Logout } from './utils';
@@ -109,10 +108,7 @@ export const interceptorRejected = async (
if (axios.isAxiosError(value) && value.response) {
const { response } = value;
const isNoAuthMode = getIsNoAuthMode();
if (
!isNoAuthMode &&
response.status === 401 &&
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry!
response.config.url !== '/sessions/rotate' &&
@@ -144,20 +140,16 @@ export const interceptorRejected = async (
return await Promise.resolve(reResponse);
} catch (error) {
if ((error as AxiosError)?.response?.status === 401) {
void Logout();
Logout();
}
}
} catch (error) {
void Logout();
Logout();
}
}
if (
!isNoAuthMode &&
response.status === 401 &&
response.config.url === '/sessions/rotate'
) {
void Logout();
if (response.status === 401 && response.config.url === '/sessions/rotate') {
Logout();
}
}
return await Promise.reject(value);

View File

@@ -19,7 +19,6 @@ import {
} from 'api/generated/services/users';
import { AxiosError } from 'axios';
import { MemberRow } from 'components/MembersTable/MembersTable';
import { NoAuthGuard } from 'components/NoAuthGuard';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import SaveErrorItem from 'components/ServiceAccountDrawer/SaveErrorItem';
import type { SaveError } from 'components/ServiceAccountDrawer/utils';
@@ -614,43 +613,39 @@ function EditMemberDrawer({
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<NoAuthGuard>
<Button
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
variant="link"
color="destructive"
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</NoAuthGuard>
<Button
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
variant="link"
color="destructive"
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<NoAuthGuard>
<Button
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
variant="link"
color="warning"
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? getInviteButtonLabel(
isLoadingTokenStatus,
existingToken,
isTokenExpired,
tokenNotFound,
)
: 'Generate Password Reset Link'}
</Button>
</NoAuthGuard>
<Button
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
variant="link"
color="warning"
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? getInviteButtonLabel(
isLoadingTokenStatus,
existingToken,
isTokenExpired,
tokenNotFound,
)
: 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
</div>
@@ -661,17 +656,15 @@ function EditMemberDrawer({
Cancel
</Button>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</NoAuthGuard>
<Button
variant="solid"
color="primary"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
</>
)}

View File

@@ -1,3 +0,0 @@
.banner {
height: var(--spacing-20);
}

View File

@@ -1,18 +0,0 @@
import { PersistedAnnouncementBanner } from '@signozhq/ui/announcement-banner';
import styles from './NoAuthBanner.module.scss';
export function NoAuthBanner(): JSX.Element {
return (
<PersistedAnnouncementBanner
type="warning"
storageKey="no-auth-banner-v1"
testId="no-auth-banner"
className={styles.banner}
>
No-auth mode: authentication is disabled, network is the trust boundary.
</PersistedAnnouncementBanner>
);
}
export default NoAuthBanner;

View File

@@ -1,17 +0,0 @@
import { render, screen } from 'tests/test-utils';
import { NoAuthBanner } from '../NoAuthBanner';
describe('NoAuthBanner', () => {
it('renders the no-auth message', () => {
render(<NoAuthBanner />);
expect(
screen.getByText(/No-auth mode: authentication is disabled/i),
).toBeInTheDocument();
});
it('renders with the warning test id', () => {
render(<NoAuthBanner />);
expect(screen.getByTestId('no-auth-banner')).toBeInTheDocument();
});
});

View File

@@ -1,46 +0,0 @@
import React from 'react';
import {
TooltipRoot,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { useAppContext } from 'providers/App/App';
export const DEFAULT_MESSAGE = 'Not available in no-auth mode';
interface NoAuthGuardProps {
children: React.ReactElement;
message?: string;
disabled?: boolean;
}
export function NoAuthGuard({
children,
message = DEFAULT_MESSAGE,
disabled,
}: NoAuthGuardProps): JSX.Element {
const { isNoAuthMode } = useAppContext();
if (!isNoAuthMode) {
return disabled ? React.cloneElement(children, { disabled: true }) : children;
}
const disabledChild = React.cloneElement(children, { disabled: true });
return (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<span
data-no-auth-trigger
style={{ display: 'inline-flex', cursor: 'not-allowed' }}
>
{disabledChild}
</span>
</TooltipTrigger>
<TooltipContent>{message}</TooltipContent>
</TooltipRoot>
</TooltipProvider>
);
}

View File

@@ -1,75 +0,0 @@
import React from 'react';
import { render } from 'tests/test-utils';
import { DEFAULT_MESSAGE, NoAuthGuard } from '..';
describe('NoAuthGuard', () => {
it('renders children unchanged when isNoAuthMode is false', () => {
const { getByRole } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: false } },
);
expect(getByRole('button', { name: 'Action' })).not.toBeDisabled();
});
it('does not intercept onClick when isNoAuthMode is false', () => {
const handleClick = jest.fn();
const { getByRole } = render(
<NoAuthGuard>
<button type="button" onClick={handleClick}>
Action
</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: false } },
);
getByRole('button', { name: 'Action' }).click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('disables children when isNoAuthMode is true', () => {
const { getByRole } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('renders a tooltip trigger wrapper when isNoAuthMode is true', () => {
const { container } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(
container.querySelector('span[data-no-auth-trigger]'),
).toBeInTheDocument();
});
it('overrides existing disabled prop — no-auth always wins', () => {
const { getByRole } = render(
// eslint-disable-next-line react/button-has-type
<NoAuthGuard>
<button type="button" disabled={false}>
Action
</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('exports DEFAULT_MESSAGE as a non-empty string', () => {
expect(typeof DEFAULT_MESSAGE).toBe('string');
expect(DEFAULT_MESSAGE.length).toBeGreaterThan(0);
});
});

View File

@@ -1 +0,0 @@
export { DEFAULT_MESSAGE, NoAuthGuard } from './NoAuthGuard';

View File

@@ -1,9 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import { KeyRound, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Skeleton, Table, Tooltip } from 'antd';
import { DEFAULT_MESSAGE, NoAuthGuard } from 'components/NoAuthGuard';
import { useAppContext } from 'providers/App/App';
import { Skeleton, Table } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
@@ -35,7 +33,6 @@ interface KeysTabProps {
interface BuildColumnsParams {
isDisabled: boolean;
accountId: string;
isNoAuthMode: boolean;
onRevokeClick: (keyId: string) => void;
handleformatLastObservedAt: (
lastObservedAt: Date | null | undefined,
@@ -56,7 +53,6 @@ function formatExpiry(expiresAt: number): JSX.Element {
function buildColumns({
isDisabled,
accountId,
isNoAuthMode,
onRevokeClick,
handleformatLastObservedAt,
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
@@ -114,38 +110,28 @@ function buildColumns({
onClick: (e): void => e.stopPropagation(),
style: { cursor: 'default' },
}),
render: (_, record): JSX.Element => {
const tooltipTitle = isDisabled
? 'Service account disabled'
: isNoAuthMode
? DEFAULT_MESSAGE
: 'Revoke Key';
return (
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
enabled={!isDisabled && !!accountId}
render: (_, record): JSX.Element => (
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
enabled={!isDisabled && !!accountId}
>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(): void => {
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<Tooltip title={tooltipTitle}>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled || isNoAuthMode}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
</AuthZTooltip>
);
},
<X size={12} />
</Button>
</AuthZTooltip>
),
},
];
}
@@ -172,7 +158,6 @@ function KeysTab({
parseAsString.withDefault(''),
);
const editKey = keys.find((k) => k.id === editKeyId) ?? null;
const { isNoAuthMode } = useAppContext();
const handleformatLastObservedAt = useCallback(
(lastObservedAt: Date | null | undefined): string =>
@@ -192,17 +177,10 @@ function KeysTab({
buildColumns({
isDisabled,
accountId,
isNoAuthMode,
onRevokeClick,
handleformatLastObservedAt,
}),
[
isDisabled,
accountId,
isNoAuthMode,
onRevokeClick,
handleformatLastObservedAt,
],
[isDisabled, accountId, onRevokeClick, handleformatLastObservedAt],
);
if (isLoading) {
@@ -232,18 +210,16 @@ function KeysTab({
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
enabled={!isDisabled && !!accountId}
>
<NoAuthGuard>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</NoAuthGuard>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</AuthZTooltip>
</div>
);

View File

@@ -49,7 +49,6 @@ import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { NoAuthGuard } from 'components/NoAuthGuard';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';
@@ -437,20 +436,18 @@ function ServiceAccountDrawer({
]}
enabled={!isDeleted && !!selectedAccountId}
>
<NoAuthGuard>
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
</NoAuthGuard>
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
</AuthZTooltip>
)}
</div>
@@ -553,18 +550,16 @@ function ServiceAccountDrawer({
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
enabled={!!selectedAccountId}
>
<NoAuthGuard>
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
</NoAuthGuard>
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
</AuthZTooltip>
)}
{!isDeleted && (
@@ -573,17 +568,15 @@ function ServiceAccountDrawer({
<X size={14} />
Cancel
</Button>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
</NoAuthGuard>
<Button
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
</div>
)}
</>

View File

@@ -93,6 +93,7 @@ function ValueGraph({
<div
ref={containerRef}
className="value-graph-container"
data-testid="value-graph-container"
style={{
backgroundColor:
threshold.thresholdFormat === 'Background'

View File

@@ -4,7 +4,6 @@ import { useQueryClient } from 'react-query';
import { Button } from 'antd';
import type { NotificationInstance } from 'antd/es/notification/interface';
import deleteChannel from 'api/channels/delete';
import { NoAuthGuard } from 'components/NoAuthGuard';
import APIError from 'types/api/error';
function Delete({ notifications, id }: DeleteProps): JSX.Element {
@@ -36,16 +35,14 @@ function Delete({ notifications, id }: DeleteProps): JSX.Element {
};
return (
<NoAuthGuard>
<Button
loading={loading}
disabled={loading}
type="link"
onClick={onClickHandler}
>
Delete
</Button>
</NoAuthGuard>
<Button
loading={loading}
disabled={loading}
type="link"
onClick={onClickHandler}
>
Delete
</Button>
);
}

View File

@@ -159,6 +159,8 @@ function GridTableComponent({
if (threshold && idx !== -1) {
return (
<div
data-testid="threshold-styled-cell"
data-threshold-format={threshold.thresholdFormat}
style={
threshold.thresholdFormat === 'Background'
? { backgroundColor: threshold.thresholdColor }

View File

@@ -18,7 +18,6 @@ import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import Header from 'components/Header/Header';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import NoAuthBanner from 'components/NoAuthBanner/NoAuthBanner';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -63,7 +62,7 @@ const homeInterval = 30 * 60 * 1000;
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function Home(): JSX.Element {
const { user, isNoAuthMode } = useAppContext();
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const isDarkMode = useIsDarkMode();
@@ -197,7 +196,7 @@ export default function Home(): JSX.Element {
const { mutate: updateUserPreference } = useMutation(updateUserPreferenceAPI, {
onSuccess: () => {
setUpdatingUserPreferences(false);
void refetchUserPreferences();
refetchUserPreferences();
},
onError: () => {
setUpdatingUserPreferences(false);
@@ -205,7 +204,7 @@ export default function Home(): JSX.Element {
});
const handleWillDoThisLater = (): void => {
void logEvent('Welcome Checklist: Will do this later clicked', {});
logEvent('Welcome Checklist: Will do this later clicked', {});
setUpdatingUserPreferences(true);
updateUserPreference({
@@ -272,12 +271,11 @@ export default function Home(): JSX.Element {
}, [metricsOnboardingData, handleUpdateChecklistDoneItem]);
useEffect(() => {
void logEvent('Homepage: Visited', {});
logEvent('Homepage: Visited', {});
}, []);
return (
<div className="home-container">
{isNoAuthMode && <NoAuthBanner />}
<div className="sticky-header">
<Header
leftComponent={
@@ -300,9 +298,9 @@ export default function Home(): JSX.Element {
autoAdjustOverflow
onOpenChange={(visible): void => {
if (visible) {
void logEvent('Welcome Checklist: Expanded', {});
logEvent('Welcome Checklist: Expanded', {});
} else {
void logEvent('Welcome Checklist: Minimized', {});
logEvent('Welcome Checklist: Minimized', {});
}
}}
content={renderWelcomeChecklistModal()}
@@ -355,7 +353,7 @@ export default function Home(): JSX.Element {
className="active-ingestion-card-actions"
onClick={(e: React.MouseEvent): void => {
// eslint-disable-next-line sonarjs/no-duplicate-string
void logEvent('Homepage: Ingestion Active Explore clicked', {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Logs',
});
safeNavigate(ROUTES.LOGS_EXPLORER, {
@@ -364,7 +362,7 @@ export default function Home(): JSX.Element {
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
void logEvent('Homepage: Ingestion Active Explore clicked', {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Logs',
});
history.push(ROUTES.LOGS_EXPLORER);
@@ -398,7 +396,7 @@ export default function Home(): JSX.Element {
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent): void => {
void logEvent('Homepage: Ingestion Active Explore clicked', {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Traces',
});
safeNavigate(ROUTES.TRACES_EXPLORER, {
@@ -407,7 +405,7 @@ export default function Home(): JSX.Element {
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
void logEvent('Homepage: Ingestion Active Explore clicked', {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Traces',
});
history.push(ROUTES.TRACES_EXPLORER);
@@ -441,7 +439,7 @@ export default function Home(): JSX.Element {
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent): void => {
void logEvent('Homepage: Ingestion Active Explore clicked', {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Metrics',
});
safeNavigate(ROUTES.METRICS_EXPLORER, {
@@ -450,7 +448,7 @@ export default function Home(): JSX.Element {
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
void logEvent('Homepage: Ingestion Active Explore clicked', {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Metrics',
});
history.push(ROUTES.METRICS_EXPLORER);
@@ -498,7 +496,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Wrench size={14} />}
onClick={(e: React.MouseEvent): void => {
void logEvent('Homepage: Explore clicked', {
logEvent('Homepage: Explore clicked', {
source: 'Logs',
});
safeNavigate(ROUTES.LOGS_EXPLORER, {
@@ -515,7 +513,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Wrench size={14} />}
onClick={(e: React.MouseEvent): void => {
void logEvent('Homepage: Explore clicked', {
logEvent('Homepage: Explore clicked', {
source: 'Traces',
});
safeNavigate(ROUTES.TRACES_EXPLORER, {
@@ -532,7 +530,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Wrench size={14} />}
onClick={(e: React.MouseEvent): void => {
void logEvent('Homepage: Explore clicked', {
logEvent('Homepage: Explore clicked', {
source: 'Metrics',
});
safeNavigate(ROUTES.METRICS_EXPLORER_EXPLORER, {
@@ -571,7 +569,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Plus size={14} />}
onClick={(e: React.MouseEvent): void => {
void logEvent('Homepage: Explore clicked', {
logEvent('Homepage: Explore clicked', {
source: 'Dashboards',
});
safeNavigate(ROUTES.ALL_DASHBOARD, {
@@ -616,7 +614,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Plus size={14} />}
onClick={(e: React.MouseEvent): void => {
void logEvent('Homepage: Explore clicked', {
logEvent('Homepage: Explore clicked', {
source: 'Alerts',
});
safeNavigate(ROUTES.ALERTS_NEW, {

View File

@@ -70,7 +70,6 @@ import {
TriangleAlert,
X,
} from '@signozhq/icons';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import {
@@ -1007,25 +1006,21 @@ function MultiIngestionSettings(): JSX.Element {
</div>
</div>
<div className="action-btn">
<NoAuthGuard>
<Button
variant="link"
size="icon"
color="secondary"
suffix={<PenLine size={14} />}
aria-label="Edit ingestion key"
onClick={onEditKey}
/>
</NoAuthGuard>
<NoAuthGuard>
<Button
variant="link"
size="icon"
color="destructive"
suffix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
onClick={onDeleteKey}
/>
</NoAuthGuard>
<Button
variant="link"
size="icon"
color="secondary"
suffix={<PenLine size={14} />}
aria-label="Edit ingestion key"
onClick={onEditKey}
/>
<Button
variant="link"
size="icon"
color="destructive"
suffix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
onClick={onDeleteKey}
/>
</div>
</div>
),
@@ -1128,32 +1123,28 @@ function MultiIngestionSettings(): JSX.Element {
<div className="actions">
{hasLimits(signalName) ? (
<>
<NoAuthGuard>
<Button
variant="link"
size="icon"
color="secondary"
prefix={<PenLine size={14} />}
aria-label={`Edit ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onEditSignalLimit}
/>
</NoAuthGuard>
<NoAuthGuard>
<Button
variant="link"
size="icon"
color="destructive"
prefix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
aria-label={`Delete ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onDeleteSignalLimit}
/>
</NoAuthGuard>
<Button
variant="link"
size="icon"
color="secondary"
prefix={<PenLine size={14} />}
aria-label={`Edit ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onEditSignalLimit}
/>
<Button
variant="link"
size="icon"
color="destructive"
prefix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
aria-label={`Delete ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onDeleteSignalLimit}
/>
</>
) : (
<Button
@@ -1688,16 +1679,14 @@ function MultiIngestionSettings(): JSX.Element {
onChange={handleSearch}
/>
<NoAuthGuard>
<Button
variant="solid"
className="add-new-ingestion-key-btn"
prefix={<Plus size={14} />}
onClick={showAddModal}
>
New Ingestion key
</Button>
</NoAuthGuard>
<Button
variant="solid"
className="add-new-ingestion-key-btn"
prefix={<Plus size={14} />}
onClick={showAddModal}
>
New Ingestion key
</Button>
</div>
<Table

View File

@@ -9,7 +9,6 @@ import { useListUsers } from 'api/generated/services/users';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import { NoAuthGuard } from 'components/NoAuthGuard';
import useUrlQuery from 'hooks/useUrlQuery';
import { toISOString } from 'utils/app';
@@ -22,6 +21,7 @@ const PAGE_SIZE = 20;
function MembersSettings(): JSX.Element {
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
@@ -146,7 +146,7 @@ function MembersSettings(): JSX.Element {
: `Deleted ⎯ ${deletedCount}`;
const handleInviteComplete = useCallback((): void => {
void refetchUsers();
refetchUsers();
}, [refetchUsers]);
const handleRowClick = useCallback((member: MemberRow): void => {
@@ -158,7 +158,7 @@ function MembersSettings(): JSX.Element {
}, []);
const handleMemberEditComplete = useCallback((): void => {
void refetchUsers();
refetchUsers();
}, [refetchUsers]);
return (
@@ -201,16 +201,14 @@ function MembersSettings(): JSX.Element {
/>
</div>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
</Button>
</NoAuthGuard>
<Button
variant="solid"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
</Button>
</div>
</div>
<MembersTable

View File

@@ -7,7 +7,6 @@ import {
updateMyPassword,
useUpdateMyUserV2,
} from 'api/generated/services/users';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useNotifications } from 'hooks/useNotifications';
import { Check, FileTerminal, Mail, User } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -81,10 +80,10 @@ function UserInfo(): JSX.Element {
currentPassword === updatePassword;
const onSaveHandler = async (): Promise<void> => {
void logEvent('Account Settings: Name Updated', {
logEvent('Account Settings: Name Updated', {
name: changedName,
});
void logEvent(
logEvent(
'Account Settings: Name Updated',
{
name: changedName,
@@ -145,16 +144,14 @@ function UserInfo(): JSX.Element {
Update name
</Button>
<NoAuthGuard>
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsResetPasswordModalOpen(true)}
>
Reset password
</Button>
</NoAuthGuard>
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsResetPasswordModalOpen(true)}
>
Reset password
</Button>
</div>
<Modal

View File

@@ -231,12 +231,14 @@ function Threshold({
type="text"
icon={<Pencil size={14} />}
className="edit-btn"
data-testid="threshold-edit-btn"
onClick={editHandler}
/>
<Button
type="text"
icon={<Trash2 size={14} />}
className="delete-btn"
data-testid="threshold-delete-btn"
onClick={deleteHandler}
/>
</div>

View File

@@ -17,7 +17,6 @@ import {
import { AxiosError } from 'axios';
import { FeatureKeys } from 'constants/features';
import { defaultTo } from 'lodash-es';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
@@ -258,16 +257,14 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
Cancel
</Button>
)}
<NoAuthGuard>
<Button
onClick={onSubmitHandler}
variant="solid"
color="primary"
loading={isCreating || isUpdating}
>
Save Changes
</Button>
</NoAuthGuard>
<Button
onClick={onSubmitHandler}
variant="solid"
color="primary"
loading={isCreating || isUpdating}
>
Save Changes
</Button>
</section>
</div>
)}

View File

@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { useUpdateAuthDomain } from 'api/generated/services/authdomains';
import {
@@ -66,9 +65,7 @@ function SSOEnforcementToggle({
};
return (
<NoAuthGuard>
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
</NoAuthGuard>
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
);
}

View File

@@ -14,7 +14,6 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { NoAuthGuard } from 'components/NoAuthGuard';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -76,7 +75,7 @@ function AuthDomain(): JSX.Element {
{
onSuccess: () => {
toast.success('Domain deleted successfully');
void refetchAuthDomainListResponse();
refetchAuthDomainListResponse();
hideDeleteModal();
},
onError: (error) => {
@@ -154,24 +153,20 @@ function AuthDomain(): JSX.Element {
width: 100,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
<section className="auth-domain-list-column-action">
<NoAuthGuard>
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
</NoAuthGuard>
<NoAuthGuard>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
>
Delete
</Button>
</NoAuthGuard>
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
>
Delete
</Button>
</section>
),
},
@@ -183,19 +178,17 @@ function AuthDomain(): JSX.Element {
<div className="auth-domain">
<section className="auth-domain-header">
<h3 className="auth-domain-title">Authenticated Domains</h3>
<NoAuthGuard>
<Button
prefix={<Plus size="md" />}
onClick={(): void => {
setAddDomain(true);
}}
variant="solid"
size="sm"
color="primary"
>
Add Domain
</Button>
</NoAuthGuard>
<Button
prefix={<Plus size="md" />}
onClick={(): void => {
setAddDomain(true);
}}
variant="solid"
size="sm"
color="primary"
>
Add Domain
</Button>
</section>
{formattedError && <ErrorContent error={formattedError} />}
{!errorFetchingAuthDomainListResponse && (
@@ -238,16 +231,15 @@ function AuthDomain(): JSX.Element {
>
Cancel
</Button>,
<NoAuthGuard key="submit">
<Button
prefix={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>
</NoAuthGuard>,
<Button
key="submit"
prefix={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>,
]}
>
<p className="delete-text">

View File

@@ -18,7 +18,6 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { handleApiError } from 'utils/errorUtils';
@@ -149,17 +148,16 @@ function CreateRoleModal({
<X size={14} />
Cancel
</Button>,
<NoAuthGuard key="submit">
<Button
variant="solid"
color="primary"
onClick={onSubmit}
loading={isLoading}
size="sm"
>
{isEditMode ? 'Save Changes' : 'Create Role'}
</Button>
</NoAuthGuard>,
<Button
key="submit"
variant="solid"
color="primary"
onClick={onSubmit}
loading={isLoading}
size="sm"
>
{isEditMode ? 'Save Changes' : 'Create Role'}
</Button>,
]}
destroyOnClose
className="create-role-modal"

View File

@@ -4,7 +4,6 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { NoAuthGuard } from 'components/NoAuthGuard';
import CreateRoleModal from './RolesComponents/CreateRoleModal';
import RolesListingTable from './RolesComponents/RolesListingTable';
@@ -40,17 +39,15 @@ function RolesSettings(): JSX.Element {
onChange={(e): void => setSearchQuery(e.target.value)}
/>
<AuthZTooltip checks={[RoleCreatePermission]}>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
</NoAuthGuard>
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
</AuthZTooltip>
</div>
<RolesListingTable searchQuery={searchQuery} />

View File

@@ -101,8 +101,6 @@ export function getAppContextMockState(
userPreferences: null,
hostsData: null,
isLoggedIn: false,
isNoAuthMode: false,
isPreflightLoading: false,
org: null,
isFetchingUser: false,
isFetchingActiveLicense: false,

View File

@@ -37,7 +37,6 @@ import {
} from './utils';
import './ServiceAccountsSettings.styles.scss';
import { NoAuthGuard } from 'components/NoAuthGuard';
function ServiceAccountsSettings(): JSX.Element {
const [currentPage, setPage] = useQueryState(
@@ -265,18 +264,16 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
<AuthZTooltip checks={[SACreatePermission]}>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</NoAuthGuard>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</AuthZTooltip>
</div>

View File

@@ -1120,7 +1120,6 @@
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
}
}
}

View File

@@ -134,7 +134,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
featureFlags,
trialInfo,
isLoggedIn,
isNoAuthMode,
userPreferences,
changelog,
toggleChangelogModal,
@@ -409,7 +408,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
);
const handleReorderShortcutNavItems = (): void => {
void logEvent('Sidebar V2: Save shortcuts clicked', {
logEvent('Sidebar V2: Save shortcuts clicked', {
shortcuts: tempPinnedMenuItems.map((item) => item.key),
});
setPinnedMenuItems(tempPinnedMenuItems);
@@ -437,7 +436,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
const onClickGetStarted = (event: MouseEvent): void => {
void logEvent('Sidebar: Menu clicked', {
logEvent('Sidebar: Menu clicked', {
menuRoute: '/get-started',
menuLabel: 'Get Started',
});
@@ -490,14 +489,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
isWorkspaceBlocked,
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
isNoAuthMode,
}),
[
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
user.email,
isWorkspaceBlocked,
isNoAuthMode,
],
);
@@ -654,7 +651,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
} else if (item) {
onClickHandler(item?.key as string, event);
}
void logEvent('Sidebar V2: Menu clicked', {
logEvent('Sidebar V2: Menu clicked', {
menuRoute: item?.key,
menuLabel: item?.label,
});
@@ -797,7 +794,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
onTogglePin={
allowPin
? (item): void => {
void logEvent(
logEvent(
`Sidebar V2: Menu item ${item.isPinned ? 'unpinned' : 'pinned'}`,
{
menuRoute: item.key,
@@ -844,7 +841,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const event = (info as SidebarItem & { domEvent?: MouseEvent }).domEvent;
if (item && !('type' in item)) {
void logEvent('Help Popover: Item clicked', {
logEvent('Help Popover: Item clicked', {
menuRoute: item.key,
menuLabel: String(item.label),
});
@@ -893,7 +890,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
menuLabel = item.label;
}
void logEvent('Settings Popover: Item clicked', {
logEvent('Settings Popover: Item clicked', {
menuRoute: item?.key,
menuLabel,
});
@@ -930,7 +927,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
}
break;
case 'logout':
void Logout();
Logout();
break;
default:
}
@@ -1084,7 +1081,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
<div
className="nav-section-title-icon reorder"
onClick={(): void => {
void logEvent('Sidebar V2: Manage shortcuts clicked', {});
logEvent('Sidebar V2: Manage shortcuts clicked', {});
setIsReorderShortcutNavItemsModalOpen(true);
}}
>
@@ -1131,7 +1128,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
return;
}
const newCollapsedState = !isMoreMenuCollapsed;
void logEvent('Sidebar V2: More menu clicked', {
logEvent('Sidebar V2: More menu clicked', {
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
});
setIsMoreMenuCollapsed(newCollapsedState);
@@ -1237,14 +1234,14 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
open={isReorderShortcutNavItemsModalOpen}
closable
onCancel={(): void => {
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
hideReorderShortcutNavItemsModal();
}}
footer={[
<Button
key="cancel"
onClick={(): void => {
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
hideReorderShortcutNavItemsModal();
}}
className="periscope-btn cancel-btn secondary-btn"

View File

@@ -5,7 +5,6 @@ const BASE_PARAMS = {
isWorkspaceBlocked: false,
isEnterpriseSelfHostedUser: false,
isCommunityEnterpriseUser: false,
isNoAuthMode: false,
};
describe('getUserSettingsDropdownMenuItems', () => {
@@ -72,15 +71,4 @@ describe('getUserSettingsDropdownMenuItems', () => {
expect(keys[3]).toBe('account');
expect(keys[keys.length - 1]).toBe('logout');
});
it('omits sign out and its preceding divider when isNoAuthMode=true', () => {
const items =
getUserSettingsDropdownMenuItems({ ...BASE_PARAMS, isNoAuthMode: true }) ??
[];
const keys = items.map((item: any) => item.key ?? item.type);
expect(keys).not.toContain('logout');
// the trailing divider before logout should also be gone
expect(keys[keys.length - 1]).toBe('keyboard-shortcuts');
});
});

View File

@@ -1,5 +1,3 @@
import { MenuProps } from 'antd';
import ROUTES from 'constants/routes';
import {
ArrowUpRight,
BarChart,
@@ -37,13 +35,15 @@ import {
Users,
Binoculars,
} from '@signozhq/icons';
import { Style } from '@signozhq/design-tokens';
import { MenuProps } from 'antd';
import ROUTES from 'constants/routes';
import {
SecondaryMenuItemKey,
SettingsNavSection,
SidebarItem,
} from './sideNav.types';
import { Style } from '@signozhq/design-tokens';
export const getStartedMenuItem = {
key: ROUTES.GET_STARTED,
@@ -487,7 +487,6 @@ export interface UserSettingsMenuItemsParams {
isWorkspaceBlocked: boolean;
isEnterpriseSelfHostedUser: boolean;
isCommunityEnterpriseUser: boolean;
isNoAuthMode: boolean;
}
export const getUserSettingsDropdownMenuItems = ({
@@ -495,7 +494,6 @@ export const getUserSettingsDropdownMenuItems = ({
isWorkspaceBlocked,
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
isNoAuthMode,
}: UserSettingsMenuItemsParams): MenuProps['items'] =>
[
{
@@ -539,25 +537,21 @@ export const getUserSettingsDropdownMenuItems = ({
icon: <Keyboard size={14} color={Style.L1_FOREGROUND} />,
dataTestId: 'keyboard-shortcuts-nav-item',
},
...(isNoAuthMode
? []
: [
{ type: 'divider' as const },
{
key: 'logout',
label: (
<span className="user-settings-dropdown-logout-section">Sign out</span>
),
icon: (
<LogOut
size={14}
className="user-settings-dropdown-logout-section"
color={Style.DANGER_BACKGROUND}
/>
),
dataTestId: 'logout-nav-item',
},
]),
{ type: 'divider' as const },
{
key: 'logout',
label: (
<span className="user-settings-dropdown-logout-section">Sign out</span>
),
icon: (
<LogOut
size={14}
className="user-settings-dropdown-logout-section"
color={Style.DANGER_BACKGROUND}
/>
),
dataTestId: 'logout-nav-item',
},
].filter(Boolean);
/** Mapping of some newly added routes and their corresponding active sidebar menu key */

View File

@@ -321,7 +321,7 @@ function SettingsPage(): JSX.Element {
isDisabled={false}
showIcon={false}
onClick={(event): void => {
void logEvent('Settings V2: Menu clicked', {
logEvent('Settings V2: Menu clicked', {
menuLabel: item.label,
menuRoute: item.key,
});

View File

@@ -13,11 +13,8 @@ import { useQuery } from 'react-query';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useGetHosts } from 'api/generated/services/zeus';
import { useGetGlobalConfig } from 'api/generated/services/global';
import { useGetMyUser } from 'api/generated/services/users';
import listOrgPreferences from 'api/v1/org/preferences/list';
import { clearAuthStorage } from 'utils/clearAuthStorage';
import { setNoAuthMode } from 'utils/noAuthMode';
import listUserPreferences from 'api/v1/user/preferences/list';
import getUserVersion from 'api/v1/version/get';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -73,50 +70,11 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(
(): boolean => getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true',
);
const [isNoAuthMode, setIsNoAuthMode] = useState<boolean>(false);
const [isPreflightLoading, setIsPreflightLoading] = useState<boolean>(true);
const [org, setOrg] = useState<Organization[] | null>(null);
const [changelog, setChangelog] = useState<ChangelogSchema | null>(null);
const [showChangelogModal, setShowChangelogModal] = useState<boolean>(false);
// Pre-flight: discover auth mode from public global config.
// On success: in impersonation mode → clear stale tokens, force isLoggedIn=true,
// set noAuthMode singleton so the axios interceptor (outside React)
// can skip the rotate-logout chain.
// On failure: fail-safe to normal auth flow (treat as not no-auth).
const { data: globalConfigData, isLoading: isFetchingGlobalConfig } =
useGetGlobalConfig({
query: {
retry: 2,
retryDelay: 1000,
refetchOnWindowFocus: false,
staleTime: Infinity,
},
});
useEffect(() => {
if (isFetchingGlobalConfig) {
return;
}
const impersonationEnabled =
globalConfigData?.data?.identN?.impersonation?.enabled === true;
if (impersonationEnabled) {
clearAuthStorage();
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
setNoAuthMode(true);
setIsNoAuthMode(true);
setIsLoggedIn(true);
} else {
setNoAuthMode(false);
setIsNoAuthMode(false);
}
setIsPreflightLoading(false);
}, [globalConfigData, isFetchingGlobalConfig]);
// fetcher for current user
// user will only be fetched if the user id and token is present
// if logged out and trying to hit any route none of these calls will trigger
@@ -408,9 +366,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
// global event listener for LOGOUT event to clean the app context state
useGlobalEventListener('LOGOUT', () => {
if (isNoAuthMode) {
return;
} // logout is meaningless in no-auth; defensively no-op
setIsLoggedIn(false);
setDefaultUser(getUserDefaults());
setActiveLicense(null);
@@ -430,8 +385,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
orgPreferences,
hostsData,
isLoggedIn,
isNoAuthMode,
isPreflightLoading,
org,
isFetchingUser,
isFetchingActiveLicense,
@@ -472,8 +425,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
isLoggedIn,
hostsData,
hostsFetchError,
isNoAuthMode,
isPreflightLoading,
org,
orgPreferences,
activeLicenseRefetch,

View File

@@ -2,7 +2,6 @@ import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook, waitFor } from '@testing-library/react';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { getIsNoAuthMode, setNoAuthMode } from 'utils/noAuthMode';
import { LOCALSTORAGE } from 'constants/localStorage';
import { SINGLE_FLIGHT_WAIT_TIME_MS } from 'hooks/useAuthZ/constants';
import { server } from 'mocks-server/server';
@@ -14,7 +13,6 @@ import { AppProvider, useAppContext } from '../App';
const MY_USER_URL = 'http://localhost/api/v2/users/me';
const MY_ORG_URL = 'http://localhost/api/v2/orgs/me';
const GLOBAL_CONFIG_URL = 'http://localhost/api/v1/global/config';
jest.mock('constants/env', () => ({
ENVIRONMENT: { baseURL: 'http://localhost', wsURL: '' },
@@ -338,89 +336,3 @@ describe('AppProvider when authz/check fails', () => {
);
});
});
describe('AppProvider no-auth preflight', () => {
beforeEach(() => {
queryClient.clear();
});
afterEach(() => {
setNoAuthMode(false);
});
it('sets isNoAuthMode=true and noAuthMode singleton when impersonation is enabled', async () => {
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: true } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.isNoAuthMode).toBe(true);
},
{ timeout: 3000 },
);
expect(getIsNoAuthMode()).toBe(true);
});
it('leaves isNoAuthMode=false and clears noAuthMode singleton when impersonation is disabled', async () => {
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: false } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.isPreflightLoading).toBe(false);
},
{ timeout: 3000 },
);
expect(result.current.isNoAuthMode).toBe(false);
expect(getIsNoAuthMode()).toBe(false);
});
it('transitions isPreflightLoading from true to false once preflight resolves', async () => {
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: false } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
expect(result.current.isPreflightLoading).toBe(true);
await waitFor(
() => {
expect(result.current.isPreflightLoading).toBe(false);
},
{ timeout: 3000 },
);
});
});

View File

@@ -20,8 +20,6 @@ export interface IAppContext {
userPreferences: UserPreference[] | null;
hostsData: GetHosts200 | null;
isLoggedIn: boolean;
isNoAuthMode: boolean;
isPreflightLoading: boolean;
org: Organization[] | null;
isFetchingUser: boolean;
isFetchingActiveLicense: boolean;

View File

@@ -240,8 +240,6 @@ export function getAppContextMock(
isFetchingOrgPreferences: false,
orgPreferencesFetchError: null,
isLoggedIn: true,
isNoAuthMode: false,
isPreflightLoading: false,
showChangelogModal: false,
updateUser: jest.fn(),
updateOrg: jest.fn(),

View File

@@ -1,39 +0,0 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { clearAuthStorage } from '../clearAuthStorage';
describe('clearAuthStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('removes all auth-related localStorage keys', () => {
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
localStorage.setItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN, 'refresh');
localStorage.setItem(LOCALSTORAGE.IS_LOGGED_IN, 'true');
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, 'old@example.com');
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_NAME, 'old');
localStorage.setItem(LOCALSTORAGE.IS_IDENTIFIED_USER, 'true');
localStorage.setItem(LOCALSTORAGE.USER_ID, 'abc');
clearAuthStorage();
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.IS_LOGGED_IN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_NAME)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.IS_IDENTIFIED_USER)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.USER_ID)).toBeNull();
});
it('preserves non-auth localStorage keys', () => {
localStorage.setItem(LOCALSTORAGE.THEME, 'dark');
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
clearAuthStorage();
expect(localStorage.getItem(LOCALSTORAGE.THEME)).toBe('dark');
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
});
});

View File

@@ -1,16 +0,0 @@
import deleteLocalStorageKey from 'api/browser/localstorage/remove';
import { LOCALSTORAGE } from 'constants/localStorage';
const AUTH_KEYS: LOCALSTORAGE[] = [
LOCALSTORAGE.AUTH_TOKEN,
LOCALSTORAGE.REFRESH_AUTH_TOKEN,
LOCALSTORAGE.IS_LOGGED_IN,
LOCALSTORAGE.LOGGED_IN_USER_EMAIL,
LOCALSTORAGE.LOGGED_IN_USER_NAME,
LOCALSTORAGE.IS_IDENTIFIED_USER,
LOCALSTORAGE.USER_ID,
];
export const clearAuthStorage = (): void => {
AUTH_KEYS.forEach((key) => deleteLocalStorageKey(key));
};

View File

@@ -1,7 +0,0 @@
let _isNoAuthMode = false;
export const setNoAuthMode = (value: boolean): void => {
_isNoAuthMode = value;
};
export const getIsNoAuthMode = (): boolean => _isNoAuthMode;

View File

@@ -3,6 +3,10 @@ import path from 'path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import apmMetricsTemplate from '../testdata/apm-metrics.json';
import queriesData from '../testdata/queries.json';
export type SignalType = 'metrics' | 'logs' | 'traces';
export type QueriesData = typeof queriesData;
// ─── Constants ───────────────────────────────────────────────────────────
//
@@ -177,3 +181,248 @@ export async function openDashboardActionMenu(
await icon.click();
return page.getByRole('tooltip');
}
// ─── Dashboard detail page helpers ──────────────────────────────────────────
/**
* Click the Configure button (`data-testid="show-drawer"`) on a dashboard
* detail page and wait for the settings drawer (`.settings-container-root`) to
* be visible. Works from both the empty-state view and the populated toolbar —
* both render the same testid.
*
* Returns the drawer locator so callers can scope further assertions to it.
*/
export async function openDashboardSettingsDrawer(page: Page): Promise<Locator> {
await page.getByTestId('show-drawer').first().click();
const drawer = page.locator('.settings-container-root');
await drawer.waitFor({ state: 'visible' });
return drawer;
}
/**
* Click `data-testid="save-dashboard-config"` and wait for the resulting
* `PUT /api/v1/dashboards/<id>` response. The Save button is only rendered
* when there is at least one unsaved change — callers must ensure the drawer
* has been dirtied before calling this.
*/
export async function saveDashboardSettings(page: Page): Promise<void> {
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('save-dashboard-config').click();
await patchResponse;
}
/**
* Rename a dashboard via the toolbar options popover:
* opens the popover (`data-testid="options"`), clicks "Rename", fills the
* input, clicks "Rename Dashboard", and waits for the PUT response.
*
* Pre-condition: the caller must be on the dashboard detail page.
*/
export async function renameDashboardViaToolbar(
page: Page,
newTitle: string,
): Promise<void> {
await page.getByTestId('options').click();
await page.getByRole('button', { name: 'Rename' }).click();
const modal = page.getByRole('dialog');
await modal.waitFor({ state: 'visible' });
const input = modal.getByTestId('dashboard-name');
await input.clear();
await input.fill(newTitle);
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByRole('button', { name: 'Rename Dashboard' }).click();
await patchResponse;
await modal.waitFor({ state: 'hidden' });
}
// ─── Add panel flow ─────────────────────────────────────────────────────────
/**
* From the dashboard detail page (must already be loaded), drive the full
* "Add Panel" flow for the given signal type:
* 1. Click the empty-state `add-panel` CTA to open the New Panel modal.
* 2. Pick the Time Series panel type.
* 3. Fill the panel name in the right pane (drives the post-save assertion).
* 4. For metrics: type the metric name from `queries.json` into the metric
* AutoComplete and select it from the dropdown. For logs/traces: switch
* the data-source selector to LOGS / TRACES; default Query Builder state
* is sufficient (queries.json query strings are empty by design).
* 5. Click Save Changes, confirm the modal, and wait for the
* PUT /api/v1/dashboards/<id> response.
*
* Throws if the PUT response is not 2xx. After return, the page is back on
* the dashboard detail page; the caller asserts the panel rendered.
*/
export async function configureAndSavePanel(
page: Page,
signal: SignalType,
panelTitle: string,
): Promise<void> {
await page.getByTestId('add-panel').click();
const newPanelModal = page
.getByRole('dialog')
.filter({ hasText: 'New Panel' });
await newPanelModal.waitFor({ state: 'visible' });
await newPanelModal.getByTestId('panel-type-graph').click();
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
await page.getByTestId('panel-name-input').fill(panelTitle);
if (signal === 'metrics') {
const metricName = queriesData.metrics.metricName;
// The testid is on the Ant Select wrapper <div>; the editable input
// lives inside it. Target the descendant input for fill().
const metricInput = page.getByTestId('metric-name-selector-0').locator('input');
await metricInput.click();
await metricInput.fill(metricName);
// AutoComplete debounces and fetches; wait for the option then click.
await page
.locator('.ant-select-item-option-content', { hasText: metricName })
.first()
.click();
} else {
// logs / traces — switch the data source. Default query is sufficient.
await page.getByTestId('query-data-source-selector-0').click();
await page
.locator('.ant-select-item-option-content', {
hasText: signal.toUpperCase(),
})
.click();
}
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
// Confirmation modal (title varies: "Save Widget" vs "Unsaved Changes" —
// don't assert title, just click OK on the topmost dialog).
const confirmModal = page.getByRole('dialog').last();
await confirmModal.waitFor({ state: 'visible' });
await confirmModal.getByRole('button', { name: /^OK$/i }).click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
// Save navigates back to /dashboard/<id> (no /new suffix).
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}
// ─── Widget editor (re-open existing panel) ────────────────────────────────
/**
* Display labels surfaced in the `panel-change-select` Ant Select inside the
* widget editor. The mapping to URL `graphType` values comes from the
* `PANEL_TYPES` enum: TIME_SERIES='graph', VALUE='value', and so on.
*/
export type PanelDisplayLabel =
| 'Time Series'
| 'Number'
| 'Table'
| 'List'
| 'Bar'
| 'Pie'
| 'Histogram';
const PANEL_DISPLAY_TO_GRAPH_TYPE: Record<PanelDisplayLabel, string> = {
'Time Series': 'graph',
Number: 'value',
Table: 'table',
List: 'list',
Bar: 'bar',
Pie: 'pie',
Histogram: 'histogram',
};
/**
* Open the widget editor for an existing panel by driving the panel header
* options menu (the three-dot Ant `Dropdown` next to the title).
*
* The widget-header-options button is `visibility: hidden` until the panel is
* hovered (see `GridCardLayout.styles.scss`) — except on TABLE panels, where
* `globalSearchAvailable` keeps it permanently visible. Hovering the title
* testid first works for both states.
*/
export async function openWidgetEditor(
page: Page,
panelTitle: string,
): Promise<void> {
await page.getByTestId(panelTitle).first().hover();
await page.getByTestId('widget-header-options').first().click();
await page
.getByRole('menuitem', { name: /^edit$/i })
.first()
.click();
await page.waitForURL(/widgetId=/);
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
}
/**
* Click "Save Changes" in the widget editor, confirm via the OK button on the
* resulting modal, await the dashboard PUT response, and wait for navigation
* back to `/dashboard/<id>`. Throws if the PUT response is not 2xx.
*
* The confirmation modal title varies between "Save Widget" and "Unsaved
* Changes" depending on whether the query was modified — don't assert title,
* just OK the topmost dialog.
*/
export async function saveWidgetEdit(page: Page): Promise<void> {
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
const confirmModal = page.getByRole('dialog').last();
await confirmModal.waitFor({ state: 'visible' });
await confirmModal.getByRole('button', { name: /^OK$/i }).click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}
/**
* Switch the editor's panel display type via the Ant `Select` exposed as
* `data-testid="panel-change-select"`. The select options carry the display
* label as visible text (matches `PanelDisplay` enum values). After the
* change, this helper waits for the URL `graphType` param to reflect the new
* panel type and for the Save Changes button to re-render — the editor
* re-routes mid-flow via `redirectWithQueryBuilderData`.
*
* Note: the "List" option is filtered out of the dropdown when the current
* query contains a metrics data source (see VisualizationSettingsSection).
*/
export async function changePanelType(
page: Page,
displayLabel: PanelDisplayLabel,
): Promise<void> {
const expectedGraphType = PANEL_DISPLAY_TO_GRAPH_TYPE[displayLabel];
await page.getByTestId('panel-change-select').click();
// Each option renders a .select-option containing the display text — match
// against the typography element to avoid matching the trigger itself.
await page
.locator('.ant-select-item-option .display', { hasText: displayLabel })
.first()
.click();
await page.waitForURL(new RegExp(`graphType=${expectedGraphType}`));
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
}

12
tests/e2e/testdata/queries.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"logs": {
"query": ""
},
"metrics": {
"metricName": "signoz_calls_total",
"query": ""
},
"traces": {
"query": ""
}
}

View File

@@ -0,0 +1,550 @@
import path from 'path';
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
gotoDashboardsList,
openDashboardSettingsDrawer,
renameDashboardViaToolbar,
saveDashboardSettings,
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
// All tests mutate dashboard state (create / rename / delete). Run serially to
// prevent cross-test interference on the list and detail pages.
test.describe.configure({ mode: 'serial' });
// ─── Suite-level seed registry ────────────────────────────────────────────────
//
// Every dashboard created by any test is registered here; one afterAll tears
// them all down. Tests that don't create anything (TC-10, TC-11, TC-13) need
// no cleanup entry.
const seedIds = new Set<string>();
const BASE_FIXTURE_TITLE = 'create-flow-base-fixture';
const APM_METRICS_TESTDATA_PATH = path.resolve(
__dirname,
'../../testdata/apm-metrics.json',
);
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.beforeAll(async ({ browser }) => {
// Seed one base dashboard so the list is non-empty and the
// `new-dashboard-cta` header button is rendered for all tests that
// drive the "New dashboard" dropdown from the list page.
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard Create Flow', () => {
// ─── 1. Create Dashboard (blank) ─────────────────────────────────────────
test('TC-01 blank create lands on onboarding state with correct default title', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('create-dashboard-menu-cta').click();
const res = await postResponse;
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
const body = (await res.json()) as {
data: { data: { title: string }; id: string };
};
expect(body.data.data.title).toBe('Sample Title');
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
// DashboardDescription always renders dashboard-title even on blank dashboards.
await expect(page.getByTestId('dashboard-title')).toBeVisible();
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
await expect(page.getByTestId('show-drawer').first()).toBeVisible();
await expect(page.getByTestId('add-panel')).toBeVisible();
// Register the UI-created dashboard for cleanup.
const id = body.data.id;
expect(id, 'POST response must include a dashboard id').toBeTruthy();
seedIds.add(id);
});
test('TC-02 configure drawer opens with Overview tab and pre-fills existing title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc02');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
// Overview tab is the default active tab.
await expect(drawer.getByRole('button', { name: 'Overview' })).toBeVisible();
const nameInput = drawer.getByTestId('dashboard-name');
await expect(nameInput).toHaveValue('create-flow-tc02');
const descInput = drawer.getByTestId('dashboard-desc');
await expect(descInput).toBeVisible();
await expect(descInput).toHaveValue('');
await expect(
drawer.getByPlaceholder('Start typing your tag name'),
).toBeVisible();
// Ant Drawer does not close on Escape — use the X close button in the header.
await drawer.getByRole('button', { name: 'Close' }).click();
await expect(drawer).not.toHaveClass(/ant-drawer-open/);
});
test('TC-03 rename title, add description and tags, save persists to list', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc03-original');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
const nameInput = drawer.getByTestId('dashboard-name');
await nameInput.clear();
await nameInput.fill('create-flow-tc03-renamed');
await expect(drawer.getByText(/1 unsaved change/)).toBeVisible();
await drawer.getByTestId('dashboard-desc').fill('A test description');
await expect(drawer.getByText(/2 unsaved changes/)).toBeVisible();
const tagInput = drawer.getByPlaceholder('Start typing your tag name');
await tagInput.click();
await tagInput.fill('e2e-tag');
await page.keyboard.press('Enter');
await expect(drawer.getByText(/3 unsaved changes/)).toBeVisible();
// Click save and wait for the unsaved-changes footer to disappear — the
// footer only clears after the PUT success callback re-syncs local state.
await page.getByTestId('save-dashboard-config').click();
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
await drawer.getByRole('button', { name: 'Close' }).click();
// Renamed dashboard appears in the list.
await gotoDashboardsList(page);
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await searchInput.fill('create-flow-tc03-renamed');
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
// Tag search also surfaces the renamed dashboard.
await searchInput.fill('e2e-tag');
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
});
test('TC-04 discard reverts unsaved changes without API call', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc04');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
const nameInput = drawer.getByTestId('dashboard-name');
await nameInput.clear();
await nameInput.fill('create-flow-tc04-discarded');
await drawer.getByTestId('dashboard-desc').fill('discarded desc');
await expect(drawer.getByText(/unsaved change/)).toBeVisible();
// Intercept any PUT to detect an unwanted save.
let patchFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
patchFired = true;
}
route.continue();
});
await drawer.getByRole('button', { name: 'Discard' }).click();
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
await expect(nameInput).toHaveValue('create-flow-tc04');
await expect(drawer.getByTestId('dashboard-desc')).toHaveValue('');
expect(patchFired).toBe(false);
});
test('TC-05 rename via toolbar options popover persists to the toolbar title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc05');
await page.goto(`/dashboard/${id}`);
// DashboardDescription toolbar always renders — even on blank dashboards.
await expect(page.getByTestId('options')).toBeVisible();
await renameDashboardViaToolbar(page, 'create-flow-tc05-renamed');
await expect(page.getByTestId('dashboard-title')).toHaveText(
'create-flow-tc05-renamed',
);
});
// ─── 2. Variables ─────────────────────────────────────────────────────────
test('TC-06 add a Custom variable, verify it appears in the variables bar', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc06');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
await drawer.getByRole('button', { name: 'Variables' }).click();
await drawer.getByTestId('add-new-variable').click();
await expect(drawer.getByRole('button', { name: 'All variables' })).toBeVisible();
await drawer
.getByPlaceholder('Unique name of the variable')
.fill('env');
await drawer.getByRole('button', { name: 'Custom' }).click();
// After selecting "Custom" type, the Options collapse panel contains a
// textarea with placeholder "Enter options separated by commas."
const customInput = drawer.getByPlaceholder(
'Enter options separated by commas.',
);
await customInput.fill('prod,staging,dev');
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await drawer.getByRole('button', { name: 'Save Variable' }).click();
const res = await patchResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
// After saving, the variable form disappears and the table row is visible.
await expect(drawer.getByRole('button', { name: 'All variables' })).not.toBeVisible();
await expect(drawer.getByText('env')).toBeVisible();
// Close the drawer via its X button and check the variables bar.
await drawer.getByRole('button', { name: 'Close' }).click();
await expect(page.locator('.dashboard-variables')).toBeVisible();
});
test('TC-07 duplicate variable name is rejected inline', async ({
authedPage: page,
}) => {
// Seed a dashboard that already has a variable named 'env'.
const id = await seed(page, 'create-flow-tc07');
await page.goto(`/dashboard/${id}`);
// Use the UI to add the first variable so the state is real.
const drawer = await openDashboardSettingsDrawer(page);
await drawer.getByRole('button', { name: 'Variables' }).click();
await drawer.getByTestId('add-new-variable').click();
await drawer.getByPlaceholder('Unique name of the variable').fill('env');
await drawer.getByRole('button', { name: 'Custom' }).click();
await drawer
.getByPlaceholder('Enter options separated by commas.')
.fill('prod');
const firstSave = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await drawer.getByRole('button', { name: 'Save Variable' }).click();
await firstSave;
// Now try to add a second variable with the same name.
await drawer.getByTestId('add-new-variable').click();
const nameInput = drawer.getByPlaceholder('Unique name of the variable');
await nameInput.fill('env');
await expect(
drawer.getByText('Variable name already exists'),
).toBeVisible();
await expect(
drawer.getByRole('button', { name: 'Save Variable' }),
).toBeDisabled();
});
// ─── 3. Import JSON ───────────────────────────────────────────────────────
//
// TC-08 and TC-12 are merged: TC-08 covers the POST contract and navigation;
// the merged test also navigates back to the list and verifies metadata
// surfacing (the TC-12 concern). This avoids two identical import flows.
test('TC-08 import via file upload creates dashboard, navigates to detail, and surfaces metadata in list', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await dialog.locator('input[type="file"]').setInputFiles(APM_METRICS_TESTDATA_PATH);
await dialog.getByRole('button', { name: 'Import and Next' }).click();
const res = await postResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
// Register for cleanup.
const urlMatch = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
expect(urlMatch, 'URL must contain dashboard ID').not.toBeNull();
seedIds.add(urlMatch![1]);
await expect(page.getByTestId('dashboard-title')).toHaveText(APM_METRICS_TITLE);
// Navigate back and confirm the imported dashboard surfaces in the list
// with at least one tag chip (TC-12 coverage).
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(APM_METRICS_TITLE);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
// The apm-metrics fixture has tags ['apm', 'latency', 'error rate', 'throughput'].
await expect(page.getByText('apm').first()).toBeVisible();
});
// TC-09 (Monaco paste path) is intentionally dropped — the file-upload
// path (TC-08) exercises the same populate-editor-then-import code path.
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
test('TC-10 invalid JSON via file upload shows "Invalid JSON" error', async ({
authedPage: page,
}) => {
// No dashboard is created by this test — no cleanup entry needed.
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
await dialog.locator('input[type="file"]').setInputFiles({
name: 'bad.json',
mimeType: 'application/json',
buffer: Buffer.from('not valid json {'),
});
await expect(dialog.getByText('Invalid JSON')).toBeVisible();
await expect(dialog).toBeVisible();
// Clicking "Import and Next" with invalid content should surface an error
// and keep the dialog open.
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(dialog).toBeVisible();
});
test('TC-11 import with empty editor clicking Import and Next shows error', async ({
authedPage: page,
}) => {
// No dashboard is created — no cleanup entry needed.
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(dialog.getByText('Error loading JSON file')).toBeVisible();
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
// ─── 4. View Templates ────────────────────────────────────────────────────
test('TC-13 View templates menu item is an external link targeting a new tab', async ({
authedPage: page,
}) => {
// No dashboard is created — no cleanup entry needed.
// The assertion guards against the link being silently changed to an
// in-app modal or a different URL (the DashboardTemplatesModal exists in
// source but is never triggered from this menu item).
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
const link = page.getByTestId('view-templates-menu-cta');
await expect(link).toBeVisible();
await expect(link).toHaveAttribute(
'href',
/signoz\.io\/docs\/dashboards\/dashboard-templates/,
);
await expect(link).toHaveAttribute('target', '_blank');
await expect(link).toHaveAttribute('rel', /noopener/);
});
// ─── 5. Post-Create Dashboard Detail — Panel Addition ────────────────────
test('TC-14 New Panel modal opens and selecting Time Series navigates to widget editor', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc14');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
await page.getByTestId('add-panel').click();
// PANEL_TYPES enum: TIME_SERIES='graph', VALUE='value', TABLE='table'
// — the testid is panel-type-<enum-value>, not panel-type-<enum-name>.
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
await expect(modal).toBeVisible();
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
await expect(modal.getByTestId('panel-type-value')).toBeVisible();
await expect(modal.getByTestId('panel-type-table')).toBeVisible();
await modal.getByTestId('panel-type-graph').click();
await expect(page).toHaveURL(/graphType=graph/);
});
test('TC-15 New Panel button from toolbar header opens the same panel type modal', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc15');
await page.goto(`/dashboard/${id}`);
// The toolbar "New Panel" button (add-panel-header) is present even on
// a blank dashboard, alongside the empty-state "add-panel" button.
await expect(page.getByTestId('add-panel-header')).toBeVisible();
await page.getByTestId('add-panel-header').click();
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
await expect(modal).toBeVisible();
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
// Click the modal X button to close (Escape also works but may conflict
// with the Enterprise modal in the background; explicit click is more reliable).
await modal.getByRole('button', { name: 'Close' }).click();
await expect(modal).not.toBeVisible();
});
// ─── 6. Cancellation and Navigation Away ─────────────────────────────────
test('TC-16 browser Back from dashboard detail returns to list with URL preserved', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc16');
await page.goto(`/dashboard?search=create-flow-tc16`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.goBack();
await expect(page).toHaveURL(/search=create-flow-tc16/);
await expect(
page.getByPlaceholder(SEARCH_PLACEHOLDER),
).toHaveValue('create-flow-tc16');
});
test('TC-17 navigating away with the settings drawer open does not crash', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc17');
await page.goto(`/dashboard/${id}`);
await openDashboardSettingsDrawer(page);
// Navigate away without closing the drawer.
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
// No error overlay should be present.
await expect(
page.getByRole('alert').filter({ hasText: /error/i }),
).toHaveCount(0);
});
// ─── 7. Add Panel — end-to-end per signal ────────────────────────────────
//
// TC-14/TC-15 verify the New Panel modal opens and routes to the widget
// editor. The TCs below go further: configure a query for each signal
// using values from testdata/queries.json, save the panel, return to the
// dashboard, and verify the panel card renders.
test('TC-18 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-metrics');
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'metrics', 'metrics-timeseries');
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
});
test('TC-19 add logs Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-logs');
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'logs', 'logs-timeseries');
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
});
test('TC-20 add traces Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-traces');
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'traces', 'traces-timeseries');
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
});
});

View File

@@ -0,0 +1,192 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'list-controls-fixture';
const FIXTURE_PANEL_TITLE = 'list-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
// LIST panels require a logs (or traces) data source — metrics queries
// hide the LIST option from panel-change-select.
await configureAndSavePanel(page, 'logs', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'List');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
test.describe('List Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('list-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('list-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'list-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'list-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and shows info icon on header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E list description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E list description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel type switch from List to Table persists and re-renders', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Table');
// Table re-renders Decimal Precision + Column Units in the right pane.
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await saveWidgetEdit(page);
// Panel card should now render an Ant table head.
await expect(
page
.locator('[data-testid="' + FIXTURE_PANEL_TITLE + '"]')
.first(),
).toBeVisible();
await expect(page.locator('.ant-table-thead').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=table/);
// Reset back to List.
await changePanelType(page, 'List');
await saveWidgetEdit(page);
});
test('TC-04 sections hidden for LIST are not rendered in the right pane', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.panel-time-preference')).toHaveCount(0);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('.decimal-precision-selector')).toHaveCount(0);
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
await expect(page.locator('.y-axis-unit-selector-v2')).toHaveCount(0);
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-description-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
});
test('TC-05 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-list-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,470 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'table-controls-fixture';
const FIXTURE_PANEL_TITLE = 'table-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Table');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/**
* Return the last <td> in the first data row of the panel's Ant Design table.
* Ant Design applies .ant-table-row to actual data rows only (not header rows),
* so this correctly skips the fixed/sticky header tbody rows.
*
* For the metrics panel the row has: td[0] = label column, td[last] = value
* column (the aggregation query "A"). The last td is thus the value cell.
* However, depending on the panel query there may only be ONE td per row. Use
* the cell that contains a non-empty value: any td that is not purely the
* label placeholder.
*
* NOTE: the value cell wraps its text in a <button> element (from the
* QueryTable open-traces render path) so textContent picks it up correctly.
*/
async function getFirstDataCell(page: Page) {
// .ant-table-row targets Ant Design data rows only (not header/fixed rows).
const firstRow = page.locator('tr.ant-table-row').first();
await firstRow.waitFor({ state: 'visible' });
// Return the last <td> — for a metrics table with columns [label, A] this
// is the value column. For a single-column table it is the only column.
return firstRow.locator('td').last();
}
/**
* Ensure a SettingsSection accordion in the widget editor right pane is
* expanded. If it is already open (content div has the `open` class), this is
* a no-op. Otherwise it clicks the header button and waits for the content to
* become visible.
*/
async function expandSection(page: Page, title: string): Promise<void> {
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) => el.classList.contains('open'));
if (!isOpen) {
await section.locator('button.settings-section-header').click();
await contentDiv.waitFor({ state: 'visible' });
}
}
/**
* Select a unit from the column-unit selector dropdown by typing a search
* term, then clicking the filtered option. Scoped to .column-unit-selector to
* avoid matching the Y-axis unit selectors on other panel types.
*
* The selector has `showSearch` enabled and renders a long virtualised option
* list — typing first avoids instability from the list re-rendering when the
* target option is off-screen.
*/
async function selectColumnUnit(
page: Page,
searchTerm: string,
optionText: string,
): Promise<void> {
const unitSelect = page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select')
.first();
await unitSelect.click();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select input')
.first()
.fill(searchTerm);
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Table Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('table-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('table-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'table-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'table-controls-renamed',
);
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 description persists and shows info icon on header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E table description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E table description',
);
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel time preference switches to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i })
.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 column unit formats the matching column cells and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Use selectColumnUnit to avoid virtualised-list detached-DOM failures.
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
await saveWidgetEdit(page);
// Cell text in the data column should now contain the `ms` suffix.
const cell = await getFirstDataCell(page);
await expect(cell).toContainText(/ms/);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section starts collapsed again on re-open — expand before asserting.
await expandSection(page, 'Formatting & Units');
await expect(
page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-selection-item')
.first(),
).toContainText(/Milliseconds/);
// Reset — clear the unit via the Ant Select allowClear X button.
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2')
.first()
.hover();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-05 decimal precision changes the number of decimals when a column unit is set', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Set a column unit so decimal precision has a visible effect.
await selectColumnUnit(page, 'Seconds', 'Seconds (s)');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
await saveWidgetEdit(page);
const cell = await getFirstDataCell(page);
await expect(cell).toContainText(/s/);
const text = (await cell.textContent()) ?? '';
expect(text.replace(/\s*s\s*$/, '')).not.toMatch(/\./);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Section starts collapsed again on re-open — expand before asserting.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset: decimal precision back to 2, clear column unit.
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2')
.first()
.hover();
await page
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
.first()
.click();
await saveWidgetEdit(page);
});
test('TC-06 column-targeted Background threshold paints only the targeted column', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
// For TABLE thresholds the column selector (table-operator-input-selector)
// defaults to the first aggregation query column (typically `A`). Operator
// defaults to '>'; switch to '>=' so it reliably matches non-negative values.
await card.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
await card.getByTestId('threshold-color-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: 'Background' })
.first()
.click();
// Save the threshold row (commits it to the thresholds state array).
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Find a data row and inspect its cells. Use tr.ant-table-row to skip
// fixed-header tbody rows that Ant Design inserts for sticky scroll.
// QueryTable wraps each cell in <div role="button">; the threshold
// styled <div> is nested inside it. Use div[style] to target the first
// <div> that actually carries an inline style — that is the threshold div.
// TODO: switch to `getByTestId('threshold-styled-cell')` once the frontend
// build deployed to the test stack picks up the testid added in
// GridTableComponent/index.tsx (the host also carries
// `data-threshold-format="Background|Text"` to discriminate variants).
const row = page.locator('tr.ant-table-row').first();
await row.waitFor({ state: 'visible' });
const dataCellInner = row.locator('td').last().locator('div[style]').first();
const dataStyle = (await dataCellInner.getAttribute('style')) ?? '';
expect(dataStyle).toMatch(/background-color:/);
// Reset — delete the threshold. Edit/delete buttons are display:none
// by default and revealed only on .threshold-card-container:hover.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// ThresholdsSection defaultOpen is based on threshold count at mount; may
// start collapsed due to async state loading — always expand before interacting.
await expandSection(page, 'Thresholds');
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` once the stack
// frontend rebuild picks up the testid added in Threshold.tsx.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-07 column-targeted Text threshold colors only the targeted column text', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const card = page.locator('.threshold-container').first();
await card.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
// Format defaults to 'Text' — no change needed.
await card.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// QueryTable wraps each cell in <div role="button">; the threshold styled
// <div> is nested inside. Use div[style] to find the threshold div directly.
// TODO: same testid migration as TC-06 once the frontend rebuild lands.
const row = page.locator('tr.ant-table-row').first();
await row.waitFor({ state: 'visible' });
const dataCellInner = row.locator('td').last().locator('div[style]').first();
const dataStyle = (await dataCellInner.getAttribute('style')) ?? '';
expect(dataStyle).toMatch(/color:/);
expect(dataStyle).not.toMatch(/background-color:/);
// Reset
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Thresholds');
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` after frontend rebuild.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-08 sections hidden for TABLE are not rendered', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
// decimal-precision-selector and column-unit-selector are inside the
// "Formatting & Units" section which starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
await expect(page.locator('.column-unit-selector').first()).toBeVisible();
// add-threshold-cta is inside "Thresholds" which is also collapsed.
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-09 panel type switch from Table to Number persists and re-renders as a number', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Number');
// Number panel exposes the Y-axis unit selector in the Formatting & Units section.
await expect(page.locator('.y-axis-unit-selector-v2').first()).toBeVisible();
await saveWidgetEdit(page);
await expect(page.getByTestId('value-graph-text').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=value/);
// Reset: switch back to Table.
await changePanelType(page, 'Table');
await saveWidgetEdit(page);
});
test('TC-10 discarding right-pane changes does not persist', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-table-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
await page
.getByRole('dialog')
.last()
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — direct navigation
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});

View File

@@ -0,0 +1,495 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
changePanelType,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
findDashboardIdByTitle,
openWidgetEditor,
saveWidgetEdit,
} from '../../../helpers/dashboards';
// All TCs operate on the same fixture panel and toggle its state — they MUST
// run serially within the worker. Project-level fullyParallel still runs this
// file in parallel with other files.
test.describe.configure({ mode: 'serial' });
const FIXTURE_DASHBOARD_TITLE = 'value-controls-fixture';
const FIXTURE_PANEL_TITLE = 'value-controls-panel';
const seedIds = new Set<string>();
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
// configureAndSavePanel creates a Time Series panel. Switch it to the
// Number (VALUE) type before the per-TC bodies run.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Number');
await saveWidgetEdit(page);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoFixtureDashboard(page: Page): Promise<void> {
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
await page.goto(`/dashboard/${id}`);
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
}
/**
* Ensure a SettingsSection accordion in the widget editor right pane is
* expanded. If it is already open (content div has the `open` class), this is
* a no-op. Otherwise it clicks the header button and waits for the CSS
* transition to complete. This handles both the common case (collapsed on
* mount) and the defensive case (already open).
*/
async function expandSection(page: Page, title: string): Promise<void> {
// Find the settings-section that contains this title in its header.
const section = page
.locator('.settings-section')
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
// Check if the content div already has the `open` class.
const contentDiv = section.locator('.settings-section-content');
const isOpen = await contentDiv.evaluate((el) =>
el.classList.contains('open'),
);
if (!isOpen) {
// Click the header button to open the section.
await section.locator('button.settings-section-header').click();
// Wait for the CSS transition to complete (opacity 0→1, max-height 0→1000px).
await contentDiv.waitFor({ state: 'visible' });
}
}
/**
* Select a unit from the Y-axis unit selector dropdown by typing a search
* term, then clicking the filtered option. The selector has `showSearch`
* enabled and renders a long virtualised option list — typing first avoids
* instability from the virtualised list re-rendering when the target option
* is off-screen.
*/
async function selectYAxisUnit(
page: Page,
searchTerm: string,
optionText: string,
): Promise<void> {
// Click the outer wrapper to open the dropdown.
const unitSelect = page.locator('.y-axis-unit-selector-v2 .ant-select').first();
await unitSelect.click();
// The Ant Select input is now focused — type to filter the virtual list.
await page.locator('.y-axis-unit-selector-v2 .ant-select input').first().fill(searchTerm);
// Wait for the dropdown to show the filtered option, then click it.
await page
.locator('.ant-select-item-option-content', { hasText: optionText })
.first()
.click();
}
test.describe('Value Panel Controls', () => {
test('TC-01 panel name persists and is reflected in the widget header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('value-controls-renamed');
await saveWidgetEdit(page);
await expect(page.getByTestId('value-controls-renamed').first()).toBeVisible();
await openWidgetEditor(page, 'value-controls-renamed');
await expect(page.getByTestId('panel-name-input')).toHaveValue(
'value-controls-renamed',
);
// Reset back to fixture title so subsequent TCs locate the panel.
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
await saveWidgetEdit(page);
});
test('TC-02 panel description persists and renders the info icon on the header', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page
.getByTestId('panel-description-input')
.fill('E2E test description');
await saveWidgetEdit(page);
await expect(
page
.locator('.widget-header-container')
.filter({ hasText: FIXTURE_PANEL_TITLE })
.locator('.info-tooltip')
.first(),
).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page.getByTestId('panel-description-input')).toHaveValue(
'E2E test description',
);
// Reset
await page.getByTestId('panel-description-input').fill('');
await saveWidgetEdit(page);
});
test('TC-03 panel time preference switches from Global Time to Last 15 min and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
const timeButton = page
.locator('section.panel-time-preference')
.getByRole('button', { name: /global time/i });
await timeButton.click();
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(
page.locator('section.panel-time-preference').getByRole('button'),
).toContainText(/Last 15 min/i);
// Reset
await page
.locator('section.panel-time-preference')
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /Global Time/i }).click();
await saveWidgetEdit(page);
});
test('TC-04 Y-axis unit applies a suffix to the rendered value and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// The Y-Axis Unit selector has showSearch enabled and a long virtualised
// option list. Type "Seconds" to filter before clicking.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
// Live preview should now render a suffix unit `s`.
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
await saveWidgetEdit(page);
// Back on the dashboard the panel card should also render the suffix.
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(
page.locator('.y-axis-unit-selector-v2 .ant-select-selection-item').first(),
).toContainText(/Seconds/);
// Reset — clear the unit via allowClear (X button on the Ant Select).
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-05 decimal precision reformats the rendered value when a unit is set', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Setting a unit is required for decimal precision to have a visible
// effect — see Known Limitations #3 in the test plan.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
.first()
.click();
// Live preview: the numeric text should no longer contain a decimal point.
await expect(page.getByTestId('value-graph-text').first()).not.toContainText(
/\./,
);
await saveWidgetEdit(page);
// Dashboard render: same assertion.
await expect(page.getByTestId('value-graph-text').first()).not.toContainText(
/\./,
);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
/0 decimals/,
);
// Reset: restore default 2 decimals and clear the unit.
await page.getByTestId('decimal-precision-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
.first()
.click();
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
});
test('TC-06 Text-format threshold colors the rendered value text and persists', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds
// (defaultOpen={!!thresholds.length}) — expand it first.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
// VALUE panels do not render a threshold label input — only operator,
// value, unit, format (Text/Background), and color. Defaults: operator
// '>', format 'Text', value 0, color 'Red'. We force operator to '>=' so
// the threshold reliably matches non-negative values.
const thresholdCard = page.locator('.threshold-container').first();
await thresholdCard
.getByTestId('operator-input-selector')
.click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
// Save the threshold row (commits it to the thresholds state array). The
// dashboard PUT still needs `saveWidgetEdit` after this.
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Dashboard render: value text should now carry an inline color style.
const valueText = page.getByTestId('value-graph-text').first();
await expect(valueText).toBeVisible();
const inlineStyle = await valueText.getAttribute('style');
expect(inlineStyle).toMatch(/color:/);
// Re-open editor and verify the threshold round-tripped.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The ThresholdsSection defaultOpen is based on threshold count at mount
// time; due to async state loading it may start collapsed. Expand it.
await expandSection(page, 'Thresholds');
await expect(
page.locator('.threshold-container').first(),
).toBeVisible();
// Reset — delete the threshold. The delete button is `display:none` by
// default and revealed only on `.threshold-card-container:hover`; hover
// the card so the CSS :hover rule activates, then click via testid.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` once the frontend
// build deployed to the test stack includes the new testid (added in
// Threshold.tsx). The class-based fallback is robust meanwhile.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-07 Background-format threshold paints the value container background', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Thresholds" section starts collapsed when there are no thresholds.
await expandSection(page, 'Thresholds');
await page.getByTestId('add-threshold-cta').click();
const thresholdCard = page.locator('.threshold-container').first();
// Set operator >= and switch format from Text to Background.
await thresholdCard.getByTestId('operator-input-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: '>=' })
.first()
.click();
await thresholdCard.getByTestId('threshold-color-selector').click();
await page
.locator('.ant-select-item-option-content', { hasText: 'Background' })
.first()
.click();
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
await saveWidgetEdit(page);
// Dashboard render: .value-graph-container should now have an inline
// background-color style. TODO: switch to `getByTestId('value-graph-container')`
// once the frontend build deployed to the test stack picks up the testid
// added in ValueGraph/index.tsx.
const container = page.locator('.value-graph-container').first();
await expect(container).toBeVisible();
const inlineStyle = await container.getAttribute('style');
expect(inlineStyle).toMatch(/background-color:/);
// Reset
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// ThresholdsSection may start collapsed even with thresholds — always
// expand before interacting with threshold cards.
await expandSection(page, 'Thresholds');
// Edit/delete buttons are display:none by default, revealed on :hover.
const firstCard = page.locator('.threshold-card-container').first();
await firstCard.hover();
// TODO: switch to `getByTestId('threshold-delete-btn')` once the frontend
// build deployed to the test stack includes the new testid (added in
// Threshold.tsx). The class-based fallback is robust meanwhile.
await firstCard.locator('button.delete-btn').click();
await saveWidgetEdit(page);
});
test('TC-08 clearing the Y-axis unit removes the suffix from the rendered value', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// The "Formatting & Units" section starts collapsed — expand it first.
await expandSection(page, 'Formatting & Units');
// Apply a unit first.
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
await saveWidgetEdit(page);
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
// Clear it.
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expandSection(page, 'Formatting & Units');
await page.locator('.y-axis-unit-selector-v2').first().hover();
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
await saveWidgetEdit(page);
// Suffix should be gone from the rendered panel.
await expect(page.getByTestId('value-graph-suffix-unit')).toHaveCount(0);
});
test('TC-09 panel type switch from Number to Time Series persists and re-renders', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await changePanelType(page, 'Time Series');
// Time Series exposes Fill gaps — confirm the right pane re-rendered.
await expect(page.locator('section.fill-gaps')).toBeVisible();
await saveWidgetEdit(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await expect(page).toHaveURL(/graphType=graph/);
// Reset: switch back to Number for downstream TCs.
await changePanelType(page, 'Number');
await saveWidgetEdit(page);
});
test('TC-10 sections hidden for VALUE are not rendered in the right pane', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
// Hidden by the panel-type matrix for VALUE — these sections are not
// rendered in the DOM at all (conditionally excluded by RightContainer).
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
await expect(page.locator('section.log-scale')).toHaveCount(0);
await expect(page.locator('section.legend-position')).toHaveCount(0);
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
await expect(page.locator('section.stack-chart')).toHaveCount(0);
// Expected to be present in the always-open General and Visualization
// sections.
await expect(page.getByTestId('panel-name-input')).toBeVisible();
await expect(page.getByTestId('panel-change-select')).toBeVisible();
// The "Formatting & Units" section is collapsed on open — expand it to
// verify the controls are rendered for VALUE.
await expandSection(page, 'Formatting & Units');
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
// The "Thresholds" section is collapsed when there are no thresholds —
// expand it to verify the Add Threshold CTA is rendered for VALUE.
await expandSection(page, 'Thresholds');
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
});
test('TC-11 discarding right-pane changes does not persist or visually update', async ({
authedPage: page,
}) => {
await gotoFixtureDashboard(page);
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
await page.getByTestId('panel-name-input').fill('discard-value-test');
let putFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
putFired = true;
}
route.continue();
});
await page.getByTestId('discard-button').click();
// If a discard confirmation appears, OK it. Right-pane-only changes
// usually don't trigger one.
const confirmDialog = page.getByRole('dialog').last();
await confirmDialog
.getByRole('button', { name: /^OK$/i })
.click({ timeout: 1000 })
.catch(() => {
// no modal — the editor navigated away immediately
});
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
expect(putFired).toBe(false);
});
});