From 149cf401dc23352d86410a32fcebffa5f088e6d5 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:07:28 +0530 Subject: [PATCH 01/22] Add tests for useGetRecommendedUpdateLevels hook Introduce a new Vitest test suite for the useGetRecommendedUpdateLevels hook. The tests mock logger, Asgardeo auth, mock config provider and API delay, and exercise cases for initial loading, mock-data flow, query options (staleTime), real API success and error (by stubbing fetch and window.config), and the empty projectId no-fetch behavior. Ensures proper logging calls and token retrieval are asserted. --- .../useGetRecommendedUpdateLevels.test.tsx | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 apps/customer-portal/webapp/src/api/__tests__/useGetRecommendedUpdateLevels.test.tsx diff --git a/apps/customer-portal/webapp/src/api/__tests__/useGetRecommendedUpdateLevels.test.tsx b/apps/customer-portal/webapp/src/api/__tests__/useGetRecommendedUpdateLevels.test.tsx new file mode 100644 index 0000000000..bc96e8b583 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/__tests__/useGetRecommendedUpdateLevels.test.tsx @@ -0,0 +1,228 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useGetRecommendedUpdateLevels } from "@api/useGetRecommendedUpdateLevels"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const mockLogger = { + debug: vi.fn(), + error: vi.fn(), +}; +vi.mock("@hooks/useLogger", () => ({ + useLogger: () => mockLogger, +})); + +vi.mock("@constants/apiConstants", async (importOriginal) => { + const actual = (await importOriginal()) as { + ApiQueryKeys: Record; + }; + return { + ...actual, + API_MOCK_DELAY: 0, + }; +}); + +const mockGetIdToken = vi.fn().mockResolvedValue("mock-token"); +vi.mock("@asgardeo/react", () => ({ + useAsgardeo: () => ({ + getIdToken: mockGetIdToken, + isSignedIn: true, + isLoading: false, + }), +})); + +let mockIsMockEnabled = true; +vi.mock("@providers/MockConfigProvider", () => ({ + useMockConfig: () => ({ + isMockEnabled: mockIsMockEnabled, + }), +})); + +describe("useGetRecommendedUpdateLevels", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + mockLogger.debug.mockClear(); + mockLogger.error.mockClear(); + mockIsMockEnabled = true; + vi.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it("should return loading state initially", async () => { + const { result } = renderHook( + () => useGetRecommendedUpdateLevels("project-1"), + { + wrapper, + }, + ); + + expect(result.current.isLoading).toBe(true); + }); + + it("should return mock data when isMockEnabled is true", async () => { + mockIsMockEnabled = true; + const { result } = renderHook( + () => useGetRecommendedUpdateLevels("project-1"), + { + wrapper, + }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBeDefined(); + expect(result.current.data).toBeInstanceOf(Array); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining( + "Fetching recommended update levels for project ID: project-1, mock: true", + ), + ); + }); + + it("should have correct query options", () => { + renderHook(() => useGetRecommendedUpdateLevels("project-1"), { + wrapper, + }); + + const query = queryClient.getQueryCache().findAll({ + queryKey: ["recommended-update-levels", "project-1", true], + })[0]; + + expect((query?.options as { staleTime?: number }).staleTime).toBe( + 5 * 60 * 1000, + ); + }); + + it("should fetch from API when isMockEnabled is false", async () => { + mockIsMockEnabled = false; + const mockResponse = [ + { + productName: "wso2am-analytics", + productBaseVersion: "2.6.0", + channel: "full", + startingUpdateLevel: 0, + endingUpdateLevel: 33, + installedUpdatesCount: 44, + installedSecurityUpdatesCount: 23, + timestamp: 1684415113845, + recommendedUpdateLevel: 33, + availableUpdatesCount: 0, + availableSecurityUpdatesCount: 0, + }, + ]; + + const originalWindowConfig = ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config; + ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config = { + CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", + }; + + try { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + status: 200, + } as Response), + ); + + const { result } = renderHook( + () => useGetRecommendedUpdateLevels("project-1"), + { + wrapper, + }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(mockResponse); + expect(mockGetIdToken).toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining( + "Fetching recommended update levels for project ID: project-1, mock: false", + ), + ); + } finally { + (window as { config?: unknown }).config = originalWindowConfig; + } + }); + + it("should handle API error when isMockEnabled is false", async () => { + mockIsMockEnabled = false; + + const originalWindowConfig = ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config; + ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config = { + CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", + }; + + try { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + statusText: "Internal Server Error", + status: 500, + } as Response), + ); + + const { result } = renderHook( + () => useGetRecommendedUpdateLevels("project-1"), + { + wrapper, + }, + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toContain( + "Error fetching recommended update levels: Internal Server Error", + ); + expect(mockLogger.error).toHaveBeenCalled(); + } finally { + (window as { config?: unknown }).config = originalWindowConfig; + } + }); + + it("should not fetch if projectId is empty", () => { + const { result } = renderHook(() => useGetRecommendedUpdateLevels(""), { + wrapper, + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.fetchStatus).toBe("idle"); + }); +}); From 489aae15e9efc1d141944a73a2f4e87bda3c89cd Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:07:44 +0530 Subject: [PATCH 02/22] Remove useGetUpdatesStats hook tests Delete apps/customer-portal/webapp/src/api/__tests__/useGetUpdatesStats.test.tsx. The removed file contained unit tests for the useGetUpdatesStats hook, including mocks for Asgardeo, fetch, and mock config, and covered mock vs API responses, error handling, query options, and empty projectId behavior. This change cleans up or relocates the obsolete/removed tests. --- .../api/__tests__/useGetUpdatesStats.test.tsx | 212 ------------------ 1 file changed, 212 deletions(-) delete mode 100644 apps/customer-portal/webapp/src/api/__tests__/useGetUpdatesStats.test.tsx diff --git a/apps/customer-portal/webapp/src/api/__tests__/useGetUpdatesStats.test.tsx b/apps/customer-portal/webapp/src/api/__tests__/useGetUpdatesStats.test.tsx deleted file mode 100644 index ce4de97666..0000000000 --- a/apps/customer-portal/webapp/src/api/__tests__/useGetUpdatesStats.test.tsx +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import { renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useGetUpdatesStats } from "@api/useGetUpdatesStats"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; - -const mockLogger = { - debug: vi.fn(), - error: vi.fn(), -}; -vi.mock("@hooks/useLogger", () => ({ - useLogger: () => mockLogger, -})); - -vi.mock("@constants/apiConstants", async (importOriginal) => { - const actual = (await importOriginal()) as { - ApiQueryKeys: Record; - }; - return { - ...actual, - API_MOCK_DELAY: 0, - }; -}); - -const mockGetIdToken = vi.fn().mockResolvedValue("mock-token"); -vi.mock("@asgardeo/react", () => ({ - useAsgardeo: () => ({ - getIdToken: mockGetIdToken, - isSignedIn: true, - isLoading: false, - }), -})); - -let mockIsMockEnabled = true; -vi.mock("@providers/MockConfigProvider", () => ({ - useMockConfig: () => ({ - isMockEnabled: mockIsMockEnabled, - }), -})); - -describe("useGetUpdatesStats", () => { - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - mockLogger.debug.mockClear(); - mockLogger.error.mockClear(); - mockIsMockEnabled = true; - vi.clearAllMocks(); - }); - - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - - it("should return loading state initially", async () => { - const { result } = renderHook(() => useGetUpdatesStats("project-1"), { - wrapper, - }); - - expect(result.current.isLoading).toBe(true); - }); - - it("should return mock data when isMockEnabled is true", async () => { - mockIsMockEnabled = true; - const { result } = renderHook(() => useGetUpdatesStats("project-1"), { - wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data).toBeDefined(); - expect(result.current.data?.productsTracked).toBe(4); - expect(result.current.data?.totalUpdatesInstalled).toBe(70); - expect(result.current.data?.totalUpdatesPending).toBe(69); - expect(result.current.data?.securityUpdatesPending).toBe(32); - expect(mockLogger.debug).toHaveBeenCalledWith( - expect.stringContaining( - "Fetching updates stats for project ID: project-1, mock: true", - ), - ); - }); - - it("should have correct query options", () => { - renderHook(() => useGetUpdatesStats("project-1"), { - wrapper, - }); - - const query = queryClient.getQueryCache().findAll({ - queryKey: ["updates-stats", "project-1", true], - })[0]; - - expect((query?.options as { staleTime?: number }).staleTime).toBe( - 5 * 60 * 1000, - ); - }); - - it("should fetch from API when isMockEnabled is false", async () => { - mockIsMockEnabled = false; - const mockResponse = { - productsTracked: 5, - totalUpdatesInstalled: 80, - totalUpdatesInstalledBreakdown: { regular: 60, security: 20 }, - totalUpdatesPending: 55, - totalUpdatesPendingBreakdown: { regular: 30, security: 25 }, - securityUpdatesPending: 25, - }; - - const originalWindowConfig = ( - window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } - ).config; - ( - window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } - ).config = { - CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", - }; - - try { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - status: 200, - } as Response), - ); - - const { result } = renderHook(() => useGetUpdatesStats("project-1"), { - wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data).toEqual(mockResponse); - expect(mockGetIdToken).toHaveBeenCalled(); - expect(mockLogger.debug).toHaveBeenCalledWith( - expect.stringContaining( - "Fetching updates stats for project ID: project-1, mock: false", - ), - ); - } finally { - (window as { config?: unknown }).config = originalWindowConfig; - } - }); - - it("should handle API error when isMockEnabled is false", async () => { - mockIsMockEnabled = false; - - const originalWindowConfig = ( - window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } - ).config; - ( - window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } - ).config = { - CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", - }; - - try { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: false, - statusText: "Internal Server Error", - status: 500, - } as Response), - ); - - const { result } = renderHook(() => useGetUpdatesStats("project-1"), { - wrapper, - }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error?.message).toContain( - "Error fetching updates stats: Internal Server Error", - ); - expect(mockLogger.error).toHaveBeenCalled(); - } finally { - (window as { config?: unknown }).config = originalWindowConfig; - } - }); - - it("should not fetch if projectId is empty", () => { - const { result } = renderHook(() => useGetUpdatesStats(""), { - wrapper, - }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.fetchStatus).toBe("idle"); - }); -}); From 0b345a59ba515d0548d300512fdd0a5cd3dfb328 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:08:02 +0530 Subject: [PATCH 03/22] Remove useGetProductUpdatesStats hook Delete apps/customer-portal/webapp/src/api/useGetProductUpdatesStats.ts which implemented a React Query hook for fetching product updates statistics (handled mock mode and authenticated API calls). Remove this unused/obsolete hook and ensure any references or imports are updated accordingly. --- .../src/api/useGetProductUpdatesStats.ts | 92 ------------------- 1 file changed, 92 deletions(-) delete mode 100644 apps/customer-portal/webapp/src/api/useGetProductUpdatesStats.ts diff --git a/apps/customer-portal/webapp/src/api/useGetProductUpdatesStats.ts b/apps/customer-portal/webapp/src/api/useGetProductUpdatesStats.ts deleted file mode 100644 index 636a1c3fbb..0000000000 --- a/apps/customer-portal/webapp/src/api/useGetProductUpdatesStats.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import { useQuery, type UseQueryResult } from "@tanstack/react-query"; -import { useAsgardeo } from "@asgardeo/react"; -import { getMockUpdatesStats } from "@models/mockFunctions"; -import { useMockConfig } from "@providers/MockConfigProvider"; -import { useLogger } from "@hooks/useLogger"; -import { ApiQueryKeys, API_MOCK_DELAY } from "@constants/apiConstants"; -import { useAuthApiClient } from "@context/AuthApiContext"; -import type { UpdatesStats } from "@models/responses"; - -/** - * Custom hook to fetch product updates statistics. - * - * @param {string} projectId - The ID of the project. - * @returns {UseQueryResult} The query result object. - */ -export function useGetProductUpdatesStats( - projectId: string, -): UseQueryResult { - const logger = useLogger(); - const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); - const fetchFn = useAuthApiClient(); - const { isMockEnabled } = useMockConfig(); - - return useQuery({ - queryKey: [ApiQueryKeys.UPDATES_STATS, projectId, isMockEnabled], - queryFn: async (): Promise => { - logger.debug( - `Fetching product updates stats for project ID: ${projectId}, mock: ${isMockEnabled}`, - ); - - if (isMockEnabled) { - await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY)); - - const stats: UpdatesStats = getMockUpdatesStats(); - - logger.debug( - `Product updates stats fetched successfully for project ID: ${projectId} (mock)`, - stats, - ); - - return stats; - } - - try { - const baseUrl = window.config?.CUSTOMER_PORTAL_BACKEND_BASE_URL; - - if (!baseUrl) { - throw new Error("CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured"); - } - - const requestUrl = `${baseUrl}/updates/stats`; - - const response = await fetchFn(requestUrl, { method: "GET" }); - - logger.debug( - `[useGetProductUpdatesStats] Response status: ${response.status}`, - ); - - if (!response.ok) { - throw new Error( - `Error fetching product updates stats: ${response.statusText}`, - ); - } - - const data: UpdatesStats = await response.json(); - logger.debug("[useGetProductUpdatesStats] Data received:", data); - return data; - } catch (error) { - logger.error("[useGetProductUpdatesStats] Error:", error); - throw error; - } - }, - enabled: !!projectId && (isMockEnabled || (isSignedIn && !isAuthLoading)), - staleTime: 5 * 60 * 1000, - }); -} From 8c15c65488947fc885e1216ad3f7756aee09d6cb Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:08:36 +0530 Subject: [PATCH 04/22] Add useGetRecommendedUpdateLevels hook Introduce a new React Query hook to fetch recommended update levels for a project. The hook (apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts) supports mock mode with configurable delay, uses the authenticated API client from AuthApiContext, and logs progress via useLogger. It validates CUSTOMER_PORTAL_BACKEND_BASE_URL, handles HTTP errors, and enables the query only when a projectId is present and authentication (or mock) conditions are met. Query results are cached with a 5-minute staleTime. --- .../src/api/useGetRecommendedUpdateLevels.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts diff --git a/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts b/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts new file mode 100644 index 0000000000..5f49784ac6 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { useAsgardeo } from "@asgardeo/react"; +import { getMockRecommendedUpdateLevels } from "@models/mockFunctions"; +import { useMockConfig } from "@providers/MockConfigProvider"; +import { useLogger } from "@hooks/useLogger"; +import { ApiQueryKeys, API_MOCK_DELAY } from "@constants/apiConstants"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import type { RecommendedUpdateLevelItem } from "@models/responses"; + +/** + * Custom hook to fetch recommended update levels. + * + * @param {string} projectId - The ID of the project. + * @returns {UseQueryResult} The query result object. + */ +export function useGetRecommendedUpdateLevels( + projectId: string, +): UseQueryResult { + const logger = useLogger(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + const { isMockEnabled } = useMockConfig(); + + return useQuery({ + queryKey: [ + ApiQueryKeys.RECOMMENDED_UPDATE_LEVELS, + projectId, + isMockEnabled, + ], + queryFn: async (): Promise => { + logger.debug( + `Fetching recommended update levels for project ID: ${projectId}, mock: ${isMockEnabled}`, + ); + + if (isMockEnabled) { + await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY)); + + const data: RecommendedUpdateLevelItem[] = + getMockRecommendedUpdateLevels(); + + logger.debug( + `Recommended update levels fetched successfully for project ID: ${projectId} (mock)`, + data, + ); + + return data; + } + + try { + const baseUrl = window.config?.CUSTOMER_PORTAL_BACKEND_BASE_URL; + + if (!baseUrl) { + throw new Error("CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured"); + } + + const requestUrl = `${baseUrl}/updates/recommended-update-levels`; + + const response = await fetchFn(requestUrl, { method: "GET" }); + + logger.debug( + `[useGetRecommendedUpdateLevels] Response status: ${response.status}`, + ); + + if (!response.ok) { + throw new Error( + `Error fetching updates stats: ${response.statusText}`, + ); + } + + const data: RecommendedUpdateLevelItem[] = await response.json(); + logger.debug("[useGetRecommendedUpdateLevels] Data received:", data); + return data; + } catch (error) { + logger.error("[useGetRecommendedUpdateLevels] Error:", error); + throw error; + } + }, + enabled: !!projectId && (isMockEnabled || (isSignedIn && !isAuthLoading)), + staleTime: 5 * 60 * 1000, + }); +} From d672cbdcb97021199f318958396c220e1a487520 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:08:48 +0530 Subject: [PATCH 05/22] Remove useGetUpdatesStats hook Delete the custom hook at apps/customer-portal/webapp/src/api/useGetUpdatesStats.ts. The hook previously fetched updates statistics using react-query, handled auth (useAsgardeo/useAuthApiClient), mock responses, and logging. Remove any imports/usages of this hook elsewhere in the codebase. --- .../webapp/src/api/useGetUpdatesStats.ts | 92 ------------------- 1 file changed, 92 deletions(-) delete mode 100644 apps/customer-portal/webapp/src/api/useGetUpdatesStats.ts diff --git a/apps/customer-portal/webapp/src/api/useGetUpdatesStats.ts b/apps/customer-portal/webapp/src/api/useGetUpdatesStats.ts deleted file mode 100644 index c0b0c49085..0000000000 --- a/apps/customer-portal/webapp/src/api/useGetUpdatesStats.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import { useQuery, type UseQueryResult } from "@tanstack/react-query"; -import { useAsgardeo } from "@asgardeo/react"; -import { getMockUpdatesStats } from "@models/mockFunctions"; -import { useMockConfig } from "@providers/MockConfigProvider"; -import { useLogger } from "@hooks/useLogger"; -import { ApiQueryKeys, API_MOCK_DELAY } from "@constants/apiConstants"; -import { useAuthApiClient } from "@context/AuthApiContext"; -import type { UpdatesStats } from "@models/responses"; - -/** - * Custom hook to fetch updates statistics. - * - * @param {string} projectId - The ID of the project. - * @returns {UseQueryResult} The query result object. - */ -export function useGetUpdatesStats( - projectId: string, -): UseQueryResult { - const logger = useLogger(); - const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); - const fetchFn = useAuthApiClient(); - const { isMockEnabled } = useMockConfig(); - - return useQuery({ - queryKey: [ApiQueryKeys.UPDATES_STATS, projectId, isMockEnabled], - queryFn: async (): Promise => { - logger.debug( - `Fetching updates stats for project ID: ${projectId}, mock: ${isMockEnabled}`, - ); - - if (isMockEnabled) { - await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY)); - - const stats: UpdatesStats = getMockUpdatesStats(); - - logger.debug( - `Updates stats fetched successfully for project ID: ${projectId} (mock)`, - stats, - ); - - return stats; - } - - try { - const baseUrl = window.config?.CUSTOMER_PORTAL_BACKEND_BASE_URL; - - if (!baseUrl) { - throw new Error("CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured"); - } - - const requestUrl = `${baseUrl}/updates/stats`; - - const response = await fetchFn(requestUrl, { method: "GET" }); - - logger.debug( - `[useGetUpdatesStats] Response status: ${response.status}`, - ); - - if (!response.ok) { - throw new Error( - `Error fetching updates stats: ${response.statusText}`, - ); - } - - const data: UpdatesStats = await response.json(); - logger.debug("[useGetUpdatesStats] Data received:", data); - return data; - } catch (error) { - logger.error("[useGetUpdatesStats] Error:", error); - throw error; - } - }, - enabled: !!projectId && (isMockEnabled || (isSignedIn && !isAuthLoading)), - staleTime: 5 * 60 * 1000, - }); -} From 2b71e9d420c1acdb8168d13df95fe3303e6fa098 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:09:01 +0530 Subject: [PATCH 06/22] Add data-testid to ErrorIndicator button Add a data-testid attribute (error-indicator) to the IconButton in the ErrorIndicator component to make it easier to target the element in unit and integration tests. --- .../src/components/common/error-indicator/ErrorIndicator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/customer-portal/webapp/src/components/common/error-indicator/ErrorIndicator.tsx b/apps/customer-portal/webapp/src/components/common/error-indicator/ErrorIndicator.tsx index 3790a1d61b..76911807a5 100644 --- a/apps/customer-portal/webapp/src/components/common/error-indicator/ErrorIndicator.tsx +++ b/apps/customer-portal/webapp/src/components/common/error-indicator/ErrorIndicator.tsx @@ -37,7 +37,7 @@ export default function ErrorIndicator({ return ( - + From 2ca1b1a7f9a31423781307fda8032ea3b0e8b479 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:09:37 +0530 Subject: [PATCH 07/22] Remove UpdatesStatsGrid.test.tsx Delete the unit test file for the UpdatesStatsGrid component (apps/customer-portal/webapp/src/components/updates/__tests__/UpdatesStatsGrid.test.tsx). This removes tests that covered headings, stat labels/values, null placeholders, and loading state skeletons. Replace or update tests if this deletion was not intended. --- .../__tests__/UpdatesStatsGrid.test.tsx | 82 ------------------- 1 file changed, 82 deletions(-) delete mode 100644 apps/customer-portal/webapp/src/components/updates/__tests__/UpdatesStatsGrid.test.tsx diff --git a/apps/customer-portal/webapp/src/components/updates/__tests__/UpdatesStatsGrid.test.tsx b/apps/customer-portal/webapp/src/components/updates/__tests__/UpdatesStatsGrid.test.tsx deleted file mode 100644 index 3e4c971d9d..0000000000 --- a/apps/customer-portal/webapp/src/components/updates/__tests__/UpdatesStatsGrid.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { UpdatesStatsGrid } from "@components/updates/UpdatesStatsGrid"; - -const mockData = { - productsTracked: 4, - totalUpdatesInstalled: 70, - totalUpdatesInstalledBreakdown: { regular: 50, security: 20 }, - totalUpdatesPending: 69, - totalUpdatesPendingBreakdown: { regular: 37, security: 32 }, - securityUpdatesPending: 32, -}; - -describe("UpdatesStatsGrid", () => { - it("should render Overall Update Status heading", () => { - render( - , - ); - expect(screen.getByText("Overall Update Status")).toBeInTheDocument(); - }); - - it("should render all four stat labels when data is loaded", () => { - render( - , - ); - expect(screen.getByText("Products Tracked")).toBeInTheDocument(); - expect(screen.getByText("Total Updates Installed")).toBeInTheDocument(); - expect(screen.getByText("Total Updates Pending")).toBeInTheDocument(); - expect(screen.getByText("Security Updates Pending")).toBeInTheDocument(); - }); - - it("should display stat values when data is loaded", () => { - render( - , - ); - expect(screen.getByText("4")).toBeInTheDocument(); - expect(screen.getByText("70")).toBeInTheDocument(); - expect(screen.getByText("69")).toBeInTheDocument(); - expect(screen.getByText("32")).toBeInTheDocument(); - }); - - it("should display -- for null values", () => { - const dataWithNulls = { - ...mockData, - productsTracked: null, - totalUpdatesInstalled: null, - }; - render( - , - ); - const placeholders = screen.getAllByText("--"); - expect(placeholders.length).toBeGreaterThanOrEqual(2); - }); - - it("should not display stat values when loading (skeletons shown instead)", () => { - render( - , - ); - expect(screen.queryByText("4")).not.toBeInTheDocument(); - expect(screen.queryByText("70")).not.toBeInTheDocument(); - }); -}); From 7e8e461a8a3d47a53655ede87845d1e587d86a4c Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:09:53 +0530 Subject: [PATCH 08/22] Add tests for StatCard component Introduce unit tests for the StatCard component (apps/.../StatCard.test.tsx). Tests cover rendering of label and value, loading state (skeleton hides value), error state (renders error-indicator), and rendering of extraContent. Uses Vitest and React Testing Library and includes license header. --- .../stat-card-row/__tests__/StatCard.test.tsx | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/StatCard.test.tsx diff --git a/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/StatCard.test.tsx b/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/StatCard.test.tsx new file mode 100644 index 0000000000..5198ba2abb --- /dev/null +++ b/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/StatCard.test.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { StatCard } from "@components/updates/stat-card-row/StatCard"; +import { Activity } from "@wso2/oxygen-ui-icons-react"; + +describe("StatCard", () => { + const defaultProps = { + label: "Test Stat", + value: 100, + icon: , + iconColor: "primary" as const, + tooltipText: "Tooltip text", + }; + + it("renders the label and value correctly", () => { + render(); + + expect(screen.getByText("Test Stat")).toBeDefined(); + expect(screen.getByText("100")).toBeDefined(); + }); + + it("renders a skeleton when loading", () => { + render(); + + // Typography shows skeleton when isLoading is true + // In our implementation, we use Oxygen UI Skeleton + // We can check if the value 100 is NOT present + expect(screen.queryByText("100")).toBeNull(); + }); + + it("renders a placeholder when there is an error", () => { + render(); + + expect(screen.queryByText("100")).toBeNull(); + // ErrorIndicator should be rendered + expect(screen.getByTestId("error-indicator")).toBeDefined(); + }); + + it("renders extra content if provided", () => { + render( + Extra Info} + />, + ); + + expect(screen.getByTestId("extra")).toBeDefined(); + expect(screen.getByText("Extra Info")).toBeDefined(); + }); +}); From b3c5adda87cf22e5cd39252fbbc52ea78c2e7ff4 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:10:12 +0530 Subject: [PATCH 09/22] Add StatCard component for updates Introduce a new StatCard component at apps/customer-portal/webapp/src/components/updates/stat-card-row/StatCard.tsx. The component renders a statistic card with an icon, colored background, value (with loading Skeleton and ErrorIndicator states), label, tooltip, and optional extra content. It uses the app theme and StatCardColor for styling and exports the typed StatCardProps interface. --- .../updates/stat-card-row/StatCard.tsx | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 apps/customer-portal/webapp/src/components/updates/stat-card-row/StatCard.tsx diff --git a/apps/customer-portal/webapp/src/components/updates/stat-card-row/StatCard.tsx b/apps/customer-portal/webapp/src/components/updates/stat-card-row/StatCard.tsx new file mode 100644 index 0000000000..4536ff2cd2 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/updates/stat-card-row/StatCard.tsx @@ -0,0 +1,139 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { + Box, + Typography, + Card, + Tooltip, + Skeleton, + useTheme, + alpha, +} from "@wso2/oxygen-ui"; +import { Info } from "@wso2/oxygen-ui-icons-react"; +import { type JSX } from "react"; +import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; +import { type StatCardColor } from "@constants/dashboardConstants"; + +//Props for the StatCard component. +interface StatCardProps { + label: string; + value: string | number; + icon: JSX.Element; + iconColor: StatCardColor; + tooltipText: string; + isLoading?: boolean; + isError?: boolean; + extraContent?: JSX.Element; +} + +/** + * Component to display a single statistic card for updates. + * + * @param {StatCardProps} props - Component props. + * @returns {JSX.Element} The rendered component. + */ +export const StatCard = ({ + label, + value, + icon, + iconColor, + tooltipText, + isLoading, + isError, + extraContent, +}: StatCardProps): JSX.Element => { + const theme = useTheme(); + + return ( + + + {/* Icon */} + + {icon} + + + {/* Value */} + + + {isLoading ? ( + + ) : isError ? ( + + ) : ( + (value ?? "N/A") + )} + + + + + {/* Label and Extra Content */} + + + + {label} + + + + + + + + + + {extraContent && {extraContent}} + + + ); +}; From b9d514eb839bff9928eb173fe15db9670fc0ad48 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:10:42 +0530 Subject: [PATCH 10/22] Switch to recommended update levels hook Replace useGetProductUpdatesStats with useGetRecommendedUpdateLevels and update the UpdatesStatsGrid import path. Update the error banner and logger messages to reference "recommended update levels" instead of "updates statistics" to align the page with the new hook/API. --- .../customer-portal/webapp/src/pages/UpdatesPage.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/customer-portal/webapp/src/pages/UpdatesPage.tsx b/apps/customer-portal/webapp/src/pages/UpdatesPage.tsx index 9cdde1643f..fc9c2bbf8b 100644 --- a/apps/customer-portal/webapp/src/pages/UpdatesPage.tsx +++ b/apps/customer-portal/webapp/src/pages/UpdatesPage.tsx @@ -18,8 +18,8 @@ import { Box, Typography } from "@wso2/oxygen-ui"; import { useParams } from "react-router"; import { useState, useEffect, useRef, type JSX } from "react"; import TabBar from "@components/common/tab-bar/TabBar"; -import { UpdatesStatsGrid } from "@components/updates/UpdatesStatsGrid"; -import { useGetProductUpdatesStats } from "@api/useGetProductUpdatesStats"; +import { UpdatesStatsGrid } from "@components/updates/stat-card-row/UpdatesStatsGrid"; +import { useGetRecommendedUpdateLevels } from "@api/useGetRecommendedUpdateLevels"; import { useLoader } from "@context/linear-loader/LoaderContext"; import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; import { useLogger } from "@hooks/useLogger"; @@ -42,7 +42,7 @@ export default function UpdatesPage(): JSX.Element { const { showError } = useErrorBanner(); const hasShownErrorRef = useRef(false); - const { data, isLoading, isError } = useGetProductUpdatesStats( + const { data, isLoading, isError } = useGetRecommendedUpdateLevels( projectId || "", ); @@ -64,8 +64,10 @@ export default function UpdatesPage(): JSX.Element { useEffect(() => { if (isError && !hasShownErrorRef.current) { hasShownErrorRef.current = true; - showError("Could not load updates statistics."); - logger.error(`Failed to load updates stats for project ID: ${projectId}`); + showError("Could not load recommended update levels."); + logger.error( + `Failed to load recommended update levels for project ID: ${projectId}`, + ); } if (!isError) { hasShownErrorRef.current = false; From 842ba13cf121dda44e4f2164b9e65778ce084657 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:11:49 +0530 Subject: [PATCH 11/22] Add RecommendedUpdateLevelItem interface Add a new RecommendedUpdateLevelItem interface to models/responses.ts to represent a single product's recommended update level and related metadata (productName, productBaseVersion, channel, starting/ending/recommended levels, installed/available update counts, and timestamp). This provides a typed shape for frontend usage of recommended update data. --- .../webapp/src/models/responses.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/customer-portal/webapp/src/models/responses.ts b/apps/customer-portal/webapp/src/models/responses.ts index 09714cfce2..29aa052ea9 100644 --- a/apps/customer-portal/webapp/src/models/responses.ts +++ b/apps/customer-portal/webapp/src/models/responses.ts @@ -403,6 +403,21 @@ export interface UpdatesStats { securityUpdatesPending: number | null; } +// Single product recommended update level item. +export interface RecommendedUpdateLevelItem { + productName: string; + productBaseVersion: string; + channel: string; + startingUpdateLevel: number; + endingUpdateLevel: number; + installedUpdatesCount: number; + installedSecurityUpdatesCount: number; + timestamp: number; + recommendedUpdateLevel: number; + availableUpdatesCount: number; + availableSecurityUpdatesCount: number; +} + // Product update levels. export interface ProductUpdateLevelEntry { "product-base-version": string; From 8758a4d6e182796e14a833892cf016ad0d812aae Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:12:32 +0530 Subject: [PATCH 12/22] Add mock recommended update levels and update stats Import RecommendedUpdateLevelItem and add mockRecommendedUpdateLevels to support the /updates/recommended-update-levels endpoint. Adjust mockUpdatesStats counts/breakdowns (totalInstalled and pending/security numbers) and make minor formatting changes to project/product/deployment objects and a vulnerability resolution string for consistency. --- .../webapp/src/models/mockData.ts | 311 +++++++++++++++++- 1 file changed, 298 insertions(+), 13 deletions(-) diff --git a/apps/customer-portal/webapp/src/models/mockData.ts b/apps/customer-portal/webapp/src/models/mockData.ts index 1455195400..9d9e02a033 100644 --- a/apps/customer-portal/webapp/src/models/mockData.ts +++ b/apps/customer-portal/webapp/src/models/mockData.ts @@ -30,6 +30,7 @@ import type { ProductVulnerability, ProductVulnerabilitiesSearchResponse, UpdatesStats, + RecommendedUpdateLevelItem, ProductUpdateLevelsResponse, } from "@models/responses"; import { @@ -1673,7 +1674,10 @@ export const mockProjectDeployments: ProjectDeploymentItem[] = [ updatedOn: "2026-02-10 03:42:27", description: null, url: null, - project: { id: "6fa0b42d1bfaa694a002c9d3604bcb77", label: "Customer 3 Project 1 - Subscription" }, + project: { + id: "6fa0b42d1bfaa694a002c9d3604bcb77", + label: "Customer 3 Project 1 - Subscription", + }, type: { id: "3", label: "Staging" }, }, { @@ -1683,7 +1687,10 @@ export const mockProjectDeployments: ProjectDeploymentItem[] = [ updatedOn: "2026-02-06 04:00:19", description: null, url: null, - project: { id: "6fa0b42d1bfaa694a002c9d3604bcb77", label: "Customer 3 Project 1 - Subscription" }, + project: { + id: "6fa0b42d1bfaa694a002c9d3604bcb77", + label: "Customer 3 Project 1 - Subscription", + }, type: { id: "6", label: "Primary Production" }, }, { @@ -1693,7 +1700,10 @@ export const mockProjectDeployments: ProjectDeploymentItem[] = [ updatedOn: "2026-02-06 04:00:02", description: null, url: null, - project: { id: "6fa0b42d1bfaa694a002c9d3604bcb77", label: "Customer 3 Project 1 - Subscription" }, + project: { + id: "6fa0b42d1bfaa694a002c9d3604bcb77", + label: "Customer 3 Project 1 - Subscription", + }, type: { id: "1", label: "Development" }, }, ]; @@ -1705,29 +1715,305 @@ export const mockDeploymentProducts: DeploymentProductItem[] = [ createdOn: "2026-02-10 03:43:39", updatedOn: "2026-02-10 03:43:39", description: null, - product: { id: "34c5b7b11b0c61d0d64e64a2604bcb5f", label: "WSO2 Identity Server 6.0.0" }, - deployment: { id: "70f481301ba7a650a002c9d3604bcbf7", label: "Development" }, + product: { + id: "34c5b7b11b0c61d0d64e64a2604bcb5f", + label: "WSO2 Identity Server 6.0.0", + }, + deployment: { + id: "70f481301ba7a650a002c9d3604bcbf7", + label: "Development", + }, }, { id: "a02f54101bcf3610182c0dc5604bcb9b", createdOn: "2026-02-09 17:04:44", updatedOn: "2026-02-09 17:05:06", description: null, - product: { id: "db16915e1b83cd10d64e64a2604bcbdb", label: "WSO2 API Manager 3.2.0" }, - deployment: { id: "70f481301ba7a650a002c9d3604bcbf7", label: "Development" }, + product: { + id: "db16915e1b83cd10d64e64a2604bcbdb", + label: "WSO2 API Manager 3.2.0", + }, + deployment: { + id: "70f481301ba7a650a002c9d3604bcbf7", + label: "Development", + }, }, ]; // Mock updates statistics (used when isMockEnabled for useGetProductUpdatesStats). export const mockUpdatesStats: UpdatesStats = { productsTracked: 4, - totalUpdatesInstalled: 70, - totalUpdatesInstalledBreakdown: { regular: 50, security: 20 }, + totalUpdatesInstalled: 66, + totalUpdatesInstalledBreakdown: { regular: 48, security: 18 }, totalUpdatesPending: 69, - totalUpdatesPendingBreakdown: { regular: 37, security: 32 }, - securityUpdatesPending: 32, + totalUpdatesPendingBreakdown: { regular: 36, security: 33 }, + securityUpdatesPending: 33, }; +// Mock recommended update levels (GET /updates/recommended-update-levels). +export const mockRecommendedUpdateLevels: RecommendedUpdateLevelItem[] = [ + { + productName: "wso2am-analytics", + productBaseVersion: "2.6.0", + channel: "full", + startingUpdateLevel: 0, + endingUpdateLevel: 33, + installedUpdatesCount: 44, + installedSecurityUpdatesCount: 23, + timestamp: 1684415113845, + recommendedUpdateLevel: 33, + availableUpdatesCount: 0, + availableSecurityUpdatesCount: 0, + }, + { + productName: "wso2-obam-accelerator", + productBaseVersion: "3.0.0", + channel: "full", + startingUpdateLevel: 41, + endingUpdateLevel: 43, + installedUpdatesCount: 50, + installedSecurityUpdatesCount: 0, + timestamp: 1687418998376, + recommendedUpdateLevel: 46, + availableUpdatesCount: 3, + availableSecurityUpdatesCount: 0, + }, + { + productName: "wso2am", + productBaseVersion: "2.1.0", + channel: "full", + startingUpdateLevel: 0, + endingUpdateLevel: 36, + installedUpdatesCount: 42, + installedSecurityUpdatesCount: 15, + timestamp: 1689152894396, + recommendedUpdateLevel: 37, + availableUpdatesCount: 1, + availableSecurityUpdatesCount: 1, + }, + { + productName: "wso2is-km", + productBaseVersion: "5.3.0", + channel: "full", + startingUpdateLevel: -1, + endingUpdateLevel: 16, + installedUpdatesCount: 21, + installedSecurityUpdatesCount: 5, + timestamp: 1623392074318, + recommendedUpdateLevel: 35, + availableUpdatesCount: 21, + availableSecurityUpdatesCount: 14, + }, + { + productName: "wso2am", + productBaseVersion: "2.2.0", + channel: "full", + startingUpdateLevel: 3, + endingUpdateLevel: 41, + installedUpdatesCount: 64, + installedSecurityUpdatesCount: 29, + timestamp: 1684419673865, + recommendedUpdateLevel: 54, + availableUpdatesCount: 14, + availableSecurityUpdatesCount: 7, + }, + { + productName: "wso2is-km", + productBaseVersion: "5.6.0", + channel: "full", + startingUpdateLevel: -1, + endingUpdateLevel: 25, + installedUpdatesCount: 38, + installedSecurityUpdatesCount: 14, + timestamp: 1638963338008, + recommendedUpdateLevel: 54, + availableUpdatesCount: 33, + availableSecurityUpdatesCount: 23, + }, + { + productName: "wso2ei", + productBaseVersion: "6.4.0", + channel: "full", + startingUpdateLevel: 0, + endingUpdateLevel: 18, + installedUpdatesCount: 35, + installedSecurityUpdatesCount: 3, + timestamp: 1620125767880, + recommendedUpdateLevel: 91, + availableUpdatesCount: 105, + availableSecurityUpdatesCount: 48, + }, + { + productName: "wso2ad", + productBaseVersion: "1.0.0", + channel: "full", + startingUpdateLevel: 2, + endingUpdateLevel: 6, + installedUpdatesCount: 6, + installedSecurityUpdatesCount: 5, + timestamp: 1687437294605, + recommendedUpdateLevel: 24, + availableUpdatesCount: 22, + availableSecurityUpdatesCount: 14, + }, + { + productName: "wso2am", + productBaseVersion: "2.6.0", + channel: "full", + startingUpdateLevel: -1, + endingUpdateLevel: 124, + installedUpdatesCount: 217, + installedSecurityUpdatesCount: 59, + timestamp: 1683102829596, + recommendedUpdateLevel: 126, + availableUpdatesCount: 2, + availableSecurityUpdatesCount: 1, + }, + { + productName: "wso2am", + productBaseVersion: "3.1.0", + channel: "full", + startingUpdateLevel: -1, + endingUpdateLevel: 102, + installedUpdatesCount: 331, + installedSecurityUpdatesCount: 53, + timestamp: 1641458770263, + recommendedUpdateLevel: 227, + availableUpdatesCount: 216, + availableSecurityUpdatesCount: 68, + }, + { + productName: "wso2am", + productBaseVersion: "3.2.0", + channel: "full", + startingUpdateLevel: -1, + endingUpdateLevel: 2147483647, + installedUpdatesCount: 896, + installedSecurityUpdatesCount: 155, + timestamp: 1690551230286, + recommendedUpdateLevel: 292, + availableUpdatesCount: 0, + availableSecurityUpdatesCount: 0, + }, + { + productName: "wso2is", + productBaseVersion: "5.11.0", + channel: "full", + startingUpdateLevel: -1, + endingUpdateLevel: 218, + installedUpdatesCount: 450, + installedSecurityUpdatesCount: 122, + timestamp: 1675314684662, + recommendedUpdateLevel: 258, + availableUpdatesCount: 77, + availableSecurityUpdatesCount: 23, + }, + { + productName: "wso2am", + productBaseVersion: "3.0.0", + channel: "full", + startingUpdateLevel: 6, + endingUpdateLevel: 26, + installedUpdatesCount: 55, + installedSecurityUpdatesCount: 12, + timestamp: 1684488832638, + recommendedUpdateLevel: 138, + availableUpdatesCount: 158, + availableSecurityUpdatesCount: 86, + }, + { + productName: "wso2am", + productBaseVersion: "4.0.0", + channel: "full", + startingUpdateLevel: -1, + endingUpdateLevel: 193, + installedUpdatesCount: 470, + installedSecurityUpdatesCount: 104, + timestamp: 1680515730101, + recommendedUpdateLevel: 222, + availableUpdatesCount: 80, + availableSecurityUpdatesCount: 18, + }, + { + productName: "wso2am", + productBaseVersion: "4.1.0", + channel: "full", + startingUpdateLevel: 7, + endingUpdateLevel: 2147483647, + installedUpdatesCount: 282, + installedSecurityUpdatesCount: 58, + timestamp: 1690036013689, + recommendedUpdateLevel: 94, + availableUpdatesCount: 0, + availableSecurityUpdatesCount: 0, + }, + { + productName: "wso2am", + productBaseVersion: "4.2.0", + channel: "full", + startingUpdateLevel: 0, + endingUpdateLevel: 11, + installedUpdatesCount: 46, + installedSecurityUpdatesCount: 3, + timestamp: 1687155792395, + recommendedUpdateLevel: 22, + availableUpdatesCount: 44, + availableSecurityUpdatesCount: 9, + }, + { + productName: "wso2ei", + productBaseVersion: "6.6.0", + channel: "full", + startingUpdateLevel: 21, + endingUpdateLevel: 22, + installedUpdatesCount: 61, + installedSecurityUpdatesCount: 4, + timestamp: 1619007254480, + recommendedUpdateLevel: 160, + availableUpdatesCount: 208, + availableSecurityUpdatesCount: 66, + }, + { + productName: "wso2ei", + productBaseVersion: "6.2.0", + channel: "full", + startingUpdateLevel: -1, + endingUpdateLevel: 1, + installedUpdatesCount: 1, + installedSecurityUpdatesCount: 1, + timestamp: 1604903450070, + recommendedUpdateLevel: 60, + availableUpdatesCount: 78, + availableSecurityUpdatesCount: 35, + }, + { + productName: "wso2is", + productBaseVersion: "5.10.0", + channel: "full", + startingUpdateLevel: -1, + endingUpdateLevel: 222, + installedUpdatesCount: 555, + installedSecurityUpdatesCount: 126, + timestamp: 1683103275878, + recommendedUpdateLevel: 241, + availableUpdatesCount: 36, + availableSecurityUpdatesCount: 16, + }, + { + productName: "wso2updatetest", + productBaseVersion: "1.0.0", + channel: "full", + startingUpdateLevel: 2, + endingUpdateLevel: 3, + installedUpdatesCount: 3, + installedSecurityUpdatesCount: 0, + timestamp: 1690050744384, + recommendedUpdateLevel: 11, + availableUpdatesCount: 7, + availableSecurityUpdatesCount: 0, + }, +]; + // Mock product update levels (used when isMockEnabled for useGetProductUpdateLevels). export const mockProductUpdateLevels: ProductUpdateLevelsResponse = [ { @@ -1850,8 +2136,7 @@ export const mockProductVulnerabilities: ProductVulnerability[] = [ useCase: "Batch for Patching", justification: "Template engine is used with controlled inputs and no direct user input exposure.", - resolution: - "We will proactively upgrade to the latest secure version.", + resolution: "We will proactively upgrade to the latest secure version.", }, ]; From 18c53b7002897e67d50822630f127757282656ac Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:12:48 +0530 Subject: [PATCH 13/22] Rename UPDATES_STATS to RECOMMENDED_UPDATE_LEVELS Update ApiQueryKeys in apps/customer-portal/webapp/src/constants/apiConstants.ts: replace the UPDATES_STATS key value "updates-stats" with "recommended-update-levels" to reflect the new naming/semantics. Verify and update any usages that reference the old key. --- apps/customer-portal/webapp/src/constants/apiConstants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/customer-portal/webapp/src/constants/apiConstants.ts b/apps/customer-portal/webapp/src/constants/apiConstants.ts index ec7ac2350d..7d72512483 100644 --- a/apps/customer-portal/webapp/src/constants/apiConstants.ts +++ b/apps/customer-portal/webapp/src/constants/apiConstants.ts @@ -34,7 +34,7 @@ export const ApiQueryKeys = { DEPLOYMENTS: "deployments", DEPLOYMENT_PRODUCTS: "deployment-products", TIME_TRACKING_STATS: "time-tracking-stats", - UPDATES_STATS: "updates-stats", + RECOMMENDED_UPDATE_LEVELS: "recommended-update-levels", PRODUCT_UPDATE_LEVELS: "product-update-levels", PRODUCT_VULNERABILITY: "product-vulnerability", PROJECT_USERS: "project-users", From 9008b2e57f62193a1b25defd32e8c7f2c2375bfc Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:13:05 +0530 Subject: [PATCH 14/22] Remove UpdatesStatsGrid component Delete the UpdatesStatsGrid.tsx component which rendered the "Overall Update Status" grid of StatCard items (used UPDATES_STATS, StatCard, Grid, Typography). Removes apps/customer-portal/webapp/src/components/updates/UpdatesStatsGrid.tsx. Likely part of a refactor or component consolidation; no other context available in this changeset. --- .../components/updates/UpdatesStatsGrid.tsx | 96 ------------------- 1 file changed, 96 deletions(-) delete mode 100644 apps/customer-portal/webapp/src/components/updates/UpdatesStatsGrid.tsx diff --git a/apps/customer-portal/webapp/src/components/updates/UpdatesStatsGrid.tsx b/apps/customer-portal/webapp/src/components/updates/UpdatesStatsGrid.tsx deleted file mode 100644 index 56fc868281..0000000000 --- a/apps/customer-portal/webapp/src/components/updates/UpdatesStatsGrid.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import { Box, Grid, Typography } from "@wso2/oxygen-ui"; -import { type JSX } from "react"; -import { StatCard } from "@components/dashboard/stats/StatCard"; -import { UPDATES_STATS } from "@constants/updatesConstants"; -import type { UpdatesStats } from "@models/responses"; - -const NULL_PLACEHOLDER = "--"; - -export interface UpdatesStatsGridProps { - data: UpdatesStats | undefined; - isLoading: boolean; - isError: boolean; -} - -/** - * Grid of stat cards for Overall Update Status. - * - * @param {UpdatesStatsGridProps} props - Component props. - * @returns {JSX.Element} The rendered component. - */ -export function UpdatesStatsGrid({ - data, - isLoading, - isError, -}: UpdatesStatsGridProps): JSX.Element { - const getValue = (id: keyof UpdatesStats): string | number => { - if (!data) return NULL_PLACEHOLDER; - const val = data[id]; - if (val === null || val === undefined) return NULL_PLACEHOLDER; - if (typeof val === "object") return NULL_PLACEHOLDER; - return val as string | number; - }; - - const getTooltipText = (stat: (typeof UPDATES_STATS)[number]): string => { - if (!data) return stat.tooltipText; - if ( - stat.id === "totalUpdatesInstalled" && - data.totalUpdatesInstalledBreakdown - ) { - const { regular, security } = data.totalUpdatesInstalledBreakdown; - return `${stat.tooltipText} (${regular} Regular • ${security} Security)`; - } - if ( - stat.id === "totalUpdatesPending" && - data.totalUpdatesPendingBreakdown - ) { - const { regular, security } = data.totalUpdatesPendingBreakdown; - return `${stat.tooltipText} (${regular} Regular • ${security} Security)`; - } - return stat.tooltipText; - }; - - return ( - - - Overall Update Status - - - {UPDATES_STATS.map((stat) => { - const Icon = stat.icon; - const value = getValue(stat.id as keyof UpdatesStats); - - return ( - - } - iconColor={stat.iconColor} - tooltipText={getTooltipText(stat)} - isLoading={isLoading} - isError={isError} - /> - - ); - })} - - - ); -} From 0bbea5e9698190470f28c3bf499bf206cf5adfc6 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:13:20 +0530 Subject: [PATCH 15/22] Add UpdatesStatsGrid component Introduce UpdatesStatsGrid.tsx: a new component that aggregates RecommendedUpdateLevelItem data and renders a grid of StatCard entries for Overall Update Status. Computes totals and breakdowns (installed/pending, regular/security), handles loading and error states with skeletons, and shows an action badge when security updates are pending. Exports UpdatesStatsGrid and its props interface; relies on UPDATES_STATS, StatCard, and oxygen-ui primitives. --- .../stat-card-row/UpdatesStatsGrid.tsx | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx diff --git a/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx b/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx new file mode 100644 index 0000000000..c3e3b02fd8 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx @@ -0,0 +1,238 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { Badge, Box, Grid, Skeleton, Typography } from "@wso2/oxygen-ui"; +import { type JSX } from "react"; +import { StatCard } from "@components/updates/stat-card-row/StatCard"; +import { UPDATES_STATS } from "@constants/updatesConstants"; +import type { + RecommendedUpdateLevelItem, + UpdatesStats, +} from "@models/responses"; + +const NULL_PLACEHOLDER = "--"; + +export interface UpdatesStatsGridProps { + data: RecommendedUpdateLevelItem[] | undefined; + isLoading: boolean; + isError: boolean; +} + +/** + * Grid of stat cards for Overall Update Status. + * + * @param {UpdatesStatsGridProps} props - Component props. + * @returns {JSX.Element} The rendered component. + */ +export function UpdatesStatsGrid({ + data, + isLoading, + isError, +}: UpdatesStatsGridProps): JSX.Element { + const aggregatedData: UpdatesStats | undefined = data + ? { + productsTracked: data.length, + totalUpdatesInstalled: data.reduce( + (acc, item) => + acc + + (item.installedUpdatesCount + item.installedSecurityUpdatesCount), + 0, + ), + totalUpdatesInstalledBreakdown: { + regular: data.reduce( + (acc, item) => acc + item.installedUpdatesCount, + 0, + ), + security: data.reduce( + (acc, item) => acc + item.installedSecurityUpdatesCount, + 0, + ), + }, + totalUpdatesPending: data.reduce( + (acc, item) => + acc + + (item.availableUpdatesCount + item.availableSecurityUpdatesCount), + 0, + ), + totalUpdatesPendingBreakdown: { + regular: data.reduce( + (acc, item) => acc + item.availableUpdatesCount, + 0, + ), + security: data.reduce( + (acc, item) => acc + item.availableSecurityUpdatesCount, + 0, + ), + }, + securityUpdatesPending: data.reduce( + (acc, item) => acc + item.availableSecurityUpdatesCount, + 0, + ), + } + : undefined; + + const getValue = (id: keyof UpdatesStats): string | number => { + if (!aggregatedData) return NULL_PLACEHOLDER; + const val = aggregatedData[id]; + if (val === null || val === undefined) return NULL_PLACEHOLDER; + if (typeof val === "object") return NULL_PLACEHOLDER; + return val as string | number; + }; + + const getTooltipText = (stat: (typeof UPDATES_STATS)[number]): string => { + if (!aggregatedData) return stat.tooltipText; + if ( + stat.id === "totalUpdatesInstalled" && + aggregatedData.totalUpdatesInstalledBreakdown + ) { + const { regular, security } = + aggregatedData.totalUpdatesInstalledBreakdown; + return `${stat.tooltipText} (${regular} Regular • ${security} Security)`; + } + if ( + stat.id === "totalUpdatesPending" && + aggregatedData.totalUpdatesPendingBreakdown + ) { + const { regular, security } = aggregatedData.totalUpdatesPendingBreakdown; + return `${stat.tooltipText} (${regular} Regular • ${security} Security)`; + } + return stat.tooltipText; + }; + + const renderCountWithSkeleton = ( + count: number | undefined, + width: number = 24, + ): JSX.Element | number => { + if (isEffectiveLoading || count === undefined) { + return ( + + ); + } + return count; + }; + + const renderExtraContent = ( + stat: (typeof UPDATES_STATS)[number], + ): JSX.Element | undefined => { + if (isError) { + return undefined; + } + + if ( + stat.id === "totalUpdatesInstalled" && + (isEffectiveLoading || aggregatedData?.totalUpdatesInstalledBreakdown) + ) { + const { regular, security } = + aggregatedData?.totalUpdatesInstalledBreakdown || {}; + return ( + + + {renderCountWithSkeleton(regular)} Regular •{" "} + {renderCountWithSkeleton(security)} Security + + + ); + } + + if ( + stat.id === "totalUpdatesPending" && + (isEffectiveLoading || aggregatedData?.totalUpdatesPendingBreakdown) + ) { + const { regular, security } = + aggregatedData?.totalUpdatesPendingBreakdown || {}; + return ( + + + {renderCountWithSkeleton(regular)} Regular •{" "} + {renderCountWithSkeleton(security)} Security + + + ); + } + + if (stat.id === "securityUpdatesPending") { + const securityPending = aggregatedData?.securityUpdatesPending; + + if (isEffectiveLoading) { + return ( + + ); + } + + if (securityPending && securityPending > 0) { + return ( + + ); + } + } + + return undefined; + }; + + const isEffectiveLoading = isLoading || (!data && !isError); + + return ( + + + Overall Update Status + + + {UPDATES_STATS.map((stat) => { + const Icon = stat.icon; + const value = getValue(stat.id as keyof UpdatesStats); + + return ( + + } + iconColor={stat.iconColor} + tooltipText={getTooltipText(stat)} + isLoading={isEffectiveLoading} + isError={isError} + extraContent={renderExtraContent(stat)} + /> + + ); + })} + + + ); +} From c0d31723e5d1ec2c395c813dff9c8401c09d5bf5 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:14:38 +0530 Subject: [PATCH 16/22] Add tests for UpdatesStatsGrid component Add a new test suite for UpdatesStatsGrid (apps/customer-portal/.../UpdatesStatsGrid.test.tsx). The tests mock the StatCard to avoid deep rendering and verify aggregated values from mockRecommendedUpdateLevels, plus loading (skeleton) and error placeholder behaviors. Uses Vitest and React Testing Library. --- .../__tests__/UpdatesStatsGrid.test.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/UpdatesStatsGrid.test.tsx diff --git a/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/UpdatesStatsGrid.test.tsx b/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/UpdatesStatsGrid.test.tsx new file mode 100644 index 0000000000..3f98dfe29c --- /dev/null +++ b/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/UpdatesStatsGrid.test.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { UpdatesStatsGrid } from "@components/updates/stat-card-row/UpdatesStatsGrid"; +import { mockRecommendedUpdateLevels } from "@models/mockData"; + +// Mock StatCard to avoid deep rendering issues and focus on grid logic +vi.mock("@components/updates/stat-card-row/StatCard", () => ({ + StatCard: ({ label, value }: { label: string; value: string | number }) => ( +
+ {label} + {value} +
+ ), +})); + +describe("UpdatesStatsGrid", () => { + it("renders all stat cards with correct aggregated values", () => { + render( + , + ); + + // Check if cards are present + expect(screen.getByTestId("stat-card-Products Tracked")).toBeDefined(); + expect( + screen.getByTestId("stat-card-Total Updates Installed"), + ).toBeDefined(); + expect(screen.getByTestId("stat-card-Total Updates Pending")).toBeDefined(); + expect( + screen.getByTestId("stat-card-Security Updates Pending"), + ).toBeDefined(); + + // Verify aggregated values for 20 products + expect(screen.getByText("20")).toBeDefined(); + expect(screen.getByText("4458")).toBeDefined(); + expect(screen.getByText("1534")).toBeDefined(); + expect(screen.getByText("429")).toBeDefined(); + }); + + it("shows skeletons when loading", () => { + render( + , + ); + expect(screen.getAllByText("--")).toHaveLength(4); + }); + + it("renders placeholders when there is an error", () => { + render( + , + ); + + expect(screen.getAllByText("--")).toHaveLength(4); + }); +}); From 30ccb8a1bbaa7ca6514119377da6f31bb404188d Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 17:15:39 +0530 Subject: [PATCH 17/22] Add getMockRecommendedUpdateLevels helper Introduce getMockRecommendedUpdateLevels() which returns mockRecommendedUpdateLevels (RecommendedUpdateLevelItem[]). Update imports to include mockRecommendedUpdateLevels and RecommendedUpdateLevelItem, add JSDoc for the new helper, and reformat getMockProductUpdateLevels for consistency. --- .../webapp/src/models/mockFunctions.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/customer-portal/webapp/src/models/mockFunctions.ts b/apps/customer-portal/webapp/src/models/mockFunctions.ts index 5336817333..85947041ed 100644 --- a/apps/customer-portal/webapp/src/models/mockFunctions.ts +++ b/apps/customer-portal/webapp/src/models/mockFunctions.ts @@ -24,6 +24,7 @@ import { mockDeploymentProducts, mockStatusOptions, mockUpdatesStats, + mockRecommendedUpdateLevels, mockProductUpdateLevels, } from "@models/mockData"; import type { CaseCreationMetadata } from "@models/mockData"; @@ -39,6 +40,7 @@ import type { ProjectDeploymentItem, DeploymentProductItem, UpdatesStats, + RecommendedUpdateLevelItem, ProductUpdateLevelsResponse, CaseClassificationResponse, } from "@models/responses"; @@ -328,7 +330,15 @@ export const getMockDeploymentProducts = ( }; /** - * Returns mock updates statistics (used when isMockEnabled for useGetProductUpdatesStats). + * Returns mock recommended update levels. + * + * @returns {RecommendedUpdateLevelItem[]} Mock recommended update levels. + */ +export const getMockRecommendedUpdateLevels = + (): RecommendedUpdateLevelItem[] => mockRecommendedUpdateLevels; + +/** + * Returns mock updates statistics. * * @returns {UpdatesStats} Mock updates statistics. */ @@ -339,8 +349,8 @@ export const getMockUpdatesStats = (): UpdatesStats => mockUpdatesStats; * * @returns {ProductUpdateLevelsResponse} Mock product update levels. */ -export const getMockProductUpdateLevels = - (): ProductUpdateLevelsResponse => mockProductUpdateLevels; +export const getMockProductUpdateLevels = (): ProductUpdateLevelsResponse => + mockProductUpdateLevels; /** * Returns mock case classification response. From 202f11d7429764f62d17ea05128136e458a78360 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 18:06:32 +0530 Subject: [PATCH 18/22] Add update utilities and tests; minor fixes Introduce updates utilities and unit tests: add utils/updates.ts with aggregateUpdateStats, getStatValue, getStatTooltipText and NULL_PLACEHOLDER to compute and format update summary statistics; add Vitest tests (utils/__tests__/updates.test.ts) covering aggregation, value retrieval and tooltip text. Also tweak UpdatesPage to call useGetRecommendedUpdateLevels() without passing projectId, and adjust a mock comment in mockData.ts. These changes centralize stats logic for the Updates UI and add coverage to ensure correct behavior. --- .../webapp/src/models/mockData.ts | 2 +- .../webapp/src/pages/UpdatesPage.tsx | 4 +- .../src/utils/__tests__/updates.test.ts | 106 ++++++++++++++ .../webapp/src/utils/updates.ts | 134 ++++++++++++++++++ 4 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 apps/customer-portal/webapp/src/utils/__tests__/updates.test.ts create mode 100644 apps/customer-portal/webapp/src/utils/updates.ts diff --git a/apps/customer-portal/webapp/src/models/mockData.ts b/apps/customer-portal/webapp/src/models/mockData.ts index 9d9e02a033..3d3d6b38ea 100644 --- a/apps/customer-portal/webapp/src/models/mockData.ts +++ b/apps/customer-portal/webapp/src/models/mockData.ts @@ -1740,7 +1740,7 @@ export const mockDeploymentProducts: DeploymentProductItem[] = [ }, ]; -// Mock updates statistics (used when isMockEnabled for useGetProductUpdatesStats). +// Mock updates statistics. export const mockUpdatesStats: UpdatesStats = { productsTracked: 4, totalUpdatesInstalled: 66, diff --git a/apps/customer-portal/webapp/src/pages/UpdatesPage.tsx b/apps/customer-portal/webapp/src/pages/UpdatesPage.tsx index fc9c2bbf8b..b1ed692661 100644 --- a/apps/customer-portal/webapp/src/pages/UpdatesPage.tsx +++ b/apps/customer-portal/webapp/src/pages/UpdatesPage.tsx @@ -42,9 +42,7 @@ export default function UpdatesPage(): JSX.Element { const { showError } = useErrorBanner(); const hasShownErrorRef = useRef(false); - const { data, isLoading, isError } = useGetRecommendedUpdateLevels( - projectId || "", - ); + const { data, isLoading, isError } = useGetRecommendedUpdateLevels(); const isUpdatesLoading = isLoading || (!data && !isError); diff --git a/apps/customer-portal/webapp/src/utils/__tests__/updates.test.ts b/apps/customer-portal/webapp/src/utils/__tests__/updates.test.ts new file mode 100644 index 0000000000..6c57ff57da --- /dev/null +++ b/apps/customer-portal/webapp/src/utils/__tests__/updates.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { describe, it, expect } from "vitest"; +import { + aggregateUpdateStats, + getStatValue, + getStatTooltipText, + NULL_PLACEHOLDER, +} from "@utils/updates"; +import { mockRecommendedUpdateLevels } from "@models/mockData"; +import { UPDATES_STATS } from "@constants/updatesConstants"; + +describe("updates utilities", () => { + describe("aggregateUpdateStats", () => { + it("should return undefined if data is undefined", () => { + expect(aggregateUpdateStats(undefined)).toBeUndefined(); + }); + + it("should correctly aggregate stats for a 20-product dataset", () => { + const stats = aggregateUpdateStats(mockRecommendedUpdateLevels); + expect(stats).toBeDefined(); + expect(stats?.productsTracked).toBe(20); + expect(stats?.totalUpdatesInstalled).toBe(4458); + expect(stats?.totalUpdatesPending).toBe(1534); + expect(stats?.securityUpdatesPending).toBe(429); + expect(stats?.totalUpdatesInstalledBreakdown!.regular).toBe(3667); + expect(stats?.totalUpdatesInstalledBreakdown!.security).toBe(791); + expect(stats?.totalUpdatesPendingBreakdown!.regular).toBe(1105); + expect(stats?.totalUpdatesPendingBreakdown!.security).toBe(429); + }); + }); + + describe("getStatValue", () => { + const mockStats = { + productsTracked: 10, + totalUpdatesInstalled: 100, + totalUpdatesInstalledBreakdown: { regular: 80, security: 20 }, + totalUpdatesPending: 50, + totalUpdatesPendingBreakdown: { regular: 30, security: 20 }, + securityUpdatesPending: 20, + }; + + it("should return NULL_PLACEHOLDER if aggregatedData is undefined", () => { + expect(getStatValue(undefined, "productsTracked")).toBe(NULL_PLACEHOLDER); + }); + + it("should return the correct value for a given key", () => { + expect(getStatValue(mockStats, "productsTracked")).toBe(10); + expect(getStatValue(mockStats, "totalUpdatesInstalled")).toBe(100); + }); + + it("should return NULL_PLACEHOLDER if value is an object", () => { + expect( + getStatValue(mockStats, "totalUpdatesInstalledBreakdown" as any), + ).toBe(NULL_PLACEHOLDER); + }); + }); + + describe("getStatTooltipText", () => { + const mockStats = { + productsTracked: 10, + totalUpdatesInstalled: 100, + totalUpdatesInstalledBreakdown: { regular: 80, security: 20 }, + totalUpdatesPending: 50, + totalUpdatesPendingBreakdown: { regular: 30, security: 20 }, + securityUpdatesPending: 20, + }; + + const installedStat = UPDATES_STATS.find( + (s) => s.id === "totalUpdatesInstalled", + )!; + const productStat = UPDATES_STATS.find((s) => s.id === "productsTracked")!; + + it("should return default tooltip if aggregatedData is undefined", () => { + expect(getStatTooltipText(installedStat, undefined)).toBe( + installedStat.tooltipText, + ); + }); + + it("should include breakdown for installed updates", () => { + const tooltip = getStatTooltipText(installedStat, mockStats); + expect(tooltip).toContain(installedStat.tooltipText); + expect(tooltip).toContain("80 Regular • 20 Security"); + }); + + it("should return default tooltip for non-breakdown stats", () => { + expect(getStatTooltipText(productStat, mockStats)).toBe( + productStat.tooltipText, + ); + }); + }); +}); diff --git a/apps/customer-portal/webapp/src/utils/updates.ts b/apps/customer-portal/webapp/src/utils/updates.ts new file mode 100644 index 0000000000..a71a21ea3c --- /dev/null +++ b/apps/customer-portal/webapp/src/utils/updates.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { UPDATES_STATS } from "@constants/updatesConstants"; +import type { + RecommendedUpdateLevelItem, + UpdatesStats, +} from "@models/responses"; + +export const NULL_PLACEHOLDER = "--"; + +/** + * Aggregates recommended update level data into summary statistics. + * + * @param {RecommendedUpdateLevelItem[] | undefined} data - The raw update level data. + * @returns {UpdatesStats | undefined} The aggregated statistics. + */ +export const aggregateUpdateStats = ( + data: RecommendedUpdateLevelItem[] | undefined, +): UpdatesStats | undefined => { + if (!data) { + return undefined; + } + + return data.reduce( + (acc, item) => { + const installedRegular = item.installedUpdatesCount; + const installedSecurity = item.installedSecurityUpdatesCount; + const pendingRegular = item.availableUpdatesCount; + const pendingSecurity = item.availableSecurityUpdatesCount; + + acc.totalUpdatesInstalled = + (acc.totalUpdatesInstalled || 0) + installedRegular + installedSecurity; + if (acc.totalUpdatesInstalledBreakdown) { + acc.totalUpdatesInstalledBreakdown.regular += installedRegular; + acc.totalUpdatesInstalledBreakdown.security += installedSecurity; + } + + acc.totalUpdatesPending = + (acc.totalUpdatesPending || 0) + pendingRegular + pendingSecurity; + if (acc.totalUpdatesPendingBreakdown) { + acc.totalUpdatesPendingBreakdown.regular += pendingRegular; + acc.totalUpdatesPendingBreakdown.security += pendingSecurity; + } + + acc.securityUpdatesPending = + (acc.securityUpdatesPending || 0) + pendingSecurity; + + return acc; + }, + { + productsTracked: data.length, + totalUpdatesInstalled: 0, + totalUpdatesInstalledBreakdown: { regular: 0, security: 0 }, + totalUpdatesPending: 0, + totalUpdatesPendingBreakdown: { regular: 0, security: 0 }, + securityUpdatesPending: 0, + }, + ); +}; + +/** + * Gets the display value for a specific statistic ID from aggregated data. + * + * @param {UpdatesStats | undefined} aggregatedData - The aggregated statistics. + * @param {keyof UpdatesStats} id - The ID of the statistic to retrieve. + * @returns {string | number} The display value. + */ +export const getStatValue = ( + aggregatedData: UpdatesStats | undefined, + id: keyof UpdatesStats, +): string | number => { + if (!aggregatedData) { + return NULL_PLACEHOLDER; + } + + const val = aggregatedData[id]; + if (val === null || val === undefined) { + return NULL_PLACEHOLDER; + } + + if (typeof val === "object") { + return NULL_PLACEHOLDER; + } + + return val as string | number; +}; + +/** + * Gets the tooltip text for a stat card, including breakdown info if applicable. + * + * @param {(typeof UPDATES_STATS)[number]} stat - The stat card definition. + * @param {UpdatesStats | undefined} aggregatedData - The aggregated statistics. + * @returns {string} The tooltip text. + */ +export const getStatTooltipText = ( + stat: (typeof UPDATES_STATS)[number], + aggregatedData: UpdatesStats | undefined, +): string => { + if (!aggregatedData) { + return stat.tooltipText; + } + + if ( + stat.id === "totalUpdatesInstalled" && + aggregatedData.totalUpdatesInstalledBreakdown + ) { + const { regular, security } = aggregatedData.totalUpdatesInstalledBreakdown; + return `${stat.tooltipText} (${regular} Regular • ${security} Security)`; + } + + if ( + stat.id === "totalUpdatesPending" && + aggregatedData.totalUpdatesPendingBreakdown + ) { + const { regular, security } = aggregatedData.totalUpdatesPendingBreakdown; + return `${stat.tooltipText} (${regular} Regular • ${security} Security)`; + } + + return stat.tooltipText; +}; From 91f3d4aa71473d1a40294d911c1a6ae5941515a8 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 18:06:53 +0530 Subject: [PATCH 19/22] Remove projectId param from recommended updates hook Update useGetRecommendedUpdateLevels to no longer accept a projectId parameter and remove it from the query key. Adjust logging and error messages to be project-agnostic, simplify the enabled condition to rely on mock or auth state, and reformat the hook signature/generics. Add a test import for NULL_PLACEHOLDER and a new test verifying the placeholder renders when value is missing, and clean up an unused comment in an existing test. --- .../src/api/useGetRecommendedUpdateLevels.ts | 21 ++++++++----------- .../stat-card-row/__tests__/StatCard.test.tsx | 12 +++++++---- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts b/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts index 5f49784ac6..5c546dfde0 100644 --- a/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts +++ b/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts @@ -29,23 +29,20 @@ import type { RecommendedUpdateLevelItem } from "@models/responses"; * @param {string} projectId - The ID of the project. * @returns {UseQueryResult} The query result object. */ -export function useGetRecommendedUpdateLevels( - projectId: string, -): UseQueryResult { +export function useGetRecommendedUpdateLevels(): UseQueryResult< + RecommendedUpdateLevelItem[], + Error +> { const logger = useLogger(); const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); const fetchFn = useAuthApiClient(); const { isMockEnabled } = useMockConfig(); return useQuery({ - queryKey: [ - ApiQueryKeys.RECOMMENDED_UPDATE_LEVELS, - projectId, - isMockEnabled, - ], + queryKey: [ApiQueryKeys.RECOMMENDED_UPDATE_LEVELS, isMockEnabled], queryFn: async (): Promise => { logger.debug( - `Fetching recommended update levels for project ID: ${projectId}, mock: ${isMockEnabled}`, + `Fetching recommended update levels, mock: ${isMockEnabled}`, ); if (isMockEnabled) { @@ -55,7 +52,7 @@ export function useGetRecommendedUpdateLevels( getMockRecommendedUpdateLevels(); logger.debug( - `Recommended update levels fetched successfully for project ID: ${projectId} (mock)`, + "Recommended update levels fetched successfully (mock)", data, ); @@ -79,7 +76,7 @@ export function useGetRecommendedUpdateLevels( if (!response.ok) { throw new Error( - `Error fetching updates stats: ${response.statusText}`, + `Error fetching recommended update levels: ${response.statusText}`, ); } @@ -91,7 +88,7 @@ export function useGetRecommendedUpdateLevels( throw error; } }, - enabled: !!projectId && (isMockEnabled || (isSignedIn && !isAuthLoading)), + enabled: isMockEnabled || (isSignedIn && !isAuthLoading), staleTime: 5 * 60 * 1000, }); } diff --git a/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/StatCard.test.tsx b/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/StatCard.test.tsx index 5198ba2abb..4e035dca77 100644 --- a/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/StatCard.test.tsx +++ b/apps/customer-portal/webapp/src/components/updates/stat-card-row/__tests__/StatCard.test.tsx @@ -18,6 +18,7 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import { StatCard } from "@components/updates/stat-card-row/StatCard"; import { Activity } from "@wso2/oxygen-ui-icons-react"; +import { NULL_PLACEHOLDER } from "@utils/updates"; describe("StatCard", () => { const defaultProps = { @@ -37,10 +38,6 @@ describe("StatCard", () => { it("renders a skeleton when loading", () => { render(); - - // Typography shows skeleton when isLoading is true - // In our implementation, we use Oxygen UI Skeleton - // We can check if the value 100 is NOT present expect(screen.queryByText("100")).toBeNull(); }); @@ -63,4 +60,11 @@ describe("StatCard", () => { expect(screen.getByTestId("extra")).toBeDefined(); expect(screen.getByText("Extra Info")).toBeDefined(); }); + + it("renders the NULL_PLACEHOLDER when value is not provided", () => { + const { value, ...propsWithoutValue } = defaultProps; + render(); + + expect(screen.getByText(NULL_PLACEHOLDER)).toBeDefined(); + }); }); From f50bbb1a6fa396c1265675c3139c277b865dca00 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 18:07:14 +0530 Subject: [PATCH 20/22] Refactor updates stats logic to utils Move aggregation and helper logic for update stats into shared utilities and simplify components. StatCard: make value optional and use NULL_PLACEHOLDER from @utils/updates. UpdatesStatsGrid: remove inline aggregation and tooltip/value helpers, use aggregateUpdateStats, getStatValue and getStatTooltipText from @utils/updates and update imports accordingly. This centralizes update-related logic and reduces duplicated code in the grid component. --- .../updates/stat-card-row/StatCard.tsx | 5 +- .../stat-card-row/UpdatesStatsGrid.tsx | 86 +++---------------- 2 files changed, 15 insertions(+), 76 deletions(-) diff --git a/apps/customer-portal/webapp/src/components/updates/stat-card-row/StatCard.tsx b/apps/customer-portal/webapp/src/components/updates/stat-card-row/StatCard.tsx index 4536ff2cd2..e5348a6546 100644 --- a/apps/customer-portal/webapp/src/components/updates/stat-card-row/StatCard.tsx +++ b/apps/customer-portal/webapp/src/components/updates/stat-card-row/StatCard.tsx @@ -27,11 +27,12 @@ import { Info } from "@wso2/oxygen-ui-icons-react"; import { type JSX } from "react"; import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; import { type StatCardColor } from "@constants/dashboardConstants"; +import { NULL_PLACEHOLDER } from "@utils/updates"; //Props for the StatCard component. interface StatCardProps { label: string; - value: string | number; + value?: string | number; icon: JSX.Element; iconColor: StatCardColor; tooltipText: string; @@ -108,7 +109,7 @@ export const StatCard = ({ ) : isError ? ( ) : ( - (value ?? "N/A") + (value ?? NULL_PLACEHOLDER) )} diff --git a/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx b/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx index c3e3b02fd8..3fb6502718 100644 --- a/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx +++ b/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx @@ -16,14 +16,17 @@ import { Badge, Box, Grid, Skeleton, Typography } from "@wso2/oxygen-ui"; import { type JSX } from "react"; -import { StatCard } from "@components/updates/stat-card-row/StatCard"; import { UPDATES_STATS } from "@constants/updatesConstants"; import type { RecommendedUpdateLevelItem, UpdatesStats, } from "@models/responses"; - -const NULL_PLACEHOLDER = "--"; +import { + aggregateUpdateStats, + getStatTooltipText, + getStatValue, +} from "@utils/updates"; +import { StatCard } from "@components/updates/stat-card-row/StatCard"; export interface UpdatesStatsGridProps { data: RecommendedUpdateLevelItem[] | undefined; @@ -42,75 +45,7 @@ export function UpdatesStatsGrid({ isLoading, isError, }: UpdatesStatsGridProps): JSX.Element { - const aggregatedData: UpdatesStats | undefined = data - ? { - productsTracked: data.length, - totalUpdatesInstalled: data.reduce( - (acc, item) => - acc + - (item.installedUpdatesCount + item.installedSecurityUpdatesCount), - 0, - ), - totalUpdatesInstalledBreakdown: { - regular: data.reduce( - (acc, item) => acc + item.installedUpdatesCount, - 0, - ), - security: data.reduce( - (acc, item) => acc + item.installedSecurityUpdatesCount, - 0, - ), - }, - totalUpdatesPending: data.reduce( - (acc, item) => - acc + - (item.availableUpdatesCount + item.availableSecurityUpdatesCount), - 0, - ), - totalUpdatesPendingBreakdown: { - regular: data.reduce( - (acc, item) => acc + item.availableUpdatesCount, - 0, - ), - security: data.reduce( - (acc, item) => acc + item.availableSecurityUpdatesCount, - 0, - ), - }, - securityUpdatesPending: data.reduce( - (acc, item) => acc + item.availableSecurityUpdatesCount, - 0, - ), - } - : undefined; - - const getValue = (id: keyof UpdatesStats): string | number => { - if (!aggregatedData) return NULL_PLACEHOLDER; - const val = aggregatedData[id]; - if (val === null || val === undefined) return NULL_PLACEHOLDER; - if (typeof val === "object") return NULL_PLACEHOLDER; - return val as string | number; - }; - - const getTooltipText = (stat: (typeof UPDATES_STATS)[number]): string => { - if (!aggregatedData) return stat.tooltipText; - if ( - stat.id === "totalUpdatesInstalled" && - aggregatedData.totalUpdatesInstalledBreakdown - ) { - const { regular, security } = - aggregatedData.totalUpdatesInstalledBreakdown; - return `${stat.tooltipText} (${regular} Regular • ${security} Security)`; - } - if ( - stat.id === "totalUpdatesPending" && - aggregatedData.totalUpdatesPendingBreakdown - ) { - const { regular, security } = aggregatedData.totalUpdatesPendingBreakdown; - return `${stat.tooltipText} (${regular} Regular • ${security} Security)`; - } - return stat.tooltipText; - }; + const aggregatedData = aggregateUpdateStats(data); const renderCountWithSkeleton = ( count: number | undefined, @@ -215,7 +150,10 @@ export function UpdatesStatsGrid({ {UPDATES_STATS.map((stat) => { const Icon = stat.icon; - const value = getValue(stat.id as keyof UpdatesStats); + const value = getStatValue( + aggregatedData, + stat.id as keyof UpdatesStats, + ); return ( @@ -224,7 +162,7 @@ export function UpdatesStatsGrid({ value={value} icon={} iconColor={stat.iconColor} - tooltipText={getTooltipText(stat)} + tooltipText={getStatTooltipText(stat, aggregatedData)} isLoading={isEffectiveLoading} isError={isError} extraContent={renderExtraContent(stat)} From 3009d65c5bbb06fdbb8c75ffb4f69eb7d49dc260 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 18:08:11 +0530 Subject: [PATCH 21/22] Refactor tests: mock providers and API client Rewrite useGetRecommendedUpdateLevels unit tests to centralize and improve mocking. Switch imports to absolute paths, add mocks for logger, @asgardeo/react, MockConfigProvider, and AuthApiContext (mocking useAuthApiClient) instead of stubbing global fetch. Adjust QueryClient defaults (staleTime/gcTime), clear/reset mocks and query client in afterEach, and replace projectId-based hooks with parameterless calls. Update assertions to use Array.isArray, add explicit waitFor timeouts, simplify window.config handling, and rename/extend test timeouts; removed the empty-projectId test. --- .../useGetRecommendedUpdateLevels.test.tsx | 174 ++++++++---------- 1 file changed, 80 insertions(+), 94 deletions(-) diff --git a/apps/customer-portal/webapp/src/api/__tests__/useGetRecommendedUpdateLevels.test.tsx b/apps/customer-portal/webapp/src/api/__tests__/useGetRecommendedUpdateLevels.test.tsx index bc96e8b583..92a455471a 100644 --- a/apps/customer-portal/webapp/src/api/__tests__/useGetRecommendedUpdateLevels.test.tsx +++ b/apps/customer-portal/webapp/src/api/__tests__/useGetRecommendedUpdateLevels.test.tsx @@ -15,29 +15,29 @@ // under the License. import { renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useGetRecommendedUpdateLevels } from "@api/useGetRecommendedUpdateLevels"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; +// Mock logger const mockLogger = { debug: vi.fn(), error: vi.fn(), }; -vi.mock("@hooks/useLogger", () => ({ +vi.mock("@/hooks/useLogger", () => ({ useLogger: () => mockLogger, })); -vi.mock("@constants/apiConstants", async (importOriginal) => { - const actual = (await importOriginal()) as { - ApiQueryKeys: Record; - }; +vi.mock("@/constants/apiConstants", async (importOriginal) => { + const actual = (await importOriginal()) as any; return { ...actual, API_MOCK_DELAY: 0, }; }); +// Mock @asgardeo/react const mockGetIdToken = vi.fn().mockResolvedValue("mock-token"); vi.mock("@asgardeo/react", () => ({ useAsgardeo: () => ({ @@ -47,13 +47,20 @@ vi.mock("@asgardeo/react", () => ({ }), })); +// Mock MockConfigProvider let mockIsMockEnabled = true; -vi.mock("@providers/MockConfigProvider", () => ({ +vi.mock("@/providers/MockConfigProvider", () => ({ useMockConfig: () => ({ isMockEnabled: mockIsMockEnabled, }), })); +// Mock AuthApiContext - directly mock the hook useAuthApiClient +const mockFetchFn = vi.fn(); +vi.mock("@/context/AuthApiContext", () => ({ + useAuthApiClient: () => mockFetchFn, +})); + describe("useGetRecommendedUpdateLevels", () => { let queryClient: QueryClient; @@ -62,65 +69,65 @@ describe("useGetRecommendedUpdateLevels", () => { defaultOptions: { queries: { retry: false, + staleTime: 0, + gcTime: 0, }, }, }); mockLogger.debug.mockClear(); mockLogger.error.mockClear(); mockIsMockEnabled = true; + mockFetchFn.mockReset(); vi.clearAllMocks(); }); + afterEach(() => { + vi.unstubAllGlobals(); + queryClient.clear(); + }); + const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); it("should return loading state initially", async () => { - const { result } = renderHook( - () => useGetRecommendedUpdateLevels("project-1"), - { - wrapper, - }, - ); + const { result } = renderHook(() => useGetRecommendedUpdateLevels(), { + wrapper, + }); expect(result.current.isLoading).toBe(true); }); - it("should return mock data when isMockEnabled is true", async () => { + it("should return mock data when mock is enabled", async () => { mockIsMockEnabled = true; - const { result } = renderHook( - () => useGetRecommendedUpdateLevels("project-1"), - { - wrapper, - }, - ); + const { result } = renderHook(() => useGetRecommendedUpdateLevels(), { + wrapper, + }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true), { + timeout: 10000, + }); expect(result.current.data).toBeDefined(); - expect(result.current.data).toBeInstanceOf(Array); + expect(Array.isArray(result.current.data)).toBe(true); expect(mockLogger.debug).toHaveBeenCalledWith( - expect.stringContaining( - "Fetching recommended update levels for project ID: project-1, mock: true", - ), + expect.stringContaining("Fetching recommended update levels, mock: true"), ); - }); + }, 15000); it("should have correct query options", () => { - renderHook(() => useGetRecommendedUpdateLevels("project-1"), { + renderHook(() => useGetRecommendedUpdateLevels(), { wrapper, }); const query = queryClient.getQueryCache().findAll({ - queryKey: ["recommended-update-levels", "project-1", true], + queryKey: ["recommended-update-levels", true], })[0]; - expect((query?.options as { staleTime?: number }).staleTime).toBe( - 5 * 60 * 1000, - ); + expect((query?.options as any).staleTime).toBe(5 * 60 * 1000); }); - it("should fetch from API when isMockEnabled is false", async () => { + it("should fetch real data when mock is disabled", async () => { mockIsMockEnabled = false; const mockResponse = [ { @@ -138,91 +145,70 @@ describe("useGetRecommendedUpdateLevels", () => { }, ]; - const originalWindowConfig = ( - window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } - ).config; - ( - window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } - ).config = { - CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", + const originalConfig = (window as any).config; + (window as any).config = { + CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.test", }; - try { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - status: 200, - } as Response), - ); + mockFetchFn.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + status: 200, + } as Response); - const { result } = renderHook( - () => useGetRecommendedUpdateLevels("project-1"), - { - wrapper, + const { result } = renderHook(() => useGetRecommendedUpdateLevels(), { + wrapper, + }); + + try { + await waitFor( + () => { + if (result.current.isError) { + throw new Error(`Hook error: ${result.current.error?.message}`); + } + return expect(result.current.isSuccess).toBe(true); }, + { timeout: 10000 }, ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual(mockResponse); - expect(mockGetIdToken).toHaveBeenCalled(); expect(mockLogger.debug).toHaveBeenCalledWith( expect.stringContaining( - "Fetching recommended update levels for project ID: project-1, mock: false", + "Fetching recommended update levels, mock: false", ), ); } finally { - (window as { config?: unknown }).config = originalWindowConfig; + (window as any).config = originalConfig; } - }); + }, 15000); - it("should handle API error when isMockEnabled is false", async () => { + it("should handle API error when mock is disabled", async () => { mockIsMockEnabled = false; - - const originalWindowConfig = ( - window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } - ).config; - ( - window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } - ).config = { - CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", + const originalConfig = (window as any).config; + (window as any).config = { + CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.test", }; - try { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: false, - statusText: "Internal Server Error", - status: 500, - } as Response), - ); + mockFetchFn.mockResolvedValue({ + ok: false, + statusText: "Internal Server Error", + status: 500, + } as Response); - const { result } = renderHook( - () => useGetRecommendedUpdateLevels("project-1"), - { - wrapper, - }, - ); + const { result } = renderHook(() => useGetRecommendedUpdateLevels(), { + wrapper, + }); - await waitFor(() => expect(result.current.isError).toBe(true)); + try { + await waitFor(() => expect(result.current.isError).toBe(true), { + timeout: 10000, + }); expect(result.current.error?.message).toContain( "Error fetching recommended update levels: Internal Server Error", ); expect(mockLogger.error).toHaveBeenCalled(); } finally { - (window as { config?: unknown }).config = originalWindowConfig; + (window as any).config = originalConfig; } - }); - - it("should not fetch if projectId is empty", () => { - const { result } = renderHook(() => useGetRecommendedUpdateLevels(""), { - wrapper, - }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.fetchStatus).toBe("idle"); - }); + }, 15000); }); From f268375785e5825e58ad2d2fab55f98303f94b7b Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Mon, 16 Feb 2026 18:17:18 +0530 Subject: [PATCH 22/22] Move loading flag, update tests, and clean JSDoc Move the computed isEffectiveLoading flag earlier in UpdatesStatsGrid to avoid a duplicate declaration and make it available to render helpers. Update unit test to guard against undefined aggregate results (early return) before asserting fields to prevent runtime errors. Remove an outdated @param line from the JSDoc in useGetRecommendedUpdateLevels for minor documentation cleanup. --- .../src/api/useGetRecommendedUpdateLevels.ts | 1 - .../updates/stat-card-row/UpdatesStatsGrid.tsx | 3 +-- .../webapp/src/utils/__tests__/updates.test.ts | 18 ++++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts b/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts index 5c546dfde0..a25a4afea1 100644 --- a/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts +++ b/apps/customer-portal/webapp/src/api/useGetRecommendedUpdateLevels.ts @@ -26,7 +26,6 @@ import type { RecommendedUpdateLevelItem } from "@models/responses"; /** * Custom hook to fetch recommended update levels. * - * @param {string} projectId - The ID of the project. * @returns {UseQueryResult} The query result object. */ export function useGetRecommendedUpdateLevels(): UseQueryResult< diff --git a/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx b/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx index 3fb6502718..8191f42b6e 100644 --- a/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx +++ b/apps/customer-portal/webapp/src/components/updates/stat-card-row/UpdatesStatsGrid.tsx @@ -46,6 +46,7 @@ export function UpdatesStatsGrid({ isError, }: UpdatesStatsGridProps): JSX.Element { const aggregatedData = aggregateUpdateStats(data); + const isEffectiveLoading = isLoading || (!data && !isError); const renderCountWithSkeleton = ( count: number | undefined, @@ -140,8 +141,6 @@ export function UpdatesStatsGrid({ return undefined; }; - const isEffectiveLoading = isLoading || (!data && !isError); - return ( diff --git a/apps/customer-portal/webapp/src/utils/__tests__/updates.test.ts b/apps/customer-portal/webapp/src/utils/__tests__/updates.test.ts index 6c57ff57da..aaca7d0e8d 100644 --- a/apps/customer-portal/webapp/src/utils/__tests__/updates.test.ts +++ b/apps/customer-portal/webapp/src/utils/__tests__/updates.test.ts @@ -33,14 +33,16 @@ describe("updates utilities", () => { it("should correctly aggregate stats for a 20-product dataset", () => { const stats = aggregateUpdateStats(mockRecommendedUpdateLevels); expect(stats).toBeDefined(); - expect(stats?.productsTracked).toBe(20); - expect(stats?.totalUpdatesInstalled).toBe(4458); - expect(stats?.totalUpdatesPending).toBe(1534); - expect(stats?.securityUpdatesPending).toBe(429); - expect(stats?.totalUpdatesInstalledBreakdown!.regular).toBe(3667); - expect(stats?.totalUpdatesInstalledBreakdown!.security).toBe(791); - expect(stats?.totalUpdatesPendingBreakdown!.regular).toBe(1105); - expect(stats?.totalUpdatesPendingBreakdown!.security).toBe(429); + if (!stats) return; + + expect(stats.productsTracked).toBe(20); + expect(stats.totalUpdatesInstalled).toBe(4458); + expect(stats.totalUpdatesPending).toBe(1534); + expect(stats.securityUpdatesPending).toBe(429); + expect(stats.totalUpdatesInstalledBreakdown!.regular).toBe(3667); + expect(stats.totalUpdatesInstalledBreakdown!.security).toBe(791); + expect(stats.totalUpdatesPendingBreakdown!.regular).toBe(1105); + expect(stats.totalUpdatesPendingBreakdown!.security).toBe(429); }); });