Compare commits

..

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
5ad3c7b191 fix(app-routes): use redirect component to avoid flash old routes due to useEffect 2026-04-06 10:41:12 -03:00
25 changed files with 594 additions and 745 deletions

View File

@@ -1,4 +1,4 @@
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
import { ReactChild, useCallback, useMemo } 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,12 +8,10 @@ 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';
@@ -25,6 +23,7 @@ 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;
@@ -57,7 +56,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const currentRoute = mapRoutes.get('current');
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
const orgData = useMemo(() => {
if (org && org.length > 0 && org[0].id !== undefined) {
return org[0];
}
return undefined;
}, [org]);
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
query: {
@@ -75,214 +79,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return remainingUsers.length === 1;
}, [usersData?.data]);
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]);
// Handle old routes - redirect to new routes
if (isOldRoute) {
const redirectUrl = oldNewRoutesMapping[pathname];
return (
@@ -296,7 +93,143 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
}
// NOTE: disabling this rule as there is no need to have div
// 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} />;
}
return <>{children}</>;
}

View File

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

View File

@@ -13,9 +13,7 @@ export interface HostListPayload {
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
} | null;
start?: number;
end?: number;
};
}
export interface TimeSeriesValue {

View File

@@ -14,8 +14,6 @@ import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schem
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import './CreateServiceAccountModal.styles.scss';
@@ -30,8 +28,6 @@ function CreateServiceAccountModal(): JSX.Element {
parseAsBoolean.withDefault(false),
);
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const {
control,
handleSubmit,
@@ -58,10 +54,13 @@ function CreateServiceAccountModal(): JSX.Element {
await invalidateListServiceAccounts(queryClient);
},
onError: (err) => {
const errMessage = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMessage as APIError);
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to create service account: ${errMessage}`, {
richColors: true,
});
},
},
});
@@ -91,7 +90,7 @@ function CreateServiceAccountModal(): JSX.Element {
showCloseButton
width="narrow"
className="create-sa-modal"
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<div className="create-sa-modal__content">
<form

View File

@@ -28,7 +28,6 @@ import {
useMemberRoleManager,
} from 'hooks/member/useMemberRoleManager';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
@@ -91,11 +90,8 @@ function EditMemberDrawer({
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
const isInvited = member?.status === MemberStatus.Invited;
const isDeleted = member?.status === MemberStatus.Deleted;
const isSelf = !!member?.id && member.id === currentUser?.id;
const { showErrorModal } = useErrorModal();
const {
data: fetchedUser,
isLoading: isFetchingUser,
@@ -157,10 +153,17 @@ function EditMemberDrawer({
onClose();
},
onError: (err): void => {
const errMessage = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMessage as APIError);
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
const prefix = isInvited
? 'Failed to revoke invite'
: 'Failed to delete member';
toast.error(`${prefix}: ${errMessage}`, {
richColors: true,
position: 'top-right',
});
},
},
});
@@ -341,15 +344,15 @@ function EditMemberDrawer({
position: 'top-right',
});
}
} catch (err) {
const errMsg = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMsg as APIError);
} catch {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
} finally {
setIsGeneratingLink(false);
}
}, [member, isInvited, onClose, showErrorModal]);
}, [member, isInvited, onClose]);
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopyResetLink = useCallback((): void => {
@@ -416,7 +419,7 @@ function EditMemberDrawer({
}}
className="edit-member-drawer__input"
placeholder="Enter name"
disabled={isRootUser || isDeleted}
disabled={isRootUser}
/>
</Tooltip>
</div>
@@ -437,15 +440,9 @@ function EditMemberDrawer({
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
{isSelf || isRootUser || isDeleted ? (
{isSelf || isRootUser ? (
<Tooltip
title={
isRootUser
? ROOT_USER_TOOLTIP
: isDeleted
? undefined
: 'You cannot modify your own role'
}
title={isRootUser ? ROOT_USER_TOOLTIP : 'You cannot modify your own role'}
>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<div className="edit-member-drawer__disabled-roles">
@@ -470,7 +467,7 @@ function EditMemberDrawer({
onRefetch={refetchRoles}
value={localRole}
onChange={(role): void => {
setLocalRole(role ?? '');
setLocalRole(role);
setSaveErrors((prev) =>
prev.filter(
(err) =>
@@ -479,7 +476,6 @@ function EditMemberDrawer({
);
}}
placeholder="Select role"
allowClear={false}
/>
)}
</div>
@@ -491,10 +487,6 @@ function EditMemberDrawer({
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : member?.status === MemberStatus.Deleted ? (
<Badge color="cherry" variant="outline">
DELETED
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
@@ -533,57 +525,55 @@ function EditMemberDrawer({
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">{drawerBody}</div>
{!isDeleted && (
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
>
<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">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser}
>
<RefreshCw size={12} />
{isGeneratingLink && 'Generating...'}
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser}
>
<RefreshCw size={12} />
{isGeneratingLink && 'Generating...'}
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
</div>
)}
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
</div>
</div>
);

View File

@@ -10,12 +10,7 @@ import APIError from 'types/api/error';
import './ErrorContent.styles.scss';
interface ErrorContentProps {
error:
| APIError
| {
code: number;
message: string;
};
error: APIError;
icon?: ReactNode;
}
@@ -25,15 +20,7 @@ function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
errors: errorMessages,
code: errorCode,
message: errorMessage,
} =
error && 'error' in error
? error?.error?.error || {}
: {
url: undefined,
errors: [],
code: error.code || 500,
message: error.message || 'Something went wrong',
};
} = error?.error?.error || {};
return (
<section className="error-content">
{/* Summary Header */}

View File

@@ -10,7 +10,6 @@ import { Select } from 'antd';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { cloneDeep, debounce } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { EMAIL_REGEX } from 'utils/app';
@@ -41,8 +40,6 @@ function InviteMembersModal({
onClose,
onComplete,
}: InviteMembersModalProps): JSX.Element {
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [rows, setRows] = useState<InviteRow[]>(() => [
EMPTY_ROW(),
EMPTY_ROW(),
@@ -207,11 +204,13 @@ function InviteMembersModal({
resetAndClose();
onComplete?.();
} catch (err) {
showErrorModal(err as APIError);
const apiErr = err as APIError;
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(errorMessage, { richColors: true, position: 'top-right' });
} finally {
setIsSubmitting(false);
}
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
}, [rows, onComplete, resetAndClose, validateAllUsers]);
const touchedRows = rows.filter(isRowTouched);
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
@@ -228,7 +227,7 @@ function InviteMembersModal({
showCloseButton
width="wide"
className="invite-members-modal"
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<div className="invite-members-modal__content">
<div className="invite-members-modal__table">
@@ -330,7 +329,6 @@ function InviteMembersModal({
size="sm"
onClick={handleSubmit}
disabled={isSubmitDisabled}
loading={isSubmitting}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>

View File

@@ -210,7 +210,7 @@ function MembersTable({
index % 2 === 0 ? 'members-table-row--tinted' : ''
}
onRow={(record): React.HTMLAttributes<HTMLElement> => {
const isClickable = !!onRowClick;
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
return {
onClick: (): void => {
if (isClickable) {

View File

@@ -85,8 +85,7 @@ interface BaseProps {
interface SingleProps extends BaseProps {
mode?: 'single';
value?: string;
onChange?: (role: string | undefined) => void;
allowClear?: boolean;
onChange?: (role: string) => void;
}
interface MultipleProps extends BaseProps {
@@ -155,14 +154,13 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
);
}
const { value, onChange, allowClear = true } = props as SingleProps;
const { value, onChange } = props as SingleProps;
return (
<Select
id={id}
value={value || undefined}
onChange={onChange}
placeholder={placeholder}
allowClear={allowClear}
className={cx('roles-single-select', className)}
loading={loading}
notFoundContent={notFoundContent}

View File

@@ -17,8 +17,6 @@ import { AxiosError } from 'axios';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import KeyCreatedPhase from './KeyCreatedPhase';
import KeyFormPhase from './KeyFormPhase';
@@ -29,7 +27,6 @@ import './AddKeyModal.styles.scss';
function AddKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
SA_QUERY_PARAMS.ADD_KEY,
@@ -84,11 +81,11 @@ function AddKeyModal(): JSX.Element {
}
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to create key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -154,7 +151,7 @@ function AddKeyModal(): JSX.Element {
width="base"
className="add-key-modal"
showCloseButton
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
{phase === Phase.FORM && (
<KeyFormPhase

View File

@@ -16,12 +16,9 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
function DeleteAccountModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
SA_QUERY_PARAMS.DELETE_SA,
@@ -48,11 +45,11 @@ function DeleteAccountModal(): JSX.Element {
await invalidateListServiceAccounts(queryClient);
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to delete service account';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -82,7 +79,7 @@ function DeleteAccountModal(): JSX.Element {
width="narrow"
className="alert-dialog sa-delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<p className="sa-delete-dialog__body">
Are you sure you want to delete <strong>{accountName}</strong>? This action

View File

@@ -17,9 +17,7 @@ import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RevokeKeyContent } from '../RevokeKeyModal';
import EditKeyForm from './EditKeyForm';
@@ -43,7 +41,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
const open = !!editKeyId && !!selectedAccountId;
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
const {
@@ -81,11 +78,11 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
}
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to update key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -105,13 +102,12 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
});
}
},
// eslint-disable-next-line sonarjs/no-identical-functions
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -164,7 +160,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
{isRevokeConfirmOpen ? (
<RevokeKeyContent

View File

@@ -17,7 +17,7 @@ interface OverviewTabProps {
localName: string;
onNameChange: (v: string) => void;
localRole: string;
onRoleChange: (v: string | undefined) => void;
onRoleChange: (v: string) => void;
isDisabled: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;

View File

@@ -16,8 +16,6 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
export interface RevokeKeyContentProps {
isRevoking: boolean;
@@ -58,7 +56,6 @@ export function RevokeKeyContent({
function RevokeKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [revokeKeyId, setRevokeKeyId] = useQueryState(
SA_QUERY_PARAMS.REVOKE_KEY,
@@ -86,11 +83,11 @@ function RevokeKeyModal(): JSX.Element {
}
},
onError: (error) => {
showErrorModal(
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
) as APIError,
);
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
},
},
});
@@ -118,7 +115,7 @@ function RevokeKeyModal(): JSX.Element {
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
disableOutsideClick={false}
>
<RevokeKeyContent
isRevoking={isRevoking}

View File

@@ -8,9 +8,7 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getGetServiceAccountRolesQueryKey,
getListServiceAccountsQueryKey,
useDeleteServiceAccountRole,
useGetServiceAccount,
useListServiceAccountKeys,
useUpdateServiceAccount,
@@ -25,10 +23,7 @@ import {
ServiceAccountStatus,
toServiceAccountRow,
} from 'container/ServiceAccountsSettings/utils';
import {
RoleUpdateFailure,
useServiceAccountRoleManager,
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
import { useServiceAccountRoleManager } from 'hooks/serviceAccount/useServiceAccountRoleManager';
import {
parseAsBoolean,
parseAsInteger,
@@ -54,13 +49,6 @@ export interface ServiceAccountDrawerProps {
const PAGE_SIZE = 15;
function toSaveApiError(err: unknown): APIError {
return (
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
);
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
onSuccess,
@@ -165,22 +153,12 @@ function ServiceAccountDrawer({
// the retry for this mutation is safe due to the api being idempotent on backend
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole();
const executeRolesOperation = useCallback(
async (accountId: string): Promise<RoleUpdateFailure[]> => {
if (localRole === '' && currentRoles[0]?.id) {
await deleteRole({
pathParams: { id: accountId, rid: currentRoles[0].id },
});
await queryClient.invalidateQueries(
getGetServiceAccountRolesQueryKey({ id: accountId }),
);
return [];
}
return applyDiff([localRole].filter(Boolean), availableRoles);
},
[localRole, currentRoles, availableRoles, applyDiff, deleteRole, queryClient],
const toSaveApiError = useCallback(
(err: unknown): APIError =>
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>),
[],
);
const retryNameUpdate = useCallback(async (): Promise<void> => {
@@ -202,7 +180,14 @@ function ServiceAccountDrawer({
),
);
}
}, [account, localName, updateMutateAsync, refetchAccount, queryClient]);
}, [
account,
localName,
updateMutateAsync,
refetchAccount,
queryClient,
toSaveApiError,
]);
const handleNameChange = useCallback((name: string): void => {
setLocalName(name);
@@ -225,39 +210,29 @@ function ServiceAccountDrawer({
);
}
},
[],
);
const clearRoleErrors = useCallback((): void => {
setSaveErrors((prev) =>
prev.filter(
(e) => e.context !== 'Roles update' && !e.context.startsWith("Role '"),
),
);
}, []);
const failuresToSaveErrors = useCallback(
(failures: RoleUpdateFailure[]): SaveError[] =>
failures.map((f) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
}),
[makeRoleRetry],
[toSaveApiError],
);
const retryRolesUpdate = useCallback(async (): Promise<void> => {
try {
const failures = await executeRolesOperation(selectedAccountId ?? '');
const failures = await applyDiff(
[localRole].filter(Boolean),
availableRoles,
);
if (failures.length === 0) {
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
} else {
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [...rest, ...failuresToSaveErrors(failures)];
const roleErrors = failures.map((f) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
});
return [...rest, ...roleErrors];
});
}
} catch (err) {
@@ -267,7 +242,7 @@ function ServiceAccountDrawer({
),
);
}
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
}, [localRole, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
@@ -286,7 +261,7 @@ function ServiceAccountDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
executeRolesOperation(account.id),
applyDiff([localRole].filter(Boolean), availableRoles),
]);
const errors: SaveError[] = [];
@@ -306,7 +281,14 @@ function ServiceAccountDrawer({
onRetry: retryRolesUpdate,
});
} else {
errors.push(...failuresToSaveErrors(rolesResult.value));
for (const failure of rolesResult.value) {
const context = `Role '${failure.roleName}'`;
errors.push({
context,
apiError: toSaveApiError(failure.error),
onRetry: makeRoleRetry(context, failure.onRetry),
});
}
}
if (errors.length > 0) {
@@ -328,14 +310,17 @@ function ServiceAccountDrawer({
account,
isDirty,
localName,
localRole,
availableRoles,
updateMutateAsync,
executeRolesOperation,
applyDiff,
refetchAccount,
onSuccess,
queryClient,
toSaveApiError,
retryNameUpdate,
makeRoleRetry,
retryRolesUpdate,
failuresToSaveErrors,
]);
const handleClose = useCallback((): void => {
@@ -428,10 +413,7 @@ function ServiceAccountDrawer({
localName={localName}
onNameChange={handleNameChange}
localRole={localRole}
onRoleChange={(role): void => {
setLocalRole(role ?? '');
clearRoleErrors();
}}
onRoleChange={setLocalRole}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}

View File

@@ -1,52 +1,41 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
import { Button, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import {
getHostLists,
HostListPayload,
HostListResponse,
} from 'api/infraMonitoring/getHostLists';
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
import HostMetricDetail from 'components/HostMetricsDetail';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFiltersHosts,
useInfraMonitoringOrderByHosts,
} from 'container/InfraMonitoringK8s/hooks';
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import { useGetHostList } from 'hooks/infraMonitoring/useGetHostList';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Filter } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { useAppContext } from 'providers/App/App';
import { useGlobalTimeStore } from 'store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
IBuilderQuery,
Query,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { AppState } from 'store/reducers';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../constants/features';
import { useAppContext } from '../../providers/App/App';
import HostsListControls from './HostsListControls';
import HostsListTable from './HostsListTable';
import { getHostListsQuery, GetHostsQuickFiltersConfig } from './utils';
import './InfraMonitoring.styles.scss';
const defaultFilters: TagFilter = { items: [], op: 'and' };
const baseQuery = getHostListsQuery();
function HostsList(): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [filters, setFilters] = useInfraMonitoringFiltersHosts();
const [orderBy, setOrderBy] = useInfraMonitoringOrderByHosts();
@@ -73,48 +62,56 @@ function HostsList(): JSX.Element {
const { pageSize, setPageSize } = usePageSize('hosts');
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const query = useMemo(() => {
const baseQuery = getHostListsQuery();
return {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy,
};
}, [pageSize, currentPage, filters, minTime, maxTime, orderBy]);
const queryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
REACT_QUERY_KEY.GET_HOST_LIST,
const queryKey = useMemo(() => {
if (selectedHostName) {
return [
'hostList',
String(pageSize),
String(currentPage),
JSON.stringify(filters),
JSON.stringify(orderBy),
),
[pageSize, currentPage, filters, orderBy, selectedTime],
);
];
}
return [
'hostList',
String(pageSize),
String(currentPage),
JSON.stringify(filters),
JSON.stringify(orderBy),
String(minTime),
String(maxTime),
];
}, [
pageSize,
currentPage,
filters,
orderBy,
selectedHostName,
minTime,
maxTime,
]);
const { data, isFetching, isLoading, isError } = useQuery<
SuccessResponse<HostListResponse> | ErrorResponse,
Error
>({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
const payload: HostListPayload = {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: filters ?? defaultFilters,
orderBy,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
};
return getHostLists(payload, signal);
const { data, isFetching, isLoading, isError } = useGetHostList(
query as HostListPayload,
{
queryKey,
enabled: !!query,
keepPreviousData: true,
},
enabled: true,
keepPreviousData: true,
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
);
const hostMetricsData = useMemo(() => data?.payload?.data?.records || [], [
data,
@@ -230,7 +227,7 @@ function HostsList(): JSX.Element {
isError={isError}
tableData={data}
hostMetricsData={hostMetricsData}
filters={filters ?? defaultFilters}
filters={filters || { items: [], op: 'AND' }}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
onHostClick={handleHostClick}

View File

@@ -10,7 +10,6 @@ import {
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { InfraMonitoringEvents } from 'constants/events';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
@@ -27,40 +26,9 @@ import {
function EmptyOrLoadingView(
viewState: EmptyOrLoadingViewProps,
): React.ReactNode {
if (viewState.showTableLoadingState) {
return (
<div className="hosts-list-loading-state">
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
</div>
);
}
const { isError, data } = viewState;
if (isError || data?.error || (data?.statusCode || 0) >= 300) {
return (
<ErrorContent
error={{
code: data?.statusCode || 500,
message: data?.error || 'Something went wrong',
}}
/>
);
const { isError, errorMessage } = viewState;
if (isError) {
return <Typography>{errorMessage || 'Something went wrong'}</Typography>;
}
if (viewState.showHostsEmptyState) {
return (
@@ -108,6 +76,30 @@ function EmptyOrLoadingView(
</div>
);
}
if (viewState.showTableLoadingState) {
return (
<div className="hosts-list-loading-state">
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
</div>
);
}
return null;
}
@@ -198,8 +190,7 @@ export default function HostsListTable({
!isLoading &&
formattedHostMetricsData.length === 0 &&
(!sentAnyHostMetricsData || isSendingIncorrectK8SAgentMetrics) &&
!filters.items.length &&
!endTimeBeforeRetention;
!filters.items.length;
const showEndTimeBeforeRetentionMessage =
!isFetching &&
@@ -220,7 +211,7 @@ export default function HostsListTable({
const emptyOrLoadingView = EmptyOrLoadingView({
isError,
data,
errorMessage: data?.error ?? '',
showHostsEmptyState,
sentAnyHostMetricsData,
isSendingIncorrectK8SAgentMetrics,

View File

@@ -2,8 +2,8 @@ import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import * as getHostListsApi from 'api/infraMonitoring/getHostLists';
import { render } from '@testing-library/react';
import * as useGetHostListHooks from 'hooks/infraMonitoring/useGetHostList';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import * as appContextHooks from 'providers/App/App';
import * as timezoneHooks from 'providers/Timezone';
@@ -19,10 +19,6 @@ jest.mock('lib/getMinMax', () => ({
maxTime: 1713738000000,
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
})),
getMinMaxForSelectedTime: jest.fn().mockReturnValue({
minTime: 1713734400000000000,
maxTime: 1713738000000000000,
}),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
@@ -45,13 +41,7 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const queryClient = new QueryClient();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
@@ -90,40 +80,27 @@ jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
offset: 0,
},
} as any);
jest.spyOn(getHostListsApi, 'getHostLists').mockResolvedValue({
statusCode: 200,
error: null,
message: 'Success',
payload: {
status: 'success',
data: {
type: 'list',
records: [
{
hostName: 'test-host',
active: true,
os: 'linux',
cpu: 0.75,
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] },
memory: 0.65,
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
wait: 0.03,
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
load15: 0.5,
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
},
],
groups: null,
total: 1,
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: false,
jest.spyOn(useGetHostListHooks, 'useGetHostList').mockReturnValue({
data: {
payload: {
data: {
records: [
{
hostName: 'test-host',
active: true,
cpu: 0.75,
memory: 0.65,
wait: 0.03,
},
],
isSendingK8SAgentMetrics: false,
sentAnyHostMetricsData: true,
},
},
},
params: {} as any,
});
isLoading: false,
isError: false,
} as any);
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
@@ -151,11 +128,7 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('HostsList', () => {
beforeEach(() => {
queryClient.clear();
});
it('renders hosts list table', async () => {
it('renders hosts list table', () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
@@ -167,12 +140,10 @@ describe('HostsList', () => {
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
});
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
});
it('renders filters', async () => {
it('renders filters', () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
@@ -184,8 +155,6 @@ describe('HostsList', () => {
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.filters')).toBeInTheDocument();
});
expect(container.querySelector('.filters')).toBeInTheDocument();
});
});

View File

@@ -1,13 +1,8 @@
import { Dispatch, SetStateAction } from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import {
Progress,
TableColumnType as ColumnType,
Tag,
Tooltip,
Typography,
} from 'antd';
import { Progress, TabsProps, Tag, Tooltip, Typography } from 'antd';
import { TableColumnType as ColumnType } from 'antd';
import { SortOrder } from 'antd/lib/table/interface';
import {
HostData,
@@ -18,6 +13,8 @@ import {
FiltersType,
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import TabLabel from 'components/TabLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TriangleAlert } from 'lucide-react';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -25,6 +22,9 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderBySchemaType } from '../InfraMonitoringK8s/schemas';
import HostsList from './HostsList';
import './InfraMonitoring.styles.scss';
export interface HostRowData {
key?: string;
@@ -112,10 +112,7 @@ export interface HostsListTableProps {
export interface EmptyOrLoadingViewProps {
isError: boolean;
data:
| ErrorResponse<string>
| SuccessResponse<HostListResponse, unknown>
| undefined;
errorMessage: string;
showHostsEmptyState: boolean;
sentAnyHostMetricsData: boolean;
isSendingIncorrectK8SAgentMetrics: boolean;
@@ -144,6 +141,14 @@ function mapOrderByToSortOrder(
: undefined;
}
export const getTabsItems = (): TabsProps['items'] => [
{
label: <TabLabel label="List View" isDisabled={false} tooltipText="" />,
key: PANEL_TYPES.LIST,
children: <HostsList />,
},
];
export const getHostsListColumns = (
orderBy: OrderBySchemaType,
): ColumnType<HostRowData>[] => [

View File

@@ -51,8 +51,6 @@ function MembersSettings(): JSX.Element {
if (filterMode === FilterMode.Invited) {
result = result.filter((m) => m.status === MemberStatus.Invited);
} else if (filterMode === FilterMode.Deleted) {
result = result.filter((m) => m.status === MemberStatus.Deleted);
}
if (searchQuery.trim()) {
@@ -91,9 +89,6 @@ function MembersSettings(): JSX.Element {
const pendingCount = allMembers.filter(
(m) => m.status === MemberStatus.Invited,
).length;
const deletedCount = allMembers.filter(
(m) => m.status === MemberStatus.Deleted,
).length;
const totalCount = allMembers.length;
const filterMenuItems: MenuProps['items'] = [
@@ -123,27 +118,12 @@ function MembersSettings(): JSX.Element {
setPage(1);
},
},
{
key: FilterMode.Deleted,
label: (
<div className="members-filter-option">
<span>Deleted {deletedCount}</span>
{filterMode === FilterMode.Deleted && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Deleted);
setPage(1);
},
},
];
const filterLabel =
filterMode === FilterMode.All
? `All members ⎯ ${totalCount}`
: filterMode === FilterMode.Invited
? `Pending invites ⎯ ${pendingCount}`
: `Deleted ⎯ ${deletedCount}`;
: `Pending invites ⎯ ${pendingCount}`;
const handleInviteComplete = useCallback((): void => {
refetchUsers();

View File

@@ -1,7 +1,6 @@
export enum FilterMode {
All = 'all',
Invited = 'invited',
Deleted = 'deleted',
}
export enum MemberStatus {

View File

@@ -1,19 +1,12 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from '@signozhq/sonner';
import { Button, Form, Input } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
useGetMyOrganization,
useUpdateMyOrganization,
} from 'api/generated/services/orgs';
import { useUpdateMyOrganization } from 'api/generated/services/orgs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
@@ -21,25 +14,8 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
const orgName = Form.useWatch('displayName', form);
const { t } = useTranslation(['organizationsettings', 'common']);
const { showErrorModal } = useErrorModal();
const { org, updateOrg, user } = useAppContext();
const currentOrg = (org || [])[index];
const isAdmin = user.role === USER_ROLES.ADMIN;
const { data: orgData } = useGetMyOrganization({
query: {
enabled: isAdmin && !currentOrg?.displayName,
},
});
const displayName =
currentOrg?.displayName ?? orgData?.data?.displayName ?? '';
useEffect(() => {
if (displayName && !form.getFieldValue('displayName')) {
form.setFieldsValue({ displayName });
}
}, [displayName, form]);
const { org, updateOrg } = useAppContext();
const { displayName } = (org || [])[index];
const {
mutateAsync: updateMyOrganization,
@@ -54,8 +30,12 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
updateOrg(orgId, data.displayName ?? '');
},
onError: (error) => {
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
const apiError = convertToApiError(
error as AxiosError<RenderErrorResponseDTO>,
);
toast.error(
apiError?.getErrorMessage() ?? t('something_went_wrong', { ns: 'common' }),
{ richColors: true, position: 'top-right' },
);
},
},

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getHostLists,
HostListPayload,
HostListResponse,
} from 'api/infraMonitoring/getHostLists';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetHostList = (
requestData: HostListPayload,
options?: UseQueryOptions<
SuccessResponse<HostListResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<SuccessResponse<HostListResponse> | ErrorResponse, Error>;
export const useGetHostList: UseGetHostList = (
requestData,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_HOST_LIST, requestData];
}, [options?.queryKey, requestData]);
return useQuery<SuccessResponse<HostListResponse> | ErrorResponse, Error>({
queryFn: ({ signal }) => getHostLists(requestData, signal, headers),
...options,
queryKey,
});
};

View File

@@ -12,6 +12,7 @@ import {
import { useQuery } from 'react-query';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useGetMyOrganization } from 'api/generated/services/orgs';
import { useGetMyUser } from 'api/generated/services/users';
import listOrgPreferences from 'api/v1/org/preferences/list';
import listUserPreferences from 'api/v1/user/preferences/list';
@@ -84,6 +85,14 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
query: { enabled: isLoggedIn },
});
const {
data: orgData,
isFetching: isFetchingOrgData,
error: orgFetchDataError,
} = useGetMyOrganization({
query: { enabled: isLoggedIn },
});
const {
permissions: permissionsResult,
isFetching: isFetchingPermissions,
@@ -93,8 +102,10 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
enabled: isLoggedIn,
});
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
const userFetchError = userFetchDataError || errorOnPermissions;
const isFetchingUser =
isFetchingUserData || isFetchingOrgData || isFetchingPermissions;
const userFetchError =
userFetchDataError || orgFetchDataError || errorOnPermissions;
const userRole = useMemo(() => {
if (permissionsResult?.[IsAdminPermission]?.isGranted) {
@@ -134,40 +145,39 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
createdAt: toISOString(userData.data.createdAt) ?? prev.createdAt,
updatedAt: toISOString(userData.data.updatedAt) ?? prev.updatedAt,
}));
}
}, [userData, isFetchingUserData]);
// todo: we need to update the org name as well, we should have the [admin only role restriction on the get org api call] - BE input needed
setOrg((prev): any => {
useEffect(() => {
if (!isFetchingOrgData && orgData?.data) {
const { id: orgId, displayName: orgDisplayName } = orgData.data;
setOrg((prev) => {
if (!prev) {
return [
{
createdAt: 0,
id: userData.data.orgId,
},
];
return [{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' }];
}
const orgIndex = prev.findIndex((e) => e.id === userData.data.orgId);
const orgIndex = prev.findIndex((e) => e.id === orgId);
if (orgIndex === -1) {
return [
...prev,
{
createdAt: 0,
id: userData.data.orgId,
},
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
];
}
return [
const updatedOrg: Organization[] = [
...prev.slice(0, orgIndex),
{
createdAt: 0,
id: userData.data.orgId,
},
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
...prev.slice(orgIndex + 1),
];
return updatedOrg;
});
setDefaultUser((prev) => ({
...prev,
organization: orgDisplayName ?? prev.organization,
}));
}
}, [userData, isFetchingUserData]);
}, [orgData, isFetchingOrgData]);
// fetcher for licenses v3
const {

View File

@@ -14,7 +14,6 @@ import APIError from 'types/api/error';
interface ErrorModalContextType {
showErrorModal: (error: APIError) => void;
hideErrorModal: () => void;
isErrorModalVisible: boolean;
}
const ErrorModalContext = createContext<ErrorModalContextType | undefined>(
@@ -39,10 +38,10 @@ export function ErrorModalProvider({
setIsVisible(false);
}, []);
const value = useMemo(
() => ({ showErrorModal, hideErrorModal, isErrorModalVisible: isVisible }),
[showErrorModal, hideErrorModal, isVisible],
);
const value = useMemo(() => ({ showErrorModal, hideErrorModal }), [
showErrorModal,
hideErrorModal,
]);
return (
<ErrorModalContext.Provider value={value}>