diff --git a/apps/customer-portal/webapp/src/api/__tests__/useGetCaseComments.test.tsx b/apps/customer-portal/webapp/src/api/__tests__/useGetCaseComments.test.tsx new file mode 100644 index 000000000..942031a33 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/__tests__/useGetCaseComments.test.tsx @@ -0,0 +1,102 @@ +// 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import useGetCaseComments from "@api/useGetCaseComments"; +import { mockCaseComments } from "@models/mockData"; + +vi.mock("@constants/apiConstants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + API_MOCK_DELAY: 0, + }; +}); + +vi.mock("@asgardeo/react", () => ({ + useAsgardeo: () => ({ + getIdToken: vi.fn().mockResolvedValue("mock-token"), + isSignedIn: true, + isLoading: false, + }), +})); + +const mockUseMockConfig = vi.fn().mockReturnValue({ isMockEnabled: true }); + +vi.mock("@providers/MockConfigProvider", () => ({ + useMockConfig: () => mockUseMockConfig(), +})); + +vi.mock("@hooks/useLogger", () => ({ + useLogger: () => ({ debug: vi.fn(), error: vi.fn() }), +})); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useGetCaseComments", () => { + beforeEach(() => { + queryClient.clear(); + vi.clearAllMocks(); + mockUseMockConfig.mockReturnValue({ isMockEnabled: true }); + }); + + it("should return mock comments when isMockEnabled is true", async () => { + const { result } = renderHook( + () => useGetCaseComments("project-1", "case-001"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBeDefined(); + expect(result.current.data?.comments).toBeDefined(); + expect(result.current.data?.comments.length).toBe(mockCaseComments.length); + expect(result.current.data?.totalRecords).toBe(mockCaseComments.length); + expect(result.current.data?.offset).toBe(0); + expect(result.current.data?.limit).toBe(20); + }); + + it("should not fetch when projectId or caseId is missing", () => { + const { result: r1 } = renderHook( + () => useGetCaseComments("", "case-001"), + { wrapper }, + ); + expect(r1.current.isFetching).toBe(false); + + const { result: r2 } = renderHook( + () => useGetCaseComments("project-1", ""), + { wrapper }, + ); + expect(r2.current.isFetching).toBe(false); + }); + + it("should use correct query key", () => { + renderHook(() => useGetCaseComments("project-1", "case-001"), { wrapper }); + const query = queryClient.getQueryCache().findAll({ + queryKey: ["case-comments", "project-1", "case-001", 0, 20, true], + })[0]; + expect(query).toBeDefined(); + }); +}); diff --git a/apps/customer-portal/webapp/src/api/useGetCaseComments.ts b/apps/customer-portal/webapp/src/api/useGetCaseComments.ts new file mode 100644 index 000000000..cc5d4760e --- /dev/null +++ b/apps/customer-portal/webapp/src/api/useGetCaseComments.ts @@ -0,0 +1,111 @@ +// 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 { useMockConfig } from "@providers/MockConfigProvider"; +import { useLogger } from "@hooks/useLogger"; +import { mockCaseComments } from "@models/mockData"; +import { ApiQueryKeys, API_MOCK_DELAY } from "@constants/apiConstants"; +import { addApiHeaders } from "@utils/apiUtils"; +import type { CaseCommentsResponse } from "@models/responses"; + +export interface UseGetCaseCommentsOptions { + offset?: number; + limit?: number; +} + +/** + * Fetches comments for a case. + * + * @param {string} projectId - The project id. + * @param {string} caseId - The case id. + * @param {UseGetCaseCommentsOptions} options - Optional offset and limit for pagination. + * @returns {UseQueryResult} Query result with comments. + */ +export default function useGetCaseComments( + projectId: string, + caseId: string, + options?: UseGetCaseCommentsOptions, +): UseQueryResult { + const logger = useLogger(); + const { getIdToken, isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const { isMockEnabled } = useMockConfig(); + const { offset = 0, limit = 20 } = options ?? {}; + + return useQuery({ + queryKey: [ + ApiQueryKeys.CASE_COMMENTS, + projectId, + caseId, + offset, + limit, + isMockEnabled, + ], + queryFn: async (): Promise => { + logger.debug( + `Fetching case comments: projectId=${projectId}, caseId=${caseId}, mock=${isMockEnabled}`, + ); + + if (isMockEnabled) { + await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY)); + const sliced = mockCaseComments.slice(offset, offset + limit); + return { + comments: sliced.length > 0 ? sliced : mockCaseComments, + totalRecords: mockCaseComments.length, + offset, + limit, + }; + } + + try { + const baseUrl = window.config?.CUSTOMER_PORTAL_BACKEND_BASE_URL; + if (!baseUrl) { + throw new Error("CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured"); + } + + const idToken = await getIdToken(); + const params = new URLSearchParams(); + if (offset !== undefined) params.set("offset", String(offset)); + if (limit !== undefined) params.set("limit", String(limit)); + const query = params.toString(); + const requestUrl = `${baseUrl}/projects/${projectId}/cases/${caseId}/comments${query ? `?${query}` : ""}`; + const response = await fetch(requestUrl, { + method: "GET", + headers: addApiHeaders(idToken), + }); + + if (!response.ok) { + throw new Error( + `Error fetching case comments: ${response.status} ${response.statusText}`, + ); + } + + const data: CaseCommentsResponse = await response.json(); + logger.debug("[useGetCaseComments] Data received:", data); + return data; + } catch (error) { + logger.error("[useGetCaseComments] Error:", error); + throw error; + } + }, + enabled: + !!projectId && + !!caseId && + (isMockEnabled || (isSignedIn && !isAuthLoading)), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} diff --git a/apps/customer-portal/webapp/src/constants/apiConstants.ts b/apps/customer-portal/webapp/src/constants/apiConstants.ts index 8f284d435..6057f2a85 100644 --- a/apps/customer-portal/webapp/src/constants/apiConstants.ts +++ b/apps/customer-portal/webapp/src/constants/apiConstants.ts @@ -28,6 +28,7 @@ export const ApiQueryKeys = { PROJECT_STATS: "project-stats", PROJECT_CASES: "project-cases", CASE_DETAILS: "case-details", + CASE_COMMENTS: "case-comments", CHAT_HISTORY: "chat-history", DEPLOYMENTS: "deployments", TIME_TRACKING_STATS: "time-tracking-stats", diff --git a/apps/customer-portal/webapp/src/models/mockData.ts b/apps/customer-portal/webapp/src/models/mockData.ts index 5fbad698e..0b5c18870 100644 --- a/apps/customer-portal/webapp/src/models/mockData.ts +++ b/apps/customer-portal/webapp/src/models/mockData.ts @@ -17,6 +17,7 @@ import type { CaseListItem, CaseDetails, + CaseComment, ProjectListItem, ProjectDetails, UserDetails, @@ -932,6 +933,33 @@ export const mockCaseDetails: CaseDetails = { severity: { id: 10, label: "Critical (P1)" }, }; +// Mock case comments. +export const mockCaseComments: CaseComment[] = [ + { + id: "1398232c1bceb290a002c9d3604bcb27", + content: + "[code]

Test comment


test

[/code]", + type: "comments", + createdOn: "2025-12-23 14:49:58", + createdBy: "para-admin@wso2.com", + isEscalated: false, + }, + { + id: "712727a81bceb290a002c9d3604bcbcc", + content: "[code]

www

[/code]", + type: "comments", + createdOn: "2025-12-23 14:43:36", + createdBy: "para-admin@wso2.com", + isEscalated: false, + }, + { + id: "61f6a7a81bceb290a002c9d3604bcb53", + content: + "[code]
Description

Test Description

[/code]", + type: "comments", + createdOn: "2025-12-23 14:42:46", + createdBy: "para-admin@wso2.com", + isEscalated: false, // Mock deployments for project detail Deployments tab. export const mockDeployments: Deployment[] = [ { diff --git a/apps/customer-portal/webapp/src/models/responses.ts b/apps/customer-portal/webapp/src/models/responses.ts index 3b8f75e17..dd76289ef 100644 --- a/apps/customer-portal/webapp/src/models/responses.ts +++ b/apps/customer-portal/webapp/src/models/responses.ts @@ -213,6 +213,24 @@ export interface CaseDetails { severity: CaseStatus | null; } +// Case comment +export interface CaseComment { + id: string; + content: string; + type: string; + createdOn: string; + createdBy: string; + isEscalated: boolean; +} + +// Response for case comments list +export interface CaseCommentsResponse { + comments: CaseComment[]; + totalRecords: number; + offset: number; + limit: number; +} + // Project Stats Response export interface ProjectStatsResponse { projectStats: {