diff --git a/web_ui/src/core/jobs/hooks/utils.test.tsx b/web_ui/src/core/jobs/hooks/utils.test.tsx index dcf1de73cb..e3c02e88e6 100644 --- a/web_ui/src/core/jobs/hooks/utils.test.tsx +++ b/web_ui/src/core/jobs/hooks/utils.test.tsx @@ -1,9 +1,12 @@ // Copyright (C) 2022-2025 Intel Corporation // LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE -import { QueryClient } from '@tanstack/react-query'; +import { ReactNode } from 'react'; + +import { QueryClientProvider } from '@tanstack/react-query'; import { waitFor } from '@testing-library/react'; +import { createGetiQueryClient } from '../../../providers/query-client-provider/query-client-provider.component'; import { getMockedWorkspaceIdentifier } from '../../../test-utils/mocked-items-factory/mocked-identifiers'; import { getMockedJob } from '../../../test-utils/mocked-items-factory/mocked-jobs'; import { renderHookWithProviders } from '../../../test-utils/render-hook-with-providers'; @@ -29,39 +32,50 @@ const getMockedResponse = (jobs: Job[]) => ({ }); const workspaceIdentifier = getMockedWorkspaceIdentifier({ workspaceId: 'workspaceId' }); +const mockSetInvalidateQueries = jest.fn(); + +const queryClient = createGetiQueryClient({ + addNotification: jest.fn(), +}); +queryClient.invalidateQueries = mockSetInvalidateQueries; + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); describe('Use jobs hook utils', () => { - beforeAll(() => { - jest.resetAllMocks(); + beforeEach(() => { + jest.clearAllMocks(); }); it('Should not invalidate balance if feature flag is disabled', async () => { - const queryClient = new QueryClient(); - queryClient.invalidateQueries = jest.fn(); renderHookWithProviders( - () => - useInvalidateBalanceOnNewJob( + () => { + return useInvalidateBalanceOnNewJob( workspaceIdentifier, getMockedResponse([getMockedJob({ cost: { leaseId: '123', requests: [], consumed: [] } })]), { jobState: JobState.SCHEDULED } - ), + ); + }, { - providerProps: { queryClient, featureFlags: { FEATURE_FLAG_CREDIT_SYSTEM: false } }, + wrapper, + providerProps: { featureFlags: { FEATURE_FLAG_CREDIT_SYSTEM: false } }, } ); await waitFor(() => { - expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); + expect(mockSetInvalidateQueries).not.toHaveBeenCalled(); }); }); it('Should invalidate balance if feature flag is enabled', async () => { - const queryClient = new QueryClient(); - queryClient.invalidateQueries = jest.fn(); const { rerender } = renderHookWithProviders( - ({ jobs }) => useInvalidateBalanceOnNewJob(workspaceIdentifier, getMockedResponse(jobs), {}), + ({ jobs }) => { + return useInvalidateBalanceOnNewJob(workspaceIdentifier, getMockedResponse(jobs), {}); + }, { - providerProps: { queryClient, featureFlags: { FEATURE_FLAG_CREDIT_SYSTEM: true } }, + wrapper, + providerProps: { featureFlags: { FEATURE_FLAG_CREDIT_SYSTEM: true } }, initialProps: { jobs: [ getMockedJob({ @@ -87,54 +101,54 @@ describe('Use jobs hook utils', () => { }); await waitFor(() => { - expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2); + expect(mockSetInvalidateQueries).toHaveBeenCalledTimes(2); }); }); it('Should not invalidate balance if there are no jobs', async () => { - const queryClient = new QueryClient(); - queryClient.invalidateQueries = jest.fn(); - renderHookWithProviders( () => useInvalidateBalanceOnNewJob(workspaceIdentifier, getMockedResponse([]), { jobState: JobState.SCHEDULED, }), - { providerProps: { queryClient, featureFlags: { FEATURE_FLAG_CREDIT_SYSTEM: true } } } + { + wrapper, + providerProps: { featureFlags: { FEATURE_FLAG_CREDIT_SYSTEM: true } }, + } ); await waitFor(() => { - expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); + expect(mockSetInvalidateQueries).not.toHaveBeenCalled(); }); }); it('Should not invalidate balance if there are no jobs with cost', async () => { - const queryClient = new QueryClient(); - queryClient.invalidateQueries = jest.fn(); renderHookWithProviders( () => useInvalidateBalanceOnNewJob(workspaceIdentifier, getMockedResponse([getMockedJob()]), { jobState: JobState.SCHEDULED, }), - { providerProps: { queryClient, featureFlags: { FEATURE_FLAG_CREDIT_SYSTEM: true } } } + { + wrapper, + providerProps: { featureFlags: { FEATURE_FLAG_CREDIT_SYSTEM: true } }, + } ); await waitFor(() => { - expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); + expect(mockSetInvalidateQueries).not.toHaveBeenCalled(); }); }); it('Should invalidate balance if there is a job with new id or a new job', async () => { - const queryClient = new QueryClient(); - queryClient.invalidateQueries = jest.fn(); const { rerender } = renderHookWithProviders( - ({ jobs }) => - useInvalidateBalanceOnNewJob(workspaceIdentifier, getMockedResponse(jobs), { + ({ jobs }) => { + return useInvalidateBalanceOnNewJob(workspaceIdentifier, getMockedResponse(jobs), { jobState: JobState.SCHEDULED, - }), + }); + }, { + wrapper, providerProps: { - queryClient, featureFlags: { FEATURE_FLAG_CREDIT_SYSTEM: true }, }, initialProps: { @@ -149,7 +163,7 @@ describe('Use jobs hook utils', () => { ); await waitFor(() => { - expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1); + expect(mockSetInvalidateQueries).toHaveBeenCalledTimes(1); }); rerender({ @@ -163,7 +177,7 @@ describe('Use jobs hook utils', () => { }); await waitFor(() => { - expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2); + expect(mockSetInvalidateQueries).toHaveBeenCalledTimes(2); }); rerender({ @@ -178,7 +192,7 @@ describe('Use jobs hook utils', () => { }); await waitFor(() => { - expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(3); + expect(mockSetInvalidateQueries).toHaveBeenCalledTimes(3); }); }); }); diff --git a/web_ui/src/notification/notification.component.tsx b/web_ui/src/notification/notification.component.tsx index 4e9025c45a..5dfcc13696 100644 --- a/web_ui/src/notification/notification.component.tsx +++ b/web_ui/src/notification/notification.component.tsx @@ -24,7 +24,7 @@ type addToastNotificationProps = Omit & { placement?: NOTIFICATION_CONTAINER; }; -interface addNotificationProps { +export interface AddNotificationProps { message: ReactChild; type: NOTIFICATION_TYPE; dismiss?: DismissOptions; @@ -35,7 +35,7 @@ interface addNotificationProps { interface NotificationContextProps { removeNotification: (id: string) => void; removeNotifications: () => void; - addNotification: ({ message, type, dismiss, actionButtons }: addNotificationProps) => string; + addNotification: ({ message, type, dismiss, actionButtons }: AddNotificationProps) => string; addToastNotification: (data: addToastNotificationProps) => string; } @@ -70,7 +70,7 @@ export const NotificationProvider = ({ children }: NotificationProviderProps): J hasCloseButton = true, dismiss = DEFAULT_DISMISS_OPTIONS, actionButtons, - }: addNotificationProps): string => { + }: AddNotificationProps): string => { const notificationId = isString(message) || isNumber(message) ? `id-${message}` : `id-${message.key}`; const NotificationContainer: iNotification = { diff --git a/web_ui/src/pages/media/hooks/media-delete/media-delete.hook.test.tsx b/web_ui/src/pages/media/hooks/media-delete/media-delete.hook.test.tsx index 69f30774c0..cbdba997ab 100644 --- a/web_ui/src/pages/media/hooks/media-delete/media-delete.hook.test.tsx +++ b/web_ui/src/pages/media/hooks/media-delete/media-delete.hook.test.tsx @@ -1,13 +1,13 @@ // Copyright (C) 2022-2025 Intel Corporation // LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE -import { QueryClient } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { waitFor } from '@testing-library/react'; import { MEDIA_TYPE } from '../../../../core/media/base-media.interface'; import { createInMemoryMediaService } from '../../../../core/media/services/in-memory-media-service/in-memory-media-service'; import { MediaService } from '../../../../core/media/services/media-service.interface'; -import { NOTIFICATION_TYPE } from '../../../../notification/notification-toast/notification-type.enum'; +import { createGetiQueryClient } from '../../../../providers/query-client-provider/query-client-provider.component'; import { getMockedProjectIdentifier } from '../../../../test-utils/mocked-items-factory/mocked-identifiers'; import { getMockedImageMediaItem } from '../../../../test-utils/mocked-items-factory/mocked-media'; import { renderHookWithProviders } from '../../../../test-utils/render-hook-with-providers'; @@ -16,18 +16,12 @@ import { filterPageMedias } from '../../utils'; import { useDeleteMediaMutation } from './media-delete.hook'; const mockSetQueriesData = jest.fn(); -const mockAddNotification = jest.fn(); jest.mock('../../utils', () => ({ ...jest.requireActual('../../utils'), filterPageMedias: jest.fn(), })); -jest.mock('../../../../notification/notification.component', () => ({ - ...jest.requireActual('../../../../notification/notification.component'), - useNotification: () => ({ addNotification: mockAddNotification }), -})); - const mockedImageMedia = getMockedImageMediaItem({ name: 'image-1', identifier: { type: MEDIA_TYPE.IMAGE, imageId: '1111' }, @@ -42,14 +36,18 @@ const renderDeleteMediaMutationHook = ({ }: { mediaService?: MediaService; } = {}) => { - const queryClient = new QueryClient(); + const queryClient = createGetiQueryClient({ + addNotification: jest.fn(), + }); queryClient.setQueriesData = mockSetQueriesData; return renderHookWithProviders(useDeleteMediaMutation, { wrapper: ({ children }) => ( - {children} + + {children} + ), - providerProps: { mediaService, queryClient }, + providerProps: { mediaService }, }); }; @@ -85,7 +83,6 @@ describe('useDeleteMediaMutation', () => { await waitFor(() => { expect(filterPageMedias).toHaveBeenCalled(); expect(mockSetQueriesData).toHaveBeenCalledTimes(1); - expect(mockAddNotification).not.toHaveBeenCalled(); }); }); @@ -106,10 +103,6 @@ describe('useDeleteMediaMutation', () => { await waitFor(() => { expect(filterPageMedias).toHaveBeenCalled(); expect(mockSetQueriesData).toHaveBeenCalledTimes(2); - expect(mockAddNotification).toHaveBeenCalledWith({ - message: `Media cannot be deleted. ${errorMessage}`, - type: NOTIFICATION_TYPE.ERROR, - }); }); }); }); diff --git a/web_ui/src/providers/query-client-provider/query-client-provider.component.tsx b/web_ui/src/providers/query-client-provider/query-client-provider.component.tsx index 1383fa6def..591a63c474 100644 --- a/web_ui/src/providers/query-client-provider/query-client-provider.component.tsx +++ b/web_ui/src/providers/query-client-provider/query-client-provider.component.tsx @@ -6,6 +6,7 @@ import { ReactNode, useLayoutEffect, useMemo, useRef } from 'react'; import { getErrorMessage } from '@geti/core/src/services/utils'; import { DefaultOptions, + MutationCache, QueryCache, QueryClient, QueryClientProvider as TanstackQueryClientProvider, @@ -14,7 +15,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { isAxiosError } from 'axios'; import { NOTIFICATION_TYPE } from '../../notification/notification-toast/notification-type.enum'; -import { useNotification } from '../../notification/notification.component'; +import { AddNotificationProps, useNotification } from '../../notification/notification.component'; declare module '@tanstack/react-query' { interface Register { @@ -25,10 +26,67 @@ declare module '@tanstack/react-query' { }; mutationMeta: { notifyOnError?: boolean; + errorMessage?: string; }; } } +export const createGetiQueryClient = ({ + defaultQueryOptions, + addNotification, +}: { + defaultQueryOptions?: DefaultOptions; + addNotification: (props: AddNotificationProps) => string; +}): QueryClient => { + const notify = { current: addNotification }; + + const queryCache = new QueryCache({ + onError: (error, query) => { + if (isAxiosError(error) && query.meta && 'notifyOnError' in query.meta) { + const message = query.meta.errorMessage; + + if (query.meta.notifyOnError === true) { + notify.current({ + message: typeof message === 'string' ? message : getErrorMessage(error), + type: NOTIFICATION_TYPE.ERROR, + }); + } + } + + if (query.meta && 'disableGlobalErrorHandling' in query.meta) { + if (query.meta.disableGlobalErrorHandling === true) { + return; + } + } + }, + }); + + const mutationCache = new MutationCache({ + onError: (error, _variables, _ctx, mutation) => { + if (isAxiosError(error) && mutation.meta && 'notifyOnError' in mutation.meta) { + const message = mutation.meta.errorMessage; + + notify.current({ + message: typeof message === 'string' ? message : getErrorMessage(error), + type: NOTIFICATION_TYPE.ERROR, + }); + } + }, + }); + + return new QueryClient({ + defaultOptions: { + queries: { + refetchIntervalInBackground: false, + refetchOnWindowFocus: false, + }, + ...defaultQueryOptions, + }, + queryCache, + mutationCache, + }); +}; + export const QueryClientProvider = ({ children, defaultQueryOptions, @@ -46,36 +104,7 @@ export const QueryClientProvider = ({ }, [addNotification]); const queryClient = useMemo(() => { - const queryCache = new QueryCache({ - onError: (error, query) => { - if (isAxiosError(error) && query.meta && 'notifyOnError' in query.meta) { - const message = query.meta.errorMessage; - if (query.meta.notifyOnError === true) { - notify.current({ - message: typeof message === 'string' ? message : getErrorMessage(error), - type: NOTIFICATION_TYPE.ERROR, - }); - } - } - - if (query.meta && 'disableGlobalErrorHandling' in query.meta) { - if (query.meta.disableGlobalErrorHandling === true) { - return; - } - } - }, - }); - - return new QueryClient({ - defaultOptions: { - queries: { - refetchIntervalInBackground: false, - refetchOnWindowFocus: false, - }, - ...defaultQueryOptions, - }, - queryCache, - }); + return createGetiQueryClient({ addNotification: notify.current, defaultQueryOptions }); }, [defaultQueryOptions]); return ( diff --git a/web_ui/src/routes/organizations/organizations-context.test.tsx b/web_ui/src/routes/organizations/organizations-context.test.tsx index ec316f247e..9b66fc87c5 100644 --- a/web_ui/src/routes/organizations/organizations-context.test.tsx +++ b/web_ui/src/routes/organizations/organizations-context.test.tsx @@ -7,7 +7,7 @@ import { OnboardingService, OrganizationMetadata, } from '@geti/core/src/users/services/onboarding-service.interface'; -import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { AxiosError, HttpStatusCode } from 'axios'; import { AccountStatus } from '../../core/organizations/organizations.interface'; @@ -51,10 +51,9 @@ const suspendedOrganization = { const renderApp = async ({ profile = null, - onboardingService, + onboardingService = createInMemoryOnboardingService(), }: { profile?: OnboardingProfile | null; - isError?: boolean; onboardingService?: OnboardingService; organizations?: OrganizationMetadata[]; selectedOrganization?: OrganizationMetadata | null; @@ -67,22 +66,15 @@ const renderApp = async ({ , { profile, services: { onboardingService } } ); - - await waitForElementToBeRemoved(screen.getByRole('progressbar')); }; describe('Organizations context', () => { it('Displays if the organization has been suspended or deleted.', async () => { - const onboardingService = createInMemoryOnboardingService(); - onboardingService.getActiveUserProfile = jest.fn(() => - Promise.resolve({ + await renderApp({ + profile: { organizations: [suspendedOrganization], hasAcceptedUserTermsAndConditions: true, - }) - ); - - await renderApp({ - onboardingService, + }, }); expect(screen.getByText(/Your organization's account has been suspended/)).toBeVisible(); @@ -104,22 +96,21 @@ describe('Organizations context', () => { await renderApp({ onboardingService, + profile: null, }); - expect(screen.getByText(/You do not have access to any Intel Geti organization/)).toBeVisible(); + await waitFor(() => { + expect(screen.getByText(/You do not have access to any Intel Geti organization/)).toBeVisible(); + }); }); describe('multiple organizations', () => { it('Displays the "OrganizationSelectionModal" when no organization has been selected', async () => { - const onboardingService = createInMemoryOnboardingService(); - onboardingService.getActiveUserProfile = jest.fn(() => - Promise.resolve({ + await renderApp({ + profile: { organizations: [mockedOrganization, mockedOrganizationTwo], hasAcceptedUserTermsAndConditions: true, - }) - ); - await renderApp({ - onboardingService, + }, }); expect(screen.getByText(/^You belong to the following organizations./i)).toBeVisible(); @@ -131,16 +122,11 @@ describe('Organizations context', () => { }); it('Displays if the organization has been suspended.', async () => { - const onboardingService = createInMemoryOnboardingService(); - onboardingService.getActiveUserProfile = jest.fn(() => - Promise.resolve({ + await renderApp({ + profile: { organizations: [suspendedOrganization, mockedOrganization], hasAcceptedUserTermsAndConditions: true, - }) - ); - - await renderApp({ - onboardingService, + }, }); expect( diff --git a/web_ui/src/test-utils/required-providers-render.tsx b/web_ui/src/test-utils/required-providers-render.tsx index 3dbac545fc..6d30b391c7 100644 --- a/web_ui/src/test-utils/required-providers-render.tsx +++ b/web_ui/src/test-utils/required-providers-render.tsx @@ -1,7 +1,7 @@ // Copyright (C) 2022-2025 Intel Corporation // LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE -import { ReactElement, ReactNode, Suspense } from 'react'; +import { ReactElement, ReactNode, Suspense, useMemo } from 'react'; import { CustomFeatureFlags, DEV_FEATURE_FLAGS } from '@geti/core'; import QUERY_KEYS from '@geti/core/src/requests/query-keys'; @@ -11,13 +11,14 @@ import { } from '@geti/core/src/services/application-services-provider.component'; import { OnboardingProfile } from '@geti/core/src/users/services/onboarding-service.interface'; import { defaultTheme, IntelBrandedLoading, Provider as ThemeProvider } from '@geti/ui'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { render, RenderOptions, RenderResult } from '@testing-library/react'; import { AuthProvider } from 'react-oidc-context'; import { MemoryRouter as Router } from 'react-router-dom'; import { AccountStatusDTO } from '../core/organizations/dtos/organizations.interface'; -import { NotificationProvider, Notifications } from '../notification/notification.component'; +import { NotificationProvider, Notifications, useNotification } from '../notification/notification.component'; +import { createGetiQueryClient } from '../providers/query-client-provider/query-client-provider.component'; import { TusUploadProvider } from '../providers/tus-upload-provider/tus-upload-provider.component'; import { getMockedWorkspace } from './mocked-items-factory/mocked-workspace'; @@ -26,43 +27,57 @@ interface RequiredProvidersProps extends Partial { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - gcTime: 1000, +const PrefilledQueryClientProvider = ({ + children, + featureFlags, + profile, +}: { + children: ReactNode; + featureFlags?: CustomFeatureFlags; + profile?: OnboardingProfile | null; +}) => { + const { addNotification } = useNotification(); + + const prefilledQueryClient = useMemo(() => { + const client = createGetiQueryClient({ + addNotification, + defaultQueryOptions: { + queries: { + gcTime: 1000, + }, }, - }, - }); - - queryClient.setQueryData(QUERY_KEYS.FEATURE_FLAGS, { - ...DEV_FEATURE_FLAGS, - ...featureFlags, - }); - - if (profile !== null) { - queryClient.setQueryData(QUERY_KEYS.USER_ONBOARDING_PROFILE, { - organizations: [{ id: prefilledOrgId, status: AccountStatusDTO.ACTIVE }], - hasAcceptedUserTermsAndConditions: true, - ...profile, }); - } - ['123', 'org-id', 'organization-id', prefilledOrgId].forEach((organizationId) => { - const workspaceKey = QUERY_KEYS.WORKSPACES(organizationId); + client.setQueryData(QUERY_KEYS.FEATURE_FLAGS, { + ...DEV_FEATURE_FLAGS, + ...featureFlags, + }); + + if (profile !== null) { + client.setQueryData(QUERY_KEYS.USER_ONBOARDING_PROFILE, { + organizations: [{ id: prefilledOrgId, status: AccountStatusDTO.ACTIVE }], + hasAcceptedUserTermsAndConditions: true, + ...profile, + }); + } + + ['123', 'org-id', 'organization-id', prefilledOrgId].forEach((organizationId) => { + const workspaceKey = QUERY_KEYS.WORKSPACES(organizationId); + + client.setQueryData(workspaceKey, [ + getMockedWorkspace({ id: 'workspace-1', name: 'Workspace 1' }), + getMockedWorkspace({ id: 'workspace-2', name: 'Workspace 2' }), + ]); + }); - queryClient.setQueryData(workspaceKey, [ - getMockedWorkspace({ id: 'workspace-1', name: 'Workspace 1' }), - getMockedWorkspace({ id: 'workspace-2', name: 'Workspace 2' }), - ]); - }); + return client; + }, [addNotification, featureFlags, profile]); - return queryClient; + return {children}; }; export const RequiredProviders = ({ @@ -70,26 +85,25 @@ export const RequiredProviders = ({ featureFlags, initialEntries, profile, - queryClient, ...services }: RequiredProvidersProps): JSX.Element => { - const prefilledQueryClient = usePrefilledQueryClient(featureFlags, profile); - return ( }> - - - - - + + + + + }> - {children} + + {children} + - - - - + + + + );