Compare commits

..

1 Commits

Author SHA1 Message Date
Piyush Singariya
6516640c81 fix: has value fixes 2026-04-07 16:34:59 +05:30
15 changed files with 496 additions and 534 deletions

View File

@@ -1,4 +1,4 @@
import { ReactChild, useCallback, useMemo } from 'react';
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -8,10 +8,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { USER_ROLES } from 'types/roles';
import { routePermission } from 'utils/permission';
@@ -23,7 +25,6 @@ import routes, {
SUPPORT_ROUTE,
} from './routes';
// eslint-disable-next-line sonarjs/cognitive-complexity
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const location = useLocation();
const { pathname } = location;
@@ -56,12 +57,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const currentRoute = mapRoutes.get('current');
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const orgData = useMemo(() => {
if (org && org.length > 0 && org[0].id !== undefined) {
return org[0];
}
return undefined;
}, [org]);
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
query: {
@@ -79,7 +75,214 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return remainingUsers.length === 1;
}, [usersData?.data]);
// Handle old routes - redirect to new routes
useEffect(() => {
if (
isCloudUserVal &&
!isFetchingOrgPreferences &&
orgPreferences &&
!isFetchingUsers &&
usersData &&
usersData.data
) {
const isOnboardingComplete = orgPreferences?.find(
(preference: OrgPreference) =>
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
)?.value;
// Don't redirect to onboarding if workspace has issues (blocked, suspended, or restricted)
// User needs access to settings/billing to fix payment issues
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
const isWorkspaceAccessRestricted =
activeLicense?.state === LicenseState.TERMINATED ||
activeLicense?.state === LicenseState.EXPIRED ||
activeLicense?.state === LicenseState.CANCELLED;
const hasWorkspaceIssue =
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
if (hasWorkspaceIssue) {
return;
}
const isFirstUser = checkFirstTimeUser();
if (
isFirstUser &&
!isOnboardingComplete &&
// if the current route is allowed to be overriden by org onboarding then only do the same
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname)
) {
history.push(ROUTES.ONBOARDING);
}
}
}, [
checkFirstTimeUser,
isCloudUserVal,
isFetchingOrgPreferences,
isFetchingUsers,
orgPreferences,
usersData,
pathname,
trialInfo?.workSpaceBlock,
activeLicense?.state,
]);
const navigateToWorkSpaceBlocked = useCallback((): void => {
const isRouteEnabledForWorkspaceBlockedState =
isAdmin &&
(pathname === ROUTES.SETTINGS ||
pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.BILLING ||
pathname === ROUTES.MY_SETTINGS);
if (
pathname &&
pathname !== ROUTES.WORKSPACE_LOCKED &&
!isRouteEnabledForWorkspaceBlockedState
) {
history.push(ROUTES.WORKSPACE_LOCKED);
}
}, [isAdmin, pathname]);
const navigateToWorkSpaceAccessRestricted = useCallback((): void => {
if (pathname && pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED) {
history.push(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
}
}, [pathname]);
useEffect(() => {
if (!isFetchingActiveLicense && activeLicense) {
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
const isExpired = activeLicense.state === LicenseState.EXPIRED;
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
const { platform } = activeLicense;
if (isWorkspaceAccessRestricted && platform === LicensePlatform.CLOUD) {
navigateToWorkSpaceAccessRestricted();
}
}
}, [
isFetchingActiveLicense,
activeLicense,
navigateToWorkSpaceAccessRestricted,
]);
useEffect(() => {
if (!isFetchingActiveLicense) {
const shouldBlockWorkspace = trialInfo?.workSpaceBlock;
if (
shouldBlockWorkspace &&
activeLicense?.platform === LicensePlatform.CLOUD
) {
navigateToWorkSpaceBlocked();
}
}
}, [
isFetchingActiveLicense,
trialInfo?.workSpaceBlock,
activeLicense?.platform,
navigateToWorkSpaceBlocked,
]);
const navigateToWorkSpaceSuspended = useCallback((): void => {
if (pathname && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
history.push(ROUTES.WORKSPACE_SUSPENDED);
}
}, [pathname]);
useEffect(() => {
if (!isFetchingActiveLicense && activeLicense) {
const shouldSuspendWorkspace =
activeLicense.state === LicenseState.DEFAULTED;
if (
shouldSuspendWorkspace &&
activeLicense.platform === LicensePlatform.CLOUD
) {
navigateToWorkSpaceSuspended();
}
}
}, [isFetchingActiveLicense, activeLicense, navigateToWorkSpaceSuspended]);
useEffect(() => {
if (org && org.length > 0 && org[0].id !== undefined) {
setOrgData(org[0]);
}
}, [org]);
// if the feature flag is enabled and the current route is /get-started then redirect to /get-started-with-signoz-cloud
useEffect(() => {
if (
currentRoute?.path === ROUTES.GET_STARTED &&
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
}
}, [currentRoute, featureFlags]);
// eslint-disable-next-line sonarjs/cognitive-complexity
useEffect(() => {
// if it is an old route navigate to the new route
if (isOldRoute) {
// this will be handled by the redirect component below
return;
}
// if the current route is public dashboard then don't redirect to login
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
if (isPublicDashboard) {
return;
}
// if the current route
if (currentRoute) {
const { isPrivate, key } = currentRoute;
if (isPrivate) {
if (isLoggedInState) {
const route = routePermission[key];
if (route && route.find((e) => e === user.role) === undefined) {
history.push(ROUTES.UN_AUTHORIZED);
}
} else {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
history.push(ROUTES.LOGIN);
}
} else if (isLoggedInState) {
const fromPathname = getLocalStorageApi(
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
);
if (fromPathname) {
history.push(fromPathname);
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
} else if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
history.push(ROUTES.HOME);
}
} else {
// do nothing as the unauthenticated routes are LOGIN and SIGNUP and the LOGIN container takes care of routing to signup if
// setup is not completed
}
} else if (isLoggedInState) {
const fromPathname = getLocalStorageApi(
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
);
if (fromPathname) {
history.push(fromPathname);
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
} else {
history.push(ROUTES.HOME);
}
} else {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
history.push(ROUTES.LOGIN);
}
}, [isLoggedInState, pathname, user, isOldRoute, currentRoute, location]);
if (isOldRoute) {
const redirectUrl = oldNewRoutesMapping[pathname];
return (
@@ -93,143 +296,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
}
// Public dashboard - no redirect needed
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
if (isPublicDashboard) {
return <>{children}</>;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
if (!isFetchingActiveLicense && activeLicense && isCloudPlatform) {
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
const isExpired = activeLicense.state === LicenseState.EXPIRED;
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
if (
isWorkspaceAccessRestricted &&
pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED
) {
return <Redirect to={ROUTES.WORKSPACE_ACCESS_RESTRICTED} />;
}
// Check for workspace suspended (DEFAULTED)
const shouldSuspendWorkspace = activeLicense.state === LicenseState.DEFAULTED;
if (shouldSuspendWorkspace && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
return <Redirect to={ROUTES.WORKSPACE_SUSPENDED} />;
}
}
// Check for workspace blocked (trial expired)
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
const isRouteEnabledForWorkspaceBlockedState =
isAdmin &&
(pathname === ROUTES.SETTINGS ||
pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.BILLING ||
pathname === ROUTES.MY_SETTINGS);
if (
pathname !== ROUTES.WORKSPACE_LOCKED &&
!isRouteEnabledForWorkspaceBlockedState
) {
return <Redirect to={ROUTES.WORKSPACE_LOCKED} />;
}
}
// Check for onboarding redirect (cloud users, first user, onboarding not complete)
if (
isCloudUserVal &&
!isFetchingOrgPreferences &&
orgPreferences &&
!isFetchingUsers &&
usersData &&
usersData.data
) {
const isOnboardingComplete = orgPreferences?.find(
(preference: OrgPreference) =>
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
)?.value;
// Don't redirect to onboarding if workspace has issues
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
const isWorkspaceAccessRestricted =
activeLicense?.state === LicenseState.TERMINATED ||
activeLicense?.state === LicenseState.EXPIRED ||
activeLicense?.state === LicenseState.CANCELLED;
const hasWorkspaceIssue =
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
if (!hasWorkspaceIssue) {
const isFirstUser = checkFirstTimeUser();
if (
isFirstUser &&
!isOnboardingComplete &&
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname) &&
pathname !== ROUTES.ONBOARDING
) {
return <Redirect to={ROUTES.ONBOARDING} />;
}
}
}
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
if (
currentRoute?.path === ROUTES.GET_STARTED &&
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
) {
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
}
// Main routing logic
if (currentRoute) {
const { isPrivate, key } = currentRoute;
if (isPrivate) {
if (isLoggedInState) {
const route = routePermission[key];
if (route && route.find((e) => e === user.role) === undefined) {
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
}
} else {
// Save current path and redirect to login
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
return <Redirect to={ROUTES.LOGIN} />;
}
} else if (isLoggedInState) {
// Non-private route, but user is logged in
const fromPathname = getLocalStorageApi(
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} />;
}
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
return <Redirect to={ROUTES.HOME} />;
}
}
// Non-private route, user not logged in - let login/signup pages handle it
} else if (isLoggedInState) {
// Unknown route, logged in
const fromPathname = getLocalStorageApi(
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} />;
}
return <Redirect to={ROUTES.HOME} />;
} else {
// Unknown route, not logged in
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
return <Redirect to={ROUTES.LOGIN} />;
}
// NOTE: disabling this rule as there is no need to have div
return <>{children}</>;
}

View File

@@ -6,6 +6,7 @@ import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { AppContext } from 'providers/App/App';
import { IAppContext, IUser } from 'providers/App/types';
import {
@@ -21,6 +22,19 @@ import { ROLES, USER_ROLES } from 'types/roles';
import PrivateRoute from '../Private';
// Mock history module
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: { pathname: '/', search: '', hash: '' },
listen: jest.fn(),
createHref: jest.fn(),
},
}));
const mockHistoryPush = history.push as jest.Mock;
// Mock localStorage APIs
const mockLocalStorage: Record<string, string> = {};
jest.mock('api/browser/localstorage/get', () => ({
@@ -225,18 +239,20 @@ function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
}
// Generic assertion helpers for navigation behavior
// Using location-based assertions since Private.tsx now uses Redirect component
// Using these allows easier refactoring when switching from history.push to Redirect component
async function assertRedirectsTo(targetRoute: string): Promise<void> {
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent(targetRoute);
expect(mockHistoryPush).toHaveBeenCalledWith(targetRoute);
});
}
function assertStaysOnRoute(expectedRoute: string): void {
expect(screen.getByTestId('location-display')).toHaveTextContent(
expectedRoute,
);
function assertNoRedirect(): void {
expect(mockHistoryPush).not.toHaveBeenCalled();
}
function assertDoesNotRedirectTo(targetRoute: string): void {
expect(mockHistoryPush).not.toHaveBeenCalledWith(targetRoute);
}
function assertRendersChildren(): void {
@@ -334,7 +350,7 @@ describe('PrivateRoute', () => {
});
assertRendersChildren();
assertStaysOnRoute('/public/dashboard/abc123');
assertNoRedirect();
});
it('should render children for public dashboard route when logged in without redirecting', () => {
@@ -346,7 +362,7 @@ describe('PrivateRoute', () => {
assertRendersChildren();
// Critical: without the isPublicDashboard early return, logged-in users
// would be redirected to HOME due to the non-private route handling
assertStaysOnRoute('/public/dashboard/abc123');
assertNoRedirect();
});
});
@@ -404,7 +420,7 @@ describe('PrivateRoute', () => {
});
assertRendersChildren();
assertStaysOnRoute(ROUTES.HOME);
assertNoRedirect();
});
it('should redirect to unauthorized when VIEWER tries to access admin-only route /alerts/new', async () => {
@@ -513,7 +529,7 @@ describe('PrivateRoute', () => {
appContext: { isLoggedIn: true },
});
assertStaysOnRoute(ROUTES.SOMETHING_WENT_WRONG);
assertDoesNotRedirectTo(ROUTES.HOME);
});
});
@@ -525,7 +541,7 @@ describe('PrivateRoute', () => {
});
// Should not redirect - login page handles its own routing
assertStaysOnRoute(ROUTES.LOGIN);
assertNoRedirect();
});
it('should not redirect when not logged in user visits signup page', () => {
@@ -534,7 +550,7 @@ describe('PrivateRoute', () => {
appContext: { isLoggedIn: false },
});
assertStaysOnRoute(ROUTES.SIGN_UP);
assertNoRedirect();
});
it('should not redirect when not logged in user visits password reset page', () => {
@@ -543,7 +559,7 @@ describe('PrivateRoute', () => {
appContext: { isLoggedIn: false },
});
assertStaysOnRoute(ROUTES.PASSWORD_RESET);
assertNoRedirect();
});
it('should not redirect when not logged in user visits forgot password page', () => {
@@ -552,7 +568,7 @@ describe('PrivateRoute', () => {
appContext: { isLoggedIn: false },
});
assertStaysOnRoute(ROUTES.FORGOT_PASSWORD);
assertNoRedirect();
});
});
@@ -641,7 +657,7 @@ describe('PrivateRoute', () => {
});
// Admin should be able to access settings even when workspace is blocked
assertStaysOnRoute(ROUTES.SETTINGS);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow ADMIN to access /settings/billing when workspace is blocked', () => {
@@ -657,7 +673,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.BILLING);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow ADMIN to access /settings/org-settings when workspace is blocked', () => {
@@ -673,7 +689,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.ORG_SETTINGS);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow ADMIN to access /settings/members when workspace is blocked', () => {
@@ -689,7 +705,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.MEMBERS_SETTINGS);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow ADMIN to access /settings/my-settings when workspace is blocked', () => {
@@ -705,7 +721,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.MY_SETTINGS);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
@@ -816,7 +832,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should not redirect self-hosted users to workspace locked even when workSpaceBlock is true', () => {
@@ -833,7 +849,7 @@ describe('PrivateRoute', () => {
isCloudUser: false,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
});
@@ -903,7 +919,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
it('should not redirect self-hosted users to workspace access restricted when license is terminated', () => {
@@ -920,7 +936,7 @@ describe('PrivateRoute', () => {
isCloudUser: false,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
it('should not redirect when license is ACTIVE', () => {
@@ -937,7 +953,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
it('should not redirect when license is EVALUATING', () => {
@@ -954,7 +970,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
});
@@ -990,7 +1006,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
});
it('should not redirect self-hosted users to workspace suspended when license is defaulted', () => {
@@ -1007,7 +1023,7 @@ describe('PrivateRoute', () => {
isCloudUser: false,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
});
});
@@ -1027,11 +1043,6 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
// Wait for the users query to complete and trigger re-render
await act(async () => {
await Promise.resolve();
});
await assertRedirectsTo(ROUTES.ONBOARDING);
});
@@ -1047,7 +1058,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when onboarding is already complete', async () => {
@@ -1073,7 +1084,7 @@ describe('PrivateRoute', () => {
// Critical: if isOnboardingComplete check is broken (always false),
// this test would fail because all other conditions for redirect ARE met
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding for non-cloud users', () => {
@@ -1088,7 +1099,7 @@ describe('PrivateRoute', () => {
isCloudUser: false,
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when on /workspace-locked route', () => {
@@ -1103,7 +1114,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when on /workspace-suspended route', () => {
@@ -1118,7 +1129,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is blocked and accessing billing', async () => {
@@ -1145,7 +1156,7 @@ describe('PrivateRoute', () => {
});
// Should NOT redirect to onboarding - user needs to access billing to fix payment
assertStaysOnRoute(ROUTES.BILLING);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is blocked and accessing settings', async () => {
@@ -1169,7 +1180,7 @@ describe('PrivateRoute', () => {
await Promise.resolve();
});
assertStaysOnRoute(ROUTES.SETTINGS);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is suspended (DEFAULTED)', async () => {
@@ -1196,7 +1207,7 @@ describe('PrivateRoute', () => {
});
// Should redirect to WORKSPACE_SUSPENDED, not ONBOARDING
await assertRedirectsTo(ROUTES.WORKSPACE_SUSPENDED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is access restricted (TERMINATED)', async () => {
@@ -1223,7 +1234,7 @@ describe('PrivateRoute', () => {
});
// Should redirect to WORKSPACE_ACCESS_RESTRICTED, not ONBOARDING
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
it('should not redirect to onboarding when workspace is access restricted (EXPIRED)', async () => {
@@ -1249,7 +1260,7 @@ describe('PrivateRoute', () => {
await Promise.resolve();
});
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
});
});
@@ -1291,7 +1302,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
});
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
@@ -1303,7 +1314,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
});
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
@@ -1323,7 +1334,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
});
});
@@ -1339,7 +1350,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
});
it('should not fetch users when org data is not available', () => {
@@ -1382,7 +1393,9 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.HOME);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
});
});
@@ -1423,40 +1436,22 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should allow ADMIN to access /services route', () => {
renderPrivateRoute({
initialRoute: ROUTES.APPLICATION,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: USER_ROLES.ADMIN as ROLES }),
},
it('should allow all roles to access /services route', () => {
const roles = [USER_ROLES.ADMIN, USER_ROLES.EDITOR, USER_ROLES.VIEWER];
roles.forEach((role) => {
jest.clearAllMocks();
renderPrivateRoute({
initialRoute: ROUTES.APPLICATION,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: role as ROLES }),
},
});
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
});
assertStaysOnRoute(ROUTES.APPLICATION);
});
it('should allow EDITOR to access /services route', () => {
renderPrivateRoute({
initialRoute: ROUTES.APPLICATION,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
},
});
assertStaysOnRoute(ROUTES.APPLICATION);
});
it('should allow VIEWER to access /services route', () => {
renderPrivateRoute({
initialRoute: ROUTES.APPLICATION,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
},
});
assertStaysOnRoute(ROUTES.APPLICATION);
});
it('should redirect VIEWER from /onboarding route (admin only)', async () => {
@@ -1486,7 +1481,7 @@ describe('PrivateRoute', () => {
});
assertRendersChildren();
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
});
it('should allow EDITOR to access /get-started route', () => {
@@ -1498,7 +1493,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
});
});

View File

@@ -13,7 +13,7 @@ import uPlot from 'uplot';
import { ChartProps } from '../types';
const TOOLTIP_WIDTH_PADDING = 120;
const TOOLTIP_WIDTH_PADDING = 60;
const TOOLTIP_MIN_WIDTH = 200;
export default function ChartWrapper({

View File

@@ -1,72 +0,0 @@
.uplot-tooltip-container {
font-family: 'Inter';
font-size: 12px;
background: var(--bg-ink-300);
-webkit-font-smoothing: antialiased;
color: var(--bg-vanilla-100);
border-radius: 6px;
border: 1px solid var(--bg-ink-100);
display: flex;
flex-direction: column;
gap: 8px;
&.lightMode {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
.uplot-tooltip-list {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}
.uplot-tooltip-divider {
background-color: var(--bg-vanilla-300);
}
}
.uplot-tooltip-header-container {
padding: 1rem 1rem 0 1rem;
display: flex;
flex-direction: column;
gap: 8px;
&:last-child {
padding-bottom: 1rem;
}
.uplot-tooltip-header {
font-size: 13px;
font-weight: 500;
}
}
.uplot-tooltip-divider {
width: 100%;
height: 1px;
background-color: var(--bg-ink-100);
}
.uplot-tooltip-list {
// Virtuoso absolutely positions its item rows; left: 0 prevents accidental
// horizontal offset when the scroller has padding or transform applied.
div[data-viewport-type='element'] {
left: 0;
padding: 4px 8px 4px 16px;
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}
}

View File

@@ -0,0 +1,70 @@
.uplot-tooltip-container {
font-family: 'Inter';
font-size: 12px;
background: var(--bg-ink-300);
-webkit-font-smoothing: antialiased;
color: var(--bg-vanilla-100);
border-radius: 6px;
padding: 1rem 0.5rem 0.5rem 1rem;
border: 1px solid var(--bg-ink-100);
display: flex;
flex-direction: column;
gap: 8px;
&.lightMode {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
.uplot-tooltip-list {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}
}
.uplot-tooltip-header {
font-size: 13px;
font-weight: 500;
}
.uplot-tooltip-list-container {
overflow-y: auto;
max-height: 330px;
.uplot-tooltip-list {
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}
}
.uplot-tooltip-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.uplot-tooltip-item-marker {
border-radius: 50%;
border-width: 2px;
width: 12px;
height: 12px;
flex-shrink: 0;
}
.uplot-tooltip-item-content {
white-space: wrap;
word-break: break-all;
}
}
}

View File

@@ -7,14 +7,12 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTimezone } from 'providers/Timezone';
import { TooltipProps } from '../types';
import TooltipItem from './components/TooltipItem/TooltipItem';
import Styles from './Tooltip.module.scss';
import './Tooltip.styles.scss';
// Fallback per-item height used for the initial size estimate before
// Virtuoso reports the real total height via totalListHeightChanged.
const TOOLTIP_LIST_MAX_HEIGHT = 330;
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
const TOOLTIP_LIST_PADDING = 10;
export default function Tooltip({
uPlotInstance,
@@ -23,26 +21,27 @@ export default function Tooltip({
showTooltipHeader = true,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [listHeight, setListHeight] = useState(0);
const tooltipContent = content ?? [];
const { timezone: userTimezone } = useTimezone();
const [totalListHeight, setTotalListHeight] = useState(0);
const tooltipContent = useMemo(() => content ?? [], [content]);
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const resolvedTimezone = useMemo(() => {
if (!timezone) {
return userTimezone.value;
}
return timezone.value;
}, [timezone, userTimezone]);
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
const data = uPlotInstance.data;
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
return dayjs(data[0][cursorIdx] * 1000)
.tz(resolvedTimezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [
@@ -52,68 +51,60 @@ export default function Tooltip({
showTooltipHeader,
]);
const activeItem = useMemo(
() => tooltipContent.find((item) => item.isActive) ?? null,
[tooltipContent],
);
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on the first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const virtuosoHeight = useMemo(() => {
return totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(tooltipContent.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT);
}, [totalListHeight, tooltipContent.length]);
const showHeader = showTooltipHeader || activeItem != null;
// With a single series the active item is fully represented in the header —
// hide the divider and list to avoid showing a duplicate row.
const showList = tooltipContent.length > 1;
const showDivider = showList && showHeader;
const virtuosoStyle = useMemo(() => {
return {
height:
listHeight > 0
? Math.min(listHeight + TOOLTIP_LIST_PADDING, TOOLTIP_LIST_MAX_HEIGHT)
: Math.min(
tooltipContent.length * TOOLTIP_ITEM_HEIGHT,
TOOLTIP_LIST_MAX_HEIGHT,
),
width: '100%',
};
}, [listHeight, tooltipContent.length]);
return (
<div
className={cx(Styles.uplotTooltipContainer, !isDarkMode && Styles.lightMode)}
className={cx(
'uplot-tooltip-container',
isDarkMode ? 'darkMode' : 'lightMode',
)}
data-testid="uplot-tooltip-container"
>
{showHeader && (
<div className={Styles.uplotTooltipHeaderContainer}>
{showTooltipHeader && headerTitle && (
<div
className={Styles.uplotTooltipHeader}
data-testid="uplot-tooltip-header"
>
<span>{headerTitle}</span>
</div>
)}
{activeItem && (
<TooltipItem
item={activeItem}
isItemActive={true}
containerTestId="uplot-tooltip-pinned"
markerTestId="uplot-tooltip-pinned-marker"
contentTestId="uplot-tooltip-pinned-content"
/>
)}
{showTooltipHeader && (
<div className="uplot-tooltip-header" data-testid="uplot-tooltip-header">
<span>{headerTitle}</span>
</div>
)}
{showDivider && <span className={Styles.uplotTooltipDivider} />}
{showList && (
<Virtuoso
className={Styles.uplotTooltipList}
data-testid="uplot-tooltip-list"
data={tooltipContent}
style={{ height: virtuosoHeight, width: '100%' }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
/>
)}
<div className="uplot-tooltip-list-container">
{tooltipContent.length > 0 ? (
<Virtuoso
className="uplot-tooltip-list"
data-testid="uplot-tooltip-list"
data={tooltipContent}
style={virtuosoStyle}
totalListHeightChanged={setListHeight}
itemContent={(_, item): JSX.Element => (
<div className="uplot-tooltip-item" data-testid="uplot-tooltip-item">
<div
className="uplot-tooltip-item-marker"
style={{ borderColor: item.color }}
data-is-legend-marker={true}
data-testid="uplot-tooltip-item-marker"
/>
<div
className="uplot-tooltip-item-content"
style={{ color: item.color, fontWeight: item.isActive ? 700 : 400 }}
data-testid="uplot-tooltip-item-content"
>
{item.label}: {item.tooltipValue}
</div>
</div>
)}
/>
) : null}
</div>
</div>
);
}

View File

@@ -133,30 +133,46 @@ describe('Tooltip', () => {
expect(screen.queryByText(unexpectedTitle)).not.toBeInTheDocument();
});
it('renders single active item in header only, without a list', () => {
it('renders lightMode class when dark mode is disabled', () => {
const uPlotInstance = createUPlotInstance(null);
const content = [createTooltipContent({ isActive: true })];
mockUseIsDarkMode.mockReturnValue(false);
renderTooltip({ uPlotInstance, content });
renderTooltip({ uPlotInstance });
// Active item is shown in the header, not duplicated in a list
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
expect(screen.getByTestId('uplot-tooltip-pinned')).toBeInTheDocument();
const pinnedContent = screen.getByTestId('uplot-tooltip-pinned-content');
expect(pinnedContent).toHaveTextContent('Series A');
expect(pinnedContent).toHaveTextContent('10');
const container = screen.getByTestId('uplot-tooltip-container');
expect(container).toHaveClass('lightMode');
expect(container).not.toHaveClass('darkMode');
});
it('renders list when multiple series are present', () => {
it('renders darkMode class when dark mode is enabled', () => {
const uPlotInstance = createUPlotInstance(null);
const content = [
createTooltipContent({ isActive: true }),
createTooltipContent({ label: 'Series B', isActive: false }),
];
mockUseIsDarkMode.mockReturnValue(true);
renderTooltip({ uPlotInstance });
const container = screen.getByTestId('uplot-tooltip-container');
expect(container).toHaveClass('darkMode');
expect(container).not.toHaveClass('lightMode');
});
it('renders tooltip items when content is provided', () => {
const uPlotInstance = createUPlotInstance(null);
const content = [createTooltipContent()];
renderTooltip({ uPlotInstance, content });
expect(screen.getByTestId('uplot-tooltip-list')).toBeInTheDocument();
const list = screen.queryByTestId('uplot-tooltip-list');
expect(list).not.toBeNull();
const marker = screen.getByTestId('uplot-tooltip-item-marker');
const itemContent = screen.getByTestId('uplot-tooltip-item-content');
expect(marker).toHaveStyle({ borderColor: '#ff0000' });
expect(itemContent).toHaveStyle({ color: '#ff0000', fontWeight: '700' });
expect(itemContent).toHaveTextContent('Series A: 10');
});
it('does not render tooltip list when content is empty', () => {
@@ -176,7 +192,7 @@ describe('Tooltip', () => {
renderTooltip({ uPlotInstance, content });
const list = screen.getByTestId('uplot-tooltip-list');
expect(list).toHaveStyle({ height: '200px' });
expect(list).toHaveStyle({ height: '210px' });
});
it('sets tooltip list height based on content length when Virtuoso reports 0 height', () => {

View File

@@ -189,7 +189,7 @@ describe('Tooltip utils', () => {
];
}
it('builds tooltip content in series-index order with isActive flag set correctly', () => {
it('builds tooltip content with active series first', () => {
const data: AlignedData = [[0], [10], [20], [30]];
const series = createSeriesConfig();
const dataIndexes = [null, 0, 0, 0];
@@ -206,21 +206,21 @@ describe('Tooltip utils', () => {
});
expect(result).toHaveLength(2);
// Series are returned in series-index order (A=index 1 before B=index 2)
// Active (series index 2) should come first
expect(result[0]).toMatchObject<Partial<TooltipContentItem>>({
label: 'A',
value: 10,
tooltipValue: 'formatted-10',
color: '#ff0000',
isActive: false,
});
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
label: 'B',
value: 20,
tooltipValue: 'formatted-20',
color: 'color-2',
isActive: true,
});
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
label: 'A',
value: 10,
tooltipValue: 'formatted-10',
color: '#ff0000',
isActive: false,
});
});
it('skips series with null data index or non-finite values', () => {
@@ -273,31 +273,5 @@ describe('Tooltip utils', () => {
expect(result[0].value).toBe(30);
expect(result[1].value).toBe(30);
});
it('returns items in series-index order', () => {
// Series values in non-sorted order: 3, 1, 4, 2
const data: AlignedData = [[0], [3], [1], [4], [2]];
const series: Series[] = [
{ label: 'x', show: true } as Series,
{ label: 'C', show: true, stroke: '#aaaaaa' } as Series,
{ label: 'A', show: true, stroke: '#bbbbbb' } as Series,
{ label: 'D', show: true, stroke: '#cccccc' } as Series,
{ label: 'B', show: true, stroke: '#dddddd' } as Series,
];
const dataIndexes = [null, 0, 0, 0, 0];
const u = createUPlotInstance();
const result = buildTooltipContent({
data,
series,
dataIndexes,
activeSeriesIndex: null,
uPlotInstance: u,
yAxisUnit,
decimalPrecision,
});
expect(result.map((item) => item.value)).toEqual([3, 1, 4, 2]);
});
});
});

View File

@@ -1,36 +0,0 @@
.uplot-tooltip-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
.uplot-tooltip-item-marker {
border-radius: 50%;
border-style: solid;
border-width: 2px;
width: 12px;
height: 12px;
flex-shrink: 0;
}
.uplot-tooltip-item-content {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
.uplot-tooltip-item-label {
white-space: normal;
overflow-wrap: anywhere;
}
&-separator {
flex: 1;
border-width: 0.5px;
border-style: dashed;
min-width: 24px;
opacity: 0.5;
}
}
}

View File

@@ -1,49 +0,0 @@
import { TooltipContentItem } from '../../../types';
import Styles from './TooltipItem.module.scss';
interface TooltipItemProps {
item: TooltipContentItem;
isItemActive: boolean;
containerTestId?: string;
markerTestId?: string;
contentTestId?: string;
}
export default function TooltipItem({
item,
isItemActive,
containerTestId = 'uplot-tooltip-item',
markerTestId = 'uplot-tooltip-item-marker',
contentTestId = 'uplot-tooltip-item-content',
}: TooltipItemProps): JSX.Element {
return (
<div
className={Styles.uplotTooltipItem}
style={{
opacity: isItemActive ? 1 : 0.7,
fontWeight: isItemActive ? 700 : 400,
}}
data-testid={containerTestId}
>
<div
className={Styles.uplotTooltipItemMarker}
style={{ borderColor: item.color }}
data-is-legend-marker={true}
data-testid={markerTestId}
/>
<div
className={Styles.uplotTooltipItemContent}
style={{ color: item.color }}
data-testid={contentTestId}
>
<span className={Styles.uplotTooltipItemLabel}>{item.label}</span>
<span
className={Styles.uplotTooltipItemContentSeparator}
style={{ borderColor: item.color }}
/>
<span>{item.tooltipValue}</span>
</div>
</div>
);
}

View File

@@ -38,16 +38,16 @@ export function getTooltipBaseValue({
// When series are hidden, we must use the next *visible* series, not index+1,
// since hidden series keep raw values and would produce negative/wrong results.
if (isStackedBarChart && baseValue !== null && series) {
let nextVisibleSeriesIdx = -1;
for (let seriesIdx = index + 1; seriesIdx < series.length; seriesIdx++) {
if (series[seriesIdx]?.show) {
nextVisibleSeriesIdx = seriesIdx;
let nextVisibleIdx = -1;
for (let j = index + 1; j < series.length; j++) {
if (series[j]?.show) {
nextVisibleIdx = j;
break;
}
}
if (nextVisibleSeriesIdx >= 1) {
const nextStackedValue = data[nextVisibleSeriesIdx][dataIndex] ?? 0;
baseValue = baseValue - nextStackedValue;
if (nextVisibleIdx >= 1) {
const nextValue = data[nextVisibleIdx][dataIndex] ?? 0;
baseValue = baseValue - nextValue;
}
}
return baseValue;
@@ -72,15 +72,16 @@ export function buildTooltipContent({
decimalPrecision?: PrecisionOption;
isStackedBarChart?: boolean;
}): TooltipContentItem[] {
const items: TooltipContentItem[] = [];
const active: TooltipContentItem[] = [];
const rest: TooltipContentItem[] = [];
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
const seriesItem = series[seriesIndex];
if (!seriesItem?.show) {
for (let index = 1; index < series.length; index += 1) {
const s = series[index];
if (!s?.show) {
continue;
}
const dataIndex = dataIndexes[seriesIndex];
const dataIndex = dataIndexes[index];
// Skip series with no data at the current cursor position
if (dataIndex === null) {
continue;
@@ -88,22 +89,30 @@ export function buildTooltipContent({
const baseValue = getTooltipBaseValue({
data,
index: seriesIndex,
index,
dataIndex,
isStackedBarChart,
series,
});
const isActive = index === activeSeriesIndex;
if (Number.isFinite(baseValue) && baseValue !== null) {
items.push({
label: String(seriesItem.label ?? ''),
const item: TooltipContentItem = {
label: String(s.label ?? ''),
value: baseValue,
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: seriesIndex === activeSeriesIndex,
});
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
isActive,
};
if (isActive) {
active.push(item);
} else {
rest.push(item);
}
}
}
return items;
return [...active, ...rest];
}

View File

@@ -36,8 +36,8 @@ const HOVER_DISMISS_DELAY_MS = 100;
export default function TooltipPlugin({
config,
render,
maxWidth = 450,
maxHeight = 600,
maxWidth = 300,
maxHeight = 400,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
pinnedTooltipElement,

View File

@@ -97,9 +97,6 @@ export default defineConfig(
javascriptEnabled: true,
},
},
modules: {
localsConvention: 'camelCaseOnly',
},
},
define: {
// TODO: Remove this in favor of import.meta.env

View File

@@ -818,9 +818,9 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
case "has":
cond = fmt.Sprintf("has(%s, %s)", fieldName, v.builder.Var(value[0]))
case "hasAny":
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value))
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value[0]))
case "hasAll":
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value))
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value[0]))
}
conds = append(conds, cond)
}

View File

@@ -198,7 +198,7 @@ func TestStatementBuilderListQueryBodyHas(t *testing.T) {
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND hasAll(dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), []any{[]any{"read", "write"}}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Args: []any{uint64(1747945619), uint64(1747983448), []any{"read", "write"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
@@ -212,7 +212,7 @@ func TestStatementBuilderListQueryBodyHas(t *testing.T) {
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND hasAny(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), []any{[]any{"Piyush", "Tushar"}}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Args: []any{uint64(1747945619), uint64(1747983448), []any{"Piyush", "Tushar"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},