diff --git a/apps/customer-portal/webapp/src/api/useGetDeploymentDocuments.ts b/apps/customer-portal/webapp/src/api/useGetDeploymentDocuments.ts new file mode 100644 index 0000000000..8aaa396605 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/useGetDeploymentDocuments.ts @@ -0,0 +1,88 @@ +// 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 { useLogger } from "@hooks/useLogger"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import type { DeploymentDocument } from "@models/responses"; + +function normalizeDocuments(raw: unknown): DeploymentDocument[] { + if (Array.isArray(raw)) { + return raw as DeploymentDocument[]; + } + if (raw && typeof raw === "object" && "attachments" in raw) { + return (raw as { attachments: DeploymentDocument[] }).attachments ?? []; + } + if (raw && typeof raw === "object" && "documents" in raw) { + return (raw as { documents: DeploymentDocument[] }).documents ?? []; + } + return []; +} + +/** + * Fetches deployment documents (GET /deployments/:deploymentId/attachments). + * + * @param {string} deploymentId - The deployment ID. + * @returns {UseQueryResult} The query result. + */ +export function useGetDeploymentDocuments( + deploymentId: string, +): UseQueryResult { + const logger = useLogger(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useQuery({ + queryKey: [ApiQueryKeys.DEPLOYMENT_ATTACHMENTS, deploymentId], + queryFn: async (): Promise => { + logger.debug( + `Fetching deployment documents for deployment ID: ${deploymentId}`, + ); + + 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}/deployments/${deploymentId}/attachments`; + const response = await fetchFn(requestUrl, { method: "GET" }); + + logger.debug( + `[useGetDeploymentDocuments] Response status: ${response.status}`, + ); + + if (!response.ok) { + throw new Error( + `Error fetching deployment documents: ${response.statusText}`, + ); + } + + const raw = await response.json(); + const documents = normalizeDocuments(raw); + logger.debug("[useGetDeploymentDocuments] Data received:", documents); + return documents; + } catch (error) { + logger.error("[useGetDeploymentDocuments] Error:", error); + throw error; + } + }, + enabled: !!deploymentId && isSignedIn && !isAuthLoading, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/customer-portal/webapp/src/api/useGetProducts.ts b/apps/customer-portal/webapp/src/api/useGetProducts.ts new file mode 100644 index 0000000000..cb51426e4b --- /dev/null +++ b/apps/customer-portal/webapp/src/api/useGetProducts.ts @@ -0,0 +1,84 @@ +// 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 { useLogger } from "@hooks/useLogger"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import type { ProductItem } from "@models/responses"; + +function normalizeProducts(raw: unknown): ProductItem[] { + if (Array.isArray(raw)) { + return raw as ProductItem[]; + } + if (raw && typeof raw === "object" && "products" in raw) { + return (raw as { products: ProductItem[] }).products ?? []; + } + return []; +} + +/** + * Fetches products list (GET /products). + * + * @param {object} params - offset and limit. + * @returns {UseQueryResult} The query result. + */ +export function useGetProducts(params?: { + offset?: number; + limit?: number; +}): UseQueryResult { + const { offset = 0, limit = 10 } = params ?? {}; + const logger = useLogger(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useQuery({ + queryKey: [ApiQueryKeys.PRODUCTS, offset, limit], + queryFn: async (): Promise => { + logger.debug(`Fetching products offset=${offset} limit=${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 searchParams = new URLSearchParams(); + searchParams.set("offset", String(offset)); + searchParams.set("limit", String(limit)); + const requestUrl = `${baseUrl}/products?${searchParams.toString()}`; + const response = await fetchFn(requestUrl, { method: "GET" }); + + logger.debug(`[useGetProducts] Response status: ${response.status}`); + + if (!response.ok) { + throw new Error(`Error fetching products: ${response.statusText}`); + } + + const raw = await response.json(); + const products = normalizeProducts(raw); + logger.debug("[useGetProducts] Data received:", products); + return products; + } catch (error) { + logger.error("[useGetProducts] Error:", error); + throw error; + } + }, + enabled: isSignedIn && !isAuthLoading, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/customer-portal/webapp/src/api/usePatchDeployment.ts b/apps/customer-portal/webapp/src/api/usePatchDeployment.ts new file mode 100644 index 0000000000..c96c5c7a99 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/usePatchDeployment.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 { + useMutation, + useQueryClient, + type UseMutationResult, +} from "@tanstack/react-query"; +import { useAsgardeo } from "@asgardeo/react"; +import { useLogger } from "@hooks/useLogger"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import type { PatchDeploymentRequest } from "@models/requests"; + +export interface PatchDeploymentVariables { + projectId: string; + deploymentId: string; + body: PatchDeploymentRequest; +} + +/** + * Patches a deployment (PATCH /projects/:projectId/deployments/:deploymentId). + * On success, invalidates project-deployments and deployment queries. + * + * @returns {UseMutationResult} Mutation result. + */ +export function usePatchDeployment(): UseMutationResult< + void, + Error, + PatchDeploymentVariables +> { + const logger = useLogger(); + const queryClient = useQueryClient(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useMutation({ + mutationFn: async ({ + projectId, + deploymentId, + body, + }: PatchDeploymentVariables): Promise => { + try { + logger.debug("[usePatchDeployment] Request:", { + projectId, + deploymentId, + body, + }); + + if (!isSignedIn || isAuthLoading) { + throw new Error( + "User must be signed in to update a deployment", + ); + } + + 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}/projects/${projectId}/deployments/${deploymentId}`; + const response = await fetchFn(requestUrl, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + logger.debug( + "[usePatchDeployment] Response status:", + response.status, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Error updating deployment: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`, + ); + } + } catch (error) { + logger.error("[usePatchDeployment] Error:", error); + throw error; + } + }, + onSuccess: (_data, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: ["project-deployments", projectId], + }); + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.DEPLOYMENTS, projectId], + }); + }, + }); +} diff --git a/apps/customer-portal/webapp/src/api/usePatchDeploymentProduct.ts b/apps/customer-portal/webapp/src/api/usePatchDeploymentProduct.ts new file mode 100644 index 0000000000..30a749ae1e --- /dev/null +++ b/apps/customer-portal/webapp/src/api/usePatchDeploymentProduct.ts @@ -0,0 +1,104 @@ +// 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 { + useMutation, + useQueryClient, + type UseMutationResult, +} from "@tanstack/react-query"; +import { useAsgardeo } from "@asgardeo/react"; +import { useLogger } from "@hooks/useLogger"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import type { PatchDeploymentProductRequest } from "@models/requests"; + +export interface PatchDeploymentProductVariables { + deploymentId: string; + productId: string; + body: PatchDeploymentProductRequest; +} + +/** + * Patches a product in a deployment (PATCH /deployments/:deploymentId/products/:productId). + * On success, invalidates deployment-products queries. + * + * @returns {UseMutationResult} Mutation result. + */ +export function usePatchDeploymentProduct(): UseMutationResult< + void, + Error, + PatchDeploymentProductVariables +> { + const logger = useLogger(); + const queryClient = useQueryClient(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useMutation({ + mutationFn: async ({ + deploymentId, + productId, + body, + }: PatchDeploymentProductVariables): Promise => { + try { + logger.debug("[usePatchDeploymentProduct] Request:", { + deploymentId, + productId, + cores: body.cores, + tps: body.tps, + }); + + if (!isSignedIn || isAuthLoading) { + throw new Error( + "User must be signed in to update a deployment product", + ); + } + + 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}/deployments/${deploymentId}/products/${productId}`; + const response = await fetchFn(requestUrl, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + logger.debug( + "[usePatchDeploymentProduct] Response status:", + response.status, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Error updating deployment product: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`, + ); + } + } catch (error) { + logger.error("[usePatchDeploymentProduct] Error:", error); + throw error; + } + }, + onSuccess: (_data, { deploymentId }) => { + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.DEPLOYMENT_PRODUCTS, deploymentId], + }); + }, + }); +} diff --git a/apps/customer-portal/webapp/src/api/usePostDeploymentAttachment.ts b/apps/customer-portal/webapp/src/api/usePostDeploymentAttachment.ts new file mode 100644 index 0000000000..c84352698d --- /dev/null +++ b/apps/customer-portal/webapp/src/api/usePostDeploymentAttachment.ts @@ -0,0 +1,114 @@ +// 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 { + useMutation, + useQueryClient, + type UseMutationResult, +} from "@tanstack/react-query"; +import { useAsgardeo } from "@asgardeo/react"; +import { useLogger } from "@hooks/useLogger"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import type { PostDeploymentAttachmentRequest } from "@models/requests"; +import type { PostDeploymentAttachmentResponse } from "@models/responses"; + +export interface PostDeploymentAttachmentVariables { + deploymentId: string; + body: PostDeploymentAttachmentRequest; +} + +/** + * Posts an attachment to a deployment (POST /deployments/:deploymentId/attachments). + * On success, invalidates deployment-attachments and deployments queries. + * + * @returns {UseMutationResult} Mutation result. + */ +export function usePostDeploymentAttachment(): UseMutationResult< + PostDeploymentAttachmentResponse, + Error, + PostDeploymentAttachmentVariables +> { + const logger = useLogger(); + const queryClient = useQueryClient(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useMutation< + PostDeploymentAttachmentResponse, + Error, + PostDeploymentAttachmentVariables + >({ + mutationFn: async ({ + deploymentId, + body, + }: PostDeploymentAttachmentVariables): Promise => { + try { + logger.debug("[usePostDeploymentAttachment] Request:", { + deploymentId, + name: body.name, + type: body.type, + contentLength: body.content?.length ?? 0, + }); + + if (!isSignedIn || isAuthLoading) { + throw new Error( + "User must be signed in to upload a deployment document", + ); + } + + 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}/deployments/${deploymentId}/attachments`; + const response = await fetchFn(requestUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + logger.debug( + "[usePostDeploymentAttachment] Response status:", + response.status, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Error uploading deployment document: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`, + ); + } + + const data: PostDeploymentAttachmentResponse = await response.json(); + logger.debug("[usePostDeploymentAttachment] Data received:", data); + return data; + } catch (error) { + logger.error("[usePostDeploymentAttachment] Error:", error); + throw error; + } + }, + onSuccess: (_data, { deploymentId }) => { + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.DEPLOYMENT_ATTACHMENTS, deploymentId], + }); + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.DEPLOYMENTS], + }); + }, + }); +} diff --git a/apps/customer-portal/webapp/src/api/usePostDeploymentProduct.ts b/apps/customer-portal/webapp/src/api/usePostDeploymentProduct.ts new file mode 100644 index 0000000000..8602d190e0 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/usePostDeploymentProduct.ts @@ -0,0 +1,101 @@ +// 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 { + useMutation, + useQueryClient, + type UseMutationResult, +} from "@tanstack/react-query"; +import { useAsgardeo } from "@asgardeo/react"; +import { useLogger } from "@hooks/useLogger"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import type { PostDeploymentProductRequest } from "@models/requests"; + +export interface PostDeploymentProductVariables { + deploymentId: string; + body: PostDeploymentProductRequest; +} + +/** + * Posts a product to a deployment (POST /deployments/:deploymentId/products). + * On success, invalidates deployment-products queries. + * + * @returns {UseMutationResult} Mutation result. + */ +export function usePostDeploymentProduct(): UseMutationResult< + void, + Error, + PostDeploymentProductVariables +> { + const logger = useLogger(); + const queryClient = useQueryClient(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useMutation({ + mutationFn: async ({ + deploymentId, + body, + }: PostDeploymentProductVariables): Promise => { + try { + logger.debug("[usePostDeploymentProduct] Request:", { + deploymentId, + productId: body.productId, + versionId: body.versionId, + }); + + if (!isSignedIn || isAuthLoading) { + throw new Error( + "User must be signed in to add a product to deployment", + ); + } + + 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}/deployments/${deploymentId}/products`; + const response = await fetchFn(requestUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + logger.debug( + "[usePostDeploymentProduct] Response status:", + response.status, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Error adding product to deployment: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`, + ); + } + } catch (error) { + logger.error("[usePostDeploymentProduct] Error:", error); + throw error; + } + }, + onSuccess: (_data, { deploymentId }) => { + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.DEPLOYMENT_PRODUCTS, deploymentId], + }); + }, + }); +} diff --git a/apps/customer-portal/webapp/src/api/useSearchProductVersions.ts b/apps/customer-portal/webapp/src/api/useSearchProductVersions.ts new file mode 100644 index 0000000000..5dafcbec47 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/useSearchProductVersions.ts @@ -0,0 +1,90 @@ +// 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 { useLogger } from "@hooks/useLogger"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import type { + ProductVersionItem, + ProductVersionsSearchResponse, +} from "@models/responses"; +import type { ProductVersionsSearchRequest } from "@models/requests"; + +/** + * Fetches product versions (POST /products/:productId/versions/search). + * + * @param {string} productId - The product ID. + * @param {object} params - pagination (limit, offset). + * @returns {UseQueryResult} The query result. + */ +export function useSearchProductVersions( + productId: string, + params?: { limit?: number; offset?: number }, +): UseQueryResult { + const { limit = 10, offset = 0 } = params ?? {}; + const logger = useLogger(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useQuery({ + queryKey: [ApiQueryKeys.PRODUCT_VERSIONS_SEARCH, productId, limit, offset], + queryFn: async (): Promise => { + logger.debug( + `Searching versions for product ${productId}, offset=${offset} limit=${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 requestUrl = `${baseUrl}/products/${productId}/versions/search`; + const body: ProductVersionsSearchRequest = { + pagination: { limit, offset }, + }; + + const response = await fetchFn(requestUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + logger.debug( + `[useSearchProductVersions] Response status: ${response.status}`, + ); + + if (!response.ok) { + throw new Error( + `Error searching product versions: ${response.statusText}`, + ); + } + + const data: ProductVersionsSearchResponse = await response.json(); + const versions = data?.versions ?? []; + logger.debug("[useSearchProductVersions] Data received:", versions); + return versions; + } catch (error) { + logger.error("[useSearchProductVersions] Error:", error); + throw error; + } + }, + enabled: !!productId && isSignedIn && !isAuthLoading, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/customer-portal/webapp/src/api/useSearchProjectTimeCards.ts b/apps/customer-portal/webapp/src/api/useSearchProjectTimeCards.ts new file mode 100644 index 0000000000..83a3c658ac --- /dev/null +++ b/apps/customer-portal/webapp/src/api/useSearchProjectTimeCards.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 { useLogger } from "@hooks/useLogger"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import type { TimeCardSearchResponse } from "@models/responses"; +import type { TimeCardSearchRequest } from "@models/requests"; + +export interface UseSearchProjectTimeCardsParams { + projectId: string; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; +} + +/** + * Custom hook to search project time cards with date range filters. + * + * @param {UseSearchProjectTimeCardsParams} params - Project ID and filters. + * @returns {UseQueryResult} The query result object. + */ +export default function useSearchProjectTimeCards({ + projectId, + startDate, + endDate, + limit = 10, + offset = 0, +}: UseSearchProjectTimeCardsParams): UseQueryResult< + TimeCardSearchResponse, + Error +> { + const logger = useLogger(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useQuery({ + queryKey: [ + ApiQueryKeys.TIME_CARDS_SEARCH, + projectId, + startDate, + endDate, + limit, + offset, + ], + queryFn: async (): Promise => { + logger.debug( + `Searching time cards for project ID: ${projectId}, start: ${startDate}, end: ${endDate}`, + ); + + 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}/projects/${projectId}/time-cards/search`; + const body: TimeCardSearchRequest = { + filters: { + startDate, + endDate, + }, + pagination: { limit, offset }, + }; + + const response = await fetchFn(requestUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + logger.debug( + `[useSearchProjectTimeCards] Response status for ${projectId}: ${response.status}`, + ); + + if (!response.ok) { + throw new Error( + `Error searching time cards: ${response.statusText}`, + ); + } + + const data: TimeCardSearchResponse = await response.json(); + logger.debug("[useSearchProjectTimeCards] Data received:", data); + return data; + } catch (error) { + logger.error("[useSearchProjectTimeCards] Error:", error); + throw error; + } + }, + enabled: + !!projectId && !!startDate && !!endDate && isSignedIn && !isAuthLoading, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/AddProductModal.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/AddProductModal.tsx index 83858ee2c7..e9cd73ddae 100644 --- a/apps/customer-portal/webapp/src/components/project-details/deployments/AddProductModal.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/AddProductModal.tsx @@ -22,113 +22,131 @@ import { DialogActions, DialogContent, DialogTitle, - FormControl, IconButton, - InputLabel, MenuItem, - Select, TextField, Typography, } from "@wso2/oxygen-ui"; import { X } from "@wso2/oxygen-ui-icons-react"; -import { useCallback, useState, type ChangeEvent, type JSX } from "react"; -import type { SelectChangeEvent } from "@wso2/oxygen-ui"; +import { useCallback, useEffect, useState, type ChangeEvent, type JSX } from "react"; +import { useGetProducts } from "@api/useGetProducts"; +import { useSearchProductVersions } from "@api/useSearchProductVersions"; +import { usePostDeploymentProduct } from "@api/usePostDeploymentProduct"; export interface AddProductModalProps { open: boolean; deploymentId: string; + projectId: string; onClose: () => void; onSuccess?: () => void; onError?: (message: string) => void; } const INITIAL_FORM = { - name: "", - version: "", + productId: "", + versionId: "", cores: "", tps: "", - description: "", - supportStatus: "Active Support", - updateLevel: "", - releaseDate: "", - eolDate: "", }; -const PRODUCT_OPTIONS = [ - "API Manager", - "Identity Server", - "Micro Integrator", - "Streaming", -]; - -const SUPPORT_STATUS_OPTIONS = ["Active Support", "End of Life", "Deprecated"]; - /** * Modal for adding a WSO2 product to a deployment environment. + * Product Name and Version come from APIs; Description and Initial Update Info are disabled. * - * @param {AddProductModalProps} props - open, deploymentId, onClose, optional onSuccess/onError. + * @param {AddProductModalProps} props - open, deploymentId, projectId, onClose, optional onSuccess/onError. * @returns {JSX.Element} The add product modal. */ export default function AddProductModal({ open, deploymentId, + projectId, onClose, onSuccess, onError, }: AddProductModalProps): JSX.Element { const [form, setForm] = useState(INITIAL_FORM); - const [isSubmitting, setIsSubmitting] = useState(false); + const { data: products = [], isLoading: isLoadingProducts } = + useGetProducts({ offset: 0, limit: 10 }); + const { data: versions = [], isLoading: isLoadingVersions } = + useSearchProductVersions(form.productId, { limit: 10, offset: 0 }); + + const postProduct = usePostDeploymentProduct(); + + const isSubmitting = postProduct.isPending; const isValid = - form.name !== "" && - form.version.trim() !== "" && - Number(form.cores) > 0 && - Number(form.tps) >= 0 && - form.cores.trim() !== "" && - form.tps.trim() !== "" && - form.supportStatus !== "" && - form.updateLevel.trim() !== "" && - form.releaseDate !== "" && - form.eolDate !== ""; + !!form.productId && + !!form.versionId && + !!projectId && + deploymentId.length > 0; const handleClose = useCallback(() => { setForm(INITIAL_FORM); onClose(); }, [onClose]); - const handleTextChange = - (field: keyof typeof INITIAL_FORM) => - (event: ChangeEvent) => { - setForm((prev) => ({ ...prev, [field]: event.target.value })); - }; + useEffect(() => { + if (!open) { + setForm(INITIAL_FORM); + } + }, [open]); + + const handleProductChange = useCallback( + (event: ChangeEvent) => { + const productId = event.target.value; + setForm((prev) => ({ + ...prev, + productId, + versionId: "", + })); + }, + [], + ); + + const handleVersionChange = useCallback((event: ChangeEvent) => { + setForm((prev) => ({ ...prev, versionId: event.target.value })); + }, []); - const handleSelectChange = - (field: keyof typeof INITIAL_FORM) => - (event: SelectChangeEvent) => { + const handleTextChange = + (field: "cores" | "tps") => + (event: ChangeEvent) => { setForm((prev) => ({ ...prev, [field]: event.target.value })); }; const handleSubmit = useCallback(async () => { if (!isValid) return; - setIsSubmitting(true); - try { - // TODO: Integrate actual API hook here using deploymentId - - // Simulating API call - await new Promise((resolve) => setTimeout(resolve, 1000)); - + await postProduct.mutateAsync({ + deploymentId, + body: { + productId: form.productId, + versionId: form.versionId, + projectId, + cores: form.cores ? Number(form.cores) : undefined, + tps: form.tps ? Number(form.tps) : undefined, + }, + }); handleClose(); onSuccess?.(); } catch (error) { onError?.( error instanceof Error ? error.message : "Failed to add product", ); - } finally { - setIsSubmitting(false); } - }, [isValid, handleClose, onSuccess, onError, deploymentId]); + }, [ + isValid, + form.productId, + form.versionId, + form.cores, + form.tps, + deploymentId, + projectId, + postProduct, + handleClose, + onSuccess, + onError, + ]); return ( - - - - Product Name * - - + + + + Select + {products.map((p) => ( + + {p.label ?? p.name ?? p.id} + + ))} + + id="product-version" + label="Version *" + value={form.versionId} + onChange={handleVersionChange} + disabled={ + isSubmitting || !form.productId || isLoadingVersions + } + sx={{ + "& .MuiSelect-select": { + color: !form.versionId ? "text.secondary" : undefined, + }, + }} + > + Select + {versions.map((v) => ( + + {v.version} + + ))} + - - - - Support Status * - - - - - - - - - + Select + + + diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentCard.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentCard.tsx index d76ec55ee6..f1345ca36e 100644 --- a/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentCard.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentCard.tsx @@ -23,14 +23,21 @@ import { Box, Card, Divider, + IconButton, Link, Typography, } from "@wso2/oxygen-ui"; -import { Activity, Calendar, ChevronDown } from "@wso2/oxygen-ui-icons-react"; -import type { JSX } from "react"; +import { + Activity, + Calendar, + ChevronDown, + PencilLine, +} from "@wso2/oxygen-ui-icons-react"; +import { useState, type JSX } from "react"; import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; import DeploymentDocumentList from "./DeploymentDocumentList"; import DeploymentProductList from "./DeploymentProductList"; +import EditDeploymentModal from "./EditDeploymentModal"; export interface DeploymentCardProps { deployment: ProjectDeploymentItem; @@ -46,6 +53,8 @@ export default function DeploymentCard({ deployment, }: DeploymentCardProps): JSX.Element { const { name, url, description, createdOn } = deployment; + const projectId = deployment.project?.id ?? ""; + const [isEditModalOpen, setIsEditModalOpen] = useState(false); const deployedAtStr = formatProjectDate(createdOn); @@ -53,7 +62,7 @@ export default function DeploymentCard({ } sx={{ p: 3 }}> - + + { + e.stopPropagation(); + setIsEditModalOpen(true); + }} + sx={{ + color: "text.secondary", + "&:hover": { color: "primary.main" }, + "&.Mui-focusVisible": { color: "primary.main" }, + }} + > + + {url ? ( @@ -102,11 +128,14 @@ export default function DeploymentCard({ - + - + @@ -148,6 +177,14 @@ export default function DeploymentCard({ + + setIsEditModalOpen(false)} + onSuccess={() => setIsEditModalOpen(false)} + /> ); } diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentDocumentList.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentDocumentList.tsx index 99103f20bb..4aef775161 100644 --- a/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentDocumentList.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentDocumentList.tsx @@ -22,40 +22,80 @@ import { AccordionSummary, Box, Button, + CircularProgress, Typography, alpha, } from "@wso2/oxygen-ui"; -import { ChevronDown, Download, Trash2 } from "@wso2/oxygen-ui-icons-react"; -import type { JSX } from "react"; +import { ChevronDown, Download, Trash2, Upload } from "@wso2/oxygen-ui-icons-react"; +import { useState, type JSX } from "react"; import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; +import UploadAttachmentModal from "@case-details-attachments/UploadAttachmentModal"; +import { useGetDeploymentDocuments } from "@api/useGetDeploymentDocuments"; +import { useQueryClient } from "@tanstack/react-query"; +import { ApiQueryKeys } from "@constants/apiConstants"; interface DeploymentDocumentListProps { - documents?: DeploymentDocument[]; - hasError?: boolean; + deploymentId: string; } /** - * Renders the list of documents for a deployment. + * Renders the list of documents for a deployment with Add Document button. * - * @param {DeploymentDocumentListProps} props - Props containing documents list or hasError. + * @param {DeploymentDocumentListProps} props - Props containing deploymentId. * @returns {JSX.Element} The document list component. */ export default function DeploymentDocumentList({ - documents = [], - hasError = false, + deploymentId, }: DeploymentDocumentListProps): JSX.Element { + const queryClient = useQueryClient(); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const { + data: documents = [], + isLoading, + isError: hasError, + } = useGetDeploymentDocuments(deploymentId); + + const handleAddSuccess = () => { + setIsAddModalOpen(false); + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.DEPLOYMENT_ATTACHMENTS, deploymentId], + }); + }; + return ( }> - Documents {hasError ? "(?)" : `(${documents.length})`} + Documents {hasError ? "(?)" : isLoading ? "…" : `(${documents.length})`} - {hasError ? ( + + + + + {isLoading ? ( + + + + ) : hasError ? ( @@ -75,6 +115,13 @@ export default function DeploymentDocumentList({ )} + + setIsAddModalOpen(false)} + onSuccess={handleAddSuccess} + /> ); } @@ -84,10 +131,11 @@ interface DocumentRowProps { } function DocumentRow({ doc }: DocumentRowProps): JSX.Element { - const sizeStr = formatBytes(doc.sizeBytes); - const dateStr = formatProjectDate(doc.uploadedAt); + const sizeBytes = doc.sizeBytes ?? doc.size ?? 0; + const dateStr = formatProjectDate(doc.uploadedAt ?? doc.createdOn ?? ""); const name = displayValue(doc.name); - const uploadedBy = displayValue(doc.uploadedBy); + const uploadedBy = displayValue(doc.uploadedBy ?? doc.createdBy); + const sizeStr = formatBytes(sizeBytes); return ( - - + ) : ( + + )} + diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentProductList.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentProductList.tsx index 40aa026f96..f32ea8bdd2 100644 --- a/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentProductList.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/DeploymentProductList.tsx @@ -22,6 +22,7 @@ import { Checkbox, Chip, CircularProgress, + IconButton, Typography, alpha, } from "@wso2/oxygen-ui"; @@ -30,6 +31,7 @@ import { CircleAlert, Cpu, Package, + PencilLine, Plus, Trash2, Zap, @@ -40,9 +42,11 @@ import { useGetDeploymentsProducts } from "@api/useGetDeploymentsProducts"; import { ApiQueryKeys } from "@constants/apiConstants"; import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; import AddProductModal from "@components/project-details/deployments/AddProductModal"; +import ManageProductModal from "@components/project-details/deployments/ManageProductModal"; interface DeploymentProductListProps { deploymentId: string; + projectId: string; } /** @@ -53,9 +57,12 @@ interface DeploymentProductListProps { */ export default function DeploymentProductList({ deploymentId, + projectId, }: DeploymentProductListProps): JSX.Element { const queryClient = useQueryClient(); const [isAddProductModalOpen, setIsAddProductModalOpen] = useState(false); + const [editingProduct, setEditingProduct] = + useState(null); const { data: products = [], isLoading, isError } = useGetDeploymentsProducts(deploymentId); @@ -100,7 +107,13 @@ export default function DeploymentProductList({ ) : ( {products.map((item) => ( - + setEditingProduct(item)} + /> ))} )} @@ -108,6 +121,7 @@ export default function DeploymentProductList({ setIsAddProductModalOpen(false)} onSuccess={() => { setIsAddProductModalOpen(false); @@ -116,15 +130,34 @@ export default function DeploymentProductList({ }); }} /> + setEditingProduct(null)} + onSuccess={() => { + setEditingProduct(null); + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.DEPLOYMENT_PRODUCTS, deploymentId], + }); + }} + /> ); } interface ProductItemRowProps { item: DeploymentProductItem; + deploymentId: string; + isEditing: boolean; + onEdit: () => void; } -function ProductItemRow({ item }: ProductItemRowProps): JSX.Element { +function ProductItemRow({ + item, + isEditing, + onEdit, +}: ProductItemRowProps): JSX.Element { const name = displayValue(item.product?.label); const version = displayValue(item.version); const description = displayValue(item.description); @@ -167,6 +200,7 @@ function ProductItemRow({ item }: ProductItemRowProps): JSX.Element { > - + + + + + + + + ); diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/EditDeploymentModal.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/EditDeploymentModal.tsx new file mode 100644 index 0000000000..aed400fd5a --- /dev/null +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/EditDeploymentModal.tsx @@ -0,0 +1,291 @@ +// 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 type { ProjectDeploymentItem } from "@models/responses"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + MenuItem, + Skeleton, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { X } from "@wso2/oxygen-ui-icons-react"; +import { + useCallback, + useEffect, + useState, + type ChangeEvent, + type JSX, +} from "react"; +import useGetCasesFilters from "@api/useGetCasesFilters"; +import { usePatchDeployment } from "@api/usePatchDeployment"; + +export interface EditDeploymentModalProps { + open: boolean; + deployment: ProjectDeploymentItem | null; + projectId: string; + onClose: () => void; + onSuccess?: () => void; + onError?: (message: string) => void; +} + +const INITIAL_FORM = { + name: "", + typeKey: "", + description: "", +}; + +/** + * Modal for editing a deployment (name, type, description). Always sends active: true. + * Deployment type options come from useGetCasesFilters. + * + * @param {EditDeploymentModalProps} props - open, deployment, projectId, onClose, optional onSuccess/onError. + * @returns {JSX.Element} The edit deployment modal. + */ +export default function EditDeploymentModal({ + open, + deployment, + projectId, + onClose, + onSuccess, + onError, +}: EditDeploymentModalProps): JSX.Element { + const { data: filtersData, isLoading: isFiltersLoading } = + useGetCasesFilters(projectId); + const patchDeployment = usePatchDeployment(); + + const deploymentTypes = filtersData?.deploymentTypes ?? []; + + const [form, setForm] = useState(INITIAL_FORM); + + const isSubmitting = patchDeployment.isPending; + const isValid = + form.name.trim() !== "" && + form.typeKey !== "" && + !!projectId && + !!deployment?.id; + + const handleClose = useCallback(() => { + setForm(INITIAL_FORM); + onClose(); + }, [onClose]); + + useEffect(() => { + if (!open) { + setForm(INITIAL_FORM); + } + }, [open]); + + useEffect(() => { + if (open && deployment) { + setForm({ + name: deployment.name ?? "", + typeKey: deployment.type?.id ?? "", + description: deployment.description ?? "", + }); + } + }, [open, deployment]); + + const handleTextChange = + (field: "name" | "description") => + (event: ChangeEvent) => { + setForm((prev) => ({ ...prev, [field]: event.target.value })); + }; + + const handleTypeChange = useCallback( + (event: ChangeEvent) => { + setForm((prev) => ({ ...prev, typeKey: event.target.value })); + }, + [], + ); + + const handleSubmit = useCallback(async () => { + if (!isValid || !deployment) return; + + try { + await patchDeployment.mutateAsync({ + projectId, + deploymentId: deployment.id, + body: { + name: form.name.trim(), + description: form.description.trim(), + typeKey: Number(form.typeKey), + active: true, + }, + }); + handleClose(); + onSuccess?.(); + } catch (error) { + onError?.( + error instanceof Error ? error.message : "Failed to update deployment", + ); + } + }, [ + isValid, + deployment, + form.name, + form.description, + form.typeKey, + projectId, + patchDeployment, + handleClose, + onSuccess, + onError, + ]); + + return ( + + + Edit Deployment + + Update deployment name, type, and description. + + + + + + + + + + + + {isFiltersLoading ? ( + + ) : ( + + + Select + {deploymentTypes.map(({ id, label }) => ( + + {label} + + ))} + + + )} + + + + + + + {isSubmitting ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/ManageProductModal.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/ManageProductModal.tsx new file mode 100644 index 0000000000..a48473f922 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/ManageProductModal.tsx @@ -0,0 +1,252 @@ +// 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, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Tabs, + Tab, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { X } from "@wso2/oxygen-ui-icons-react"; +import { useCallback, useEffect, useState, type ChangeEvent, type JSX } from "react"; +import { usePatchDeploymentProduct } from "@api/usePatchDeploymentProduct"; +import type { DeploymentProductItem } from "@models/responses"; + +export interface ManageProductModalProps { + open: boolean; + deploymentId: string; + product: DeploymentProductItem | null; + onClose: () => void; + onSuccess?: () => void; + onError?: (message: string) => void; +} + +/** + * Modal for managing (editing) a deployment product. + * Product Details tab: Core Count and TPS editable; Description disabled. + * Update History tab disabled. + * + * @param {ManageProductModalProps} props - open, deploymentId, product, onClose, optional onSuccess/onError. + * @returns {JSX.Element | null} The manage product modal or null when product is missing. + */ +export default function ManageProductModal({ + open, + deploymentId, + product, + onClose, + onSuccess, + onError, +}: ManageProductModalProps): JSX.Element | null { + const [tabValue, setTabValue] = useState(0); + const [cores, setCores] = useState(""); + const [tps, setTps] = useState(""); + + const patchProduct = usePatchDeploymentProduct(); + const isSubmitting = patchProduct.isPending; + + useEffect(() => { + if (open && product) { + setCores( + typeof product.cores === "number" ? String(product.cores) : "", + ); + setTps(typeof product.tps === "number" ? String(product.tps) : ""); + setTabValue(0); + } + }, [open, product]); + + const handleClose = useCallback(() => { + onClose(); + }, [onClose]); + + const handleTabChange = useCallback((_e: unknown, v: number) => { + if (v === 0) setTabValue(0); + // Tab 1 (Update History) is disabled, do not switch + }, []); + + const handleCoresChange = useCallback((e: ChangeEvent) => { + setCores(e.target.value); + }, []); + + const handleTpsChange = useCallback((e: ChangeEvent) => { + setTps(e.target.value); + }, []); + + const handleSave = useCallback(async () => { + if (!product?.id) return; + + try { + await patchProduct.mutateAsync({ + deploymentId, + productId: product.id, + body: { + cores: cores.trim() ? Number(cores) : undefined, + tps: tps.trim() ? Number(tps) : undefined, + }, + }); + handleClose(); + onSuccess?.(); + } catch (error) { + onError?.( + error instanceof Error ? error.message : "Failed to update product", + ); + } + }, [ + product?.id, + deploymentId, + cores, + tps, + patchProduct, + handleClose, + onSuccess, + onError, + ]); + + if (!product) return null; + + const description = product.description ?? ""; + + return ( + + + Manage Product + + Update product details and manage update history + + + + + + + + + + + + + {tabValue === 0 && ( + + + + + + + + )} + + + + + {isSubmitting ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/AddProductModal.test.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/AddProductModal.test.tsx index 628ecabf8a..7849979667 100644 --- a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/AddProductModal.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/AddProductModal.test.tsx @@ -14,23 +14,73 @@ // specific language governing permissions and limitations // under the License. -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import AddProductModal from "@components/project-details/deployments/AddProductModal"; +import { useGetProducts } from "@api/useGetProducts"; +import { useSearchProductVersions } from "@api/useSearchProductVersions"; +import { usePostDeploymentProduct } from "@api/usePostDeploymentProduct"; + +vi.mock("@api/useGetProducts"); +vi.mock("@api/useSearchProductVersions"); +vi.mock("@api/usePostDeploymentProduct"); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +function renderWithProviders( + ui: React.ReactElement, + options?: { queryClient?: QueryClient }, +) { + const client = options?.queryClient ?? queryClient; + return render(ui, { + wrapper: ({ children }) => ( + {children} + ), + }); +} + +const mockProducts = [ + { id: "prod-api-mgr", label: "WSO2 API Manager", name: "API Manager" }, + { id: "prod-id", label: "WSO2 Identity Server", name: "Identity Server" }, +]; + +const mockVersions = [ + { id: "ver-781", version: "7.8.0", product: { id: "prod-api-mgr", label: "WSO2 API Manager" } }, + { id: "ver-450", version: "4.5.0", product: { id: "prod-api-mgr", label: "WSO2 API Manager" } }, +]; describe("AddProductModal", () => { const mockOnClose = vi.fn(); const mockOnSuccess = vi.fn(); + const mockMutateAsync = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + vi.mocked(useGetProducts).mockReturnValue({ + data: mockProducts, + isLoading: false, + isError: false, + } as unknown as ReturnType); + vi.mocked(useSearchProductVersions).mockReturnValue({ + data: mockVersions, + isLoading: false, + isError: false, + } as unknown as ReturnType); + vi.mocked(usePostDeploymentProduct).mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + } as unknown as ReturnType); }); it("should not render when open is false", () => { - render( + renderWithProviders( , ); @@ -38,10 +88,11 @@ describe("AddProductModal", () => { }); it("should render correctly when open is true", () => { - render( + renderWithProviders( , ); @@ -52,109 +103,161 @@ describe("AddProductModal", () => { expect(screen.getByLabelText(/Version/)).toBeInTheDocument(); expect(screen.getByLabelText(/Core Count/)).toBeInTheDocument(); expect(screen.getByLabelText(/TPS/)).toBeInTheDocument(); - expect(screen.getByLabelText(/Support Status/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Description/)).toBeInTheDocument(); + expect(screen.getByText("Initial Update Information")).toBeInTheDocument(); + }); + + it("should disable version dropdown until product is selected", () => { + renderWithProviders( + , + ); + + const versionSelect = screen.getByLabelText(/Version/); + expect(versionSelect).toHaveAttribute("aria-disabled", "true"); }); it("should validate required fields", () => { - render( + renderWithProviders( , ); const submitButton = screen.getByRole("button", { name: "Add Product" }); expect(submitButton).toBeDisabled(); + }); + + it("should enable submit and call API when product and version selected", async () => { + mockMutateAsync.mockResolvedValue(undefined); + renderWithProviders( + , + ); + + const productSelect = screen.getByLabelText(/Product Name/); + fireEvent.mouseDown(productSelect); + fireEvent.click(screen.getByText("WSO2 API Manager")); - // Fill some fields but not all - fireEvent.change(screen.getByLabelText(/Version/), { - target: { value: "4.2.0" }, + const versionSelect = screen.getByLabelText(/Version/); + fireEvent.mouseDown(versionSelect); + fireEvent.click(screen.getByText("7.8.0")); + + const submitButton = screen.getByRole("button", { name: "Add Product" }); + await waitFor(() => expect(submitButton).not.toBeDisabled()); + + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + deploymentId: "dep-1", + body: { + productId: "prod-api-mgr", + versionId: "ver-781", + projectId: "proj-1", + cores: undefined, + tps: undefined, + }, + }); }); - expect(submitButton).toBeDisabled(); + expect(mockOnSuccess).toHaveBeenCalled(); }); - it("should enable submit button when form is valid and call onSuccess on submit", async () => { - render( + it("should include cores and tps when provided", async () => { + mockMutateAsync.mockResolvedValue(undefined); + renderWithProviders( , ); - // For Material UI Select, we need to click the select to open options, then click an option. - const nameSelect = screen.getByLabelText(/Product Name/); - fireEvent.mouseDown(nameSelect); - const apiManagerOption = screen.getByText("API Manager"); - fireEvent.click(apiManagerOption); + fireEvent.mouseDown(screen.getByLabelText(/Product Name/)); + fireEvent.click(screen.getByText("WSO2 API Manager")); + + fireEvent.mouseDown(screen.getByLabelText(/Version/)); + fireEvent.click(screen.getByText("7.8.0")); - fireEvent.change(screen.getByLabelText(/Version/), { - target: { value: "4.2.0" }, - }); fireEvent.change(screen.getByLabelText(/Core Count/), { target: { value: "8" }, }); fireEvent.change(screen.getByLabelText(/TPS/), { target: { value: "5000" }, }); - fireEvent.change(screen.getByLabelText(/Current Update Level/), { - target: { value: "U10" }, - }); - fireEvent.change(screen.getByLabelText(/Release Date/), { - target: { value: "2023-01-01" }, - }); - fireEvent.change(screen.getByLabelText(/Support EOL Date/), { - target: { value: "2026-01-01" }, - }); - - // Support Status has default value "Active Support", so it should be fine. const submitButton = screen.getByRole("button", { name: "Add Product" }); - expect(submitButton).not.toBeDisabled(); - - vi.useFakeTimers(); fireEvent.click(submitButton); - // Advance timers to trigger the setTimeout callback - await vi.advanceTimersByTimeAsync(1000); - - expect(mockOnSuccess).toHaveBeenCalled(); - - vi.useRealTimers(); + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + deploymentId: "dep-1", + body: { + productId: "prod-api-mgr", + versionId: "ver-781", + projectId: "proj-1", + cores: 8, + tps: 5000, + }, + }); + }); }); - it("should reset form on close", async () => { - const { rerender } = render( + it("should reset form on close", () => { + const { rerender } = renderWithProviders( , ); - const versionInput = screen.getByLabelText(/Version/); - fireEvent.change(versionInput, { - target: { value: "4.2.0" }, + const productCombobox = screen.getByRole("combobox", { + name: /Product Name/, }); - expect(versionInput).toHaveValue("4.2.0"); - - const closeButton = screen.getByRole("button", { name: "Cancel" }); - fireEvent.click(closeButton); + fireEvent.mouseDown(productCombobox); + fireEvent.click(screen.getByText("WSO2 API Manager")); + expect(productCombobox).toHaveTextContent(/WSO2 API Manager/); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(mockOnClose).toHaveBeenCalled(); - // Re-render to check if reset + // Simulate parent closing and reopening modal; form should be reset. + rerender( + , + ); rerender( , ); - // Version should be empty (initial value) - expect(screen.getByLabelText(/Version/)).toHaveValue(""); + // After reset, Product Name should be cleared (empty selection) + expect( + screen.getByRole("combobox", { name: /Product Name/ }), + ).not.toHaveTextContent("WSO2 API Manager"); }); }); diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentCard.test.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentCard.test.tsx index 8c55e615b3..d48c8cc7ae 100644 --- a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentCard.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentCard.test.tsx @@ -15,7 +15,7 @@ // under the License. import type { ReactElement } from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import DeploymentCard from "@components/project-details/deployments/DeploymentCard"; @@ -40,6 +40,45 @@ vi.mock("@api/useGetDeploymentsProducts", () => ({ }), })); +vi.mock("@api/useGetProducts", () => ({ + useGetProducts: () => ({ data: [], isLoading: false, isError: false }), +})); +vi.mock("@api/useSearchProductVersions", () => ({ + useSearchProductVersions: () => ({ + data: [], + isLoading: false, + isError: false, + }), +})); +vi.mock("@api/usePostDeploymentProduct", () => ({ + usePostDeploymentProduct: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); +vi.mock("@api/usePatchDeploymentProduct", () => ({ + usePatchDeploymentProduct: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +vi.mock("@api/useGetDeploymentDocuments", () => ({ + useGetDeploymentDocuments: () => ({ + data: [], + isLoading: false, + isError: false, + }), +})); + +vi.mock("@case-details-attachments/UploadAttachmentModal", () => ({ + default: () => null, +})); + +vi.mock("@components/project-details/deployments/EditDeploymentModal", () => ({ + default: () => null, +})); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); function renderWithProviders(ui: ReactElement) { @@ -69,11 +108,18 @@ describe("DeploymentCard", () => { expect(screen.getByText("No products added yet")).toBeInTheDocument(); }); - it("should render documents section with error state", () => { + it("should render documents section with Upload button", () => { renderWithProviders(); - expect(screen.getByText("Documents (?)")).toBeInTheDocument(); - expect(screen.getByText("Failed to load documents")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Production/ })); + fireEvent.click(screen.getByText(/Documents/)); + expect(screen.getByRole("button", { name: /Upload/ })).toBeInTheDocument(); + }); + + it("should render documents section", () => { + renderWithProviders(); + + expect(screen.getByText("Documents (0)")).toBeInTheDocument(); }); it("should display -- for null description", () => { @@ -86,4 +132,12 @@ describe("DeploymentCard", () => { expect(screen.getByText("--")).toBeInTheDocument(); }); + + it("should display Edit deployment button", () => { + renderWithProviders(); + + expect( + screen.getByRole("button", { name: "Edit deployment" }), + ).toBeInTheDocument(); + }); }); diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentDocumentList.test.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentDocumentList.test.tsx index e92195806b..d328b58009 100644 --- a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentDocumentList.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentDocumentList.test.tsx @@ -14,8 +14,10 @@ // specific language governing permissions and limitations // under the License. -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; +import type { ReactElement } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import DeploymentDocumentList from "@components/project-details/deployments/DeploymentDocumentList"; import type { DeploymentDocument } from "@models/responses"; @@ -38,15 +40,68 @@ const mockDocuments: DeploymentDocument[] = [ }, ]; +vi.mock("@api/useGetDeploymentDocuments", () => ({ + useGetDeploymentDocuments: (deploymentId: string) => { + if (deploymentId === "error-id") { + return { + data: undefined, + isLoading: false, + isError: true, + }; + } + if (deploymentId === "loading-id") { + return { + data: undefined, + isLoading: true, + isError: false, + }; + } + if (deploymentId === "empty-id") { + return { + data: [], + isLoading: false, + isError: false, + }; + } + if (deploymentId === "single-id") { + return { + data: [mockDocuments[0]], + isLoading: false, + isError: false, + }; + } + return { + data: mockDocuments, + isLoading: false, + isError: false, + }; + }, +})); + +vi.mock("@case-details-attachments/UploadAttachmentModal", () => ({ + default: ({ open }: { open: boolean }) => + open ?
Upload Modal
: null, +})); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +function renderWithProviders(ui: ReactElement) { + return render( + {ui}, + ); +} + describe("DeploymentDocumentList", () => { it("should render documents count in accordion summary", () => { - render(); + renderWithProviders(); expect(screen.getByText("Documents (2)")).toBeInTheDocument(); }); it("should render document list with names, sizes, dates, and uploaders", () => { - render(); + renderWithProviders(); expect(screen.getByText("Architecture.pdf")).toBeInTheDocument(); expect(screen.getByText("Deployment-Guide.pdf")).toBeInTheDocument(); @@ -54,44 +109,54 @@ describe("DeploymentDocumentList", () => { expect(screen.getByText(/Jane Smith/)).toBeInTheDocument(); }); + it("should display Upload button", () => { + renderWithProviders(); + + fireEvent.click(screen.getByText(/Documents/)); + expect(screen.getByRole("button", { name: /Upload/ })).toBeInTheDocument(); + }); + it("should display 'No documents uploaded' when documents array is empty", () => { - render(); + renderWithProviders(); expect(screen.getByText("Documents (0)")).toBeInTheDocument(); expect(screen.getByText("No documents uploaded")).toBeInTheDocument(); }); it("should render download and delete icons for each document", () => { - const { container } = render( - , + const { container } = renderWithProviders( + , ); - // Each document should have 2 icon buttons (download and delete) const iconButtons = container.querySelectorAll("button"); - expect(iconButtons.length).toBeGreaterThanOrEqual(4); // 2 documents × 2 buttons + expect(iconButtons.length).toBeGreaterThanOrEqual(4); }); it("should properly format file sizes", () => { - render(); + renderWithProviders(); - // formatBytes should convert bytes to readable format like "1 MB" const sizeElements = screen.getAllByText(/MB|KB|GB/); expect(sizeElements.length).toBeGreaterThan(0); }); it("should render single document correctly", () => { - const singleDoc: DeploymentDocument[] = [mockDocuments[0]]; - render(); + renderWithProviders(); expect(screen.getByText("Documents (1)")).toBeInTheDocument(); expect(screen.getByText("Architecture.pdf")).toBeInTheDocument(); }); - it("should show error state when hasError is true", () => { - render(); + it("should show error state when fetch fails", () => { + renderWithProviders(); expect(screen.getByText("Documents (?)")).toBeInTheDocument(); expect(screen.getByText("Failed to load documents")).toBeInTheDocument(); - expect(screen.getByTestId("error-indicator")).toBeInTheDocument(); + }); + + it("should show loading state while fetching documents", () => { + renderWithProviders(); + + fireEvent.click(screen.getByText(/Documents/)); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); }); }); diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentProductList.test.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentProductList.test.tsx index c89d929c00..0d0e469531 100644 --- a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentProductList.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/DeploymentProductList.test.tsx @@ -15,14 +15,22 @@ // under the License. import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ReactElement } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import DeploymentProductList from "@components/project-details/deployments/DeploymentProductList"; import { useGetDeploymentsProducts } from "@api/useGetDeploymentsProducts"; +import { useGetProducts } from "@api/useGetProducts"; +import { useSearchProductVersions } from "@api/useSearchProductVersions"; +import { usePostDeploymentProduct } from "@api/usePostDeploymentProduct"; +import { usePatchDeploymentProduct } from "@api/usePatchDeploymentProduct"; import type { DeploymentProductItem } from "@models/responses"; vi.mock("@api/useGetDeploymentsProducts"); +vi.mock("@api/useGetProducts"); +vi.mock("@api/useSearchProductVersions"); +vi.mock("@api/usePostDeploymentProduct"); +vi.mock("@api/usePatchDeploymentProduct"); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -52,6 +60,27 @@ const mockProducts: DeploymentProductItem[] = [ ]; describe("DeploymentProductList", () => { + beforeEach(() => { + vi.mocked(useGetProducts).mockReturnValue({ + data: [], + isLoading: false, + isError: false, + } as unknown as ReturnType); + vi.mocked(useSearchProductVersions).mockReturnValue({ + data: [], + isLoading: false, + isError: false, + } as unknown as ReturnType); + vi.mocked(usePostDeploymentProduct).mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + } as unknown as ReturnType); + vi.mocked(usePatchDeploymentProduct).mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + } as unknown as ReturnType); + }); + it("should render products count and Add Product button", () => { vi.mocked(useGetDeploymentsProducts).mockReturnValue({ data: mockProducts, @@ -59,7 +88,9 @@ describe("DeploymentProductList", () => { isError: false, } as unknown as ReturnType); - renderWithProviders(); + renderWithProviders( + , + ); expect(screen.getByText("WSO2 Products (2)")).toBeInTheDocument(); expect( @@ -74,7 +105,9 @@ describe("DeploymentProductList", () => { isError: false, } as unknown as ReturnType); - renderWithProviders(); + renderWithProviders( + , + ); expect(screen.getByText("WSO2 API Manager")).toBeInTheDocument(); expect(screen.getByText("API Gateway")).toBeInTheDocument(); @@ -89,7 +122,9 @@ describe("DeploymentProductList", () => { isError: false, } as unknown as ReturnType); - renderWithProviders(); + renderWithProviders( + , + ); expect(screen.getByText("WSO2 Products (0)")).toBeInTheDocument(); expect(screen.getByText("No products added yet")).toBeInTheDocument(); @@ -111,7 +146,9 @@ describe("DeploymentProductList", () => { isError: false, } as unknown as ReturnType); - renderWithProviders(); + renderWithProviders( + , + ); expect(screen.getAllByText("--").length).toBeGreaterThan(0); }); @@ -123,7 +160,9 @@ describe("DeploymentProductList", () => { isError: false, } as unknown as ReturnType); - renderWithProviders(); + renderWithProviders( + , + ); expect(screen.getByRole("progressbar")).toBeInTheDocument(); }); @@ -135,7 +174,9 @@ describe("DeploymentProductList", () => { isError: true, } as unknown as ReturnType); - renderWithProviders(); + renderWithProviders( + , + ); expect(screen.getByText("Failed to load products")).toBeInTheDocument(); }); @@ -147,7 +188,9 @@ describe("DeploymentProductList", () => { isError: false, } as unknown as ReturnType); - renderWithProviders(); + renderWithProviders( + , + ); const addButton = screen.getByRole("button", { name: /Add Product/i }); fireEvent.click(addButton); diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/EditDeploymentModal.test.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/EditDeploymentModal.test.tsx new file mode 100644 index 0000000000..fba2d1b8cc --- /dev/null +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/EditDeploymentModal.test.tsx @@ -0,0 +1,117 @@ +// 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 { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import EditDeploymentModal from "@components/project-details/deployments/EditDeploymentModal"; +import useGetCasesFilters from "@api/useGetCasesFilters"; +import { usePatchDeployment } from "@api/usePatchDeployment"; +import type { ProjectDeploymentItem } from "@models/responses"; + +vi.mock("@api/useGetCasesFilters"); +vi.mock("@api/usePatchDeployment"); + +const mockDeployment: ProjectDeploymentItem = { + id: "dep-1", + name: "Production", + createdOn: "2026-01-17", + updatedOn: "2026-01-17", + description: "Primary production env", + url: "https://api.example.com", + project: { id: "proj-1", label: "Test Project" }, + type: { id: "6", label: "Primary Production" }, +}; + +const mockFiltersData = { + deploymentTypes: [ + { id: "1", label: "Development" }, + { id: "2", label: "QA" }, + { id: "3", label: "Staging" }, + { id: "6", label: "Primary Production" }, + ], +}; + +const defaultProps = { + open: true, + deployment: mockDeployment, + projectId: "proj-1", + onClose: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), +}; + +describe("EditDeploymentModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useGetCasesFilters).mockReturnValue({ + data: mockFiltersData, + isLoading: false, + isError: false, + } as ReturnType); + vi.mocked(usePatchDeployment).mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, + } as unknown as ReturnType); + }); + + it("should render modal with pre-filled deployment data", () => { + render(); + + expect(screen.getByText("Edit Deployment")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Production")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Primary production env")).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toHaveTextContent("Primary Production"); + }); + + it("should call PATCH with correct body when Update is clicked", async () => { + const mutateAsync = vi.fn().mockResolvedValue(undefined); + vi.mocked(usePatchDeployment).mockReturnValue({ + mutateAsync, + isPending: false, + } as unknown as ReturnType); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Update" })); + }); + + expect(mutateAsync).toHaveBeenCalledWith({ + projectId: "proj-1", + deploymentId: "dep-1", + body: { + name: "Production", + description: "Primary production env", + typeKey: 6, + active: true, + }, + }); + }); + + it("should disable submit when name is empty", () => { + render( + , + ); + + const nameInput = screen.getByLabelText(/Deployment Name/); + fireEvent.change(nameInput, { target: { value: "" } }); + + expect(screen.getByRole("button", { name: "Update" })).toBeDisabled(); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/ProjectDeployments.test.tsx b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/ProjectDeployments.test.tsx index 8a58ae1236..6e6431280a 100644 --- a/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/ProjectDeployments.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/deployments/__tests__/ProjectDeployments.test.tsx @@ -44,6 +44,39 @@ vi.mock("@api/useGetDeploymentsProducts", () => ({ isError: false, }), })); +vi.mock("@api/useGetProducts", () => ({ + useGetProducts: () => ({ data: [], isLoading: false, isError: false }), +})); +vi.mock("@api/useSearchProductVersions", () => ({ + useSearchProductVersions: () => ({ + data: [], + isLoading: false, + isError: false, + }), +})); +vi.mock("@api/usePostDeploymentProduct", () => ({ + usePostDeploymentProduct: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); +vi.mock("@api/usePatchDeploymentProduct", () => ({ + usePatchDeploymentProduct: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); +vi.mock("@api/useGetDeploymentDocuments", () => ({ + useGetDeploymentDocuments: () => ({ + data: [], + isLoading: false, + isError: false, + }), +})); + +vi.mock("@case-details-attachments/UploadAttachmentModal", () => ({ + default: () => null, +})); // Mock AddDeploymentModal so we can test open/close without full modal rendering vi.mock("@components/project-details/deployments/AddDeploymentModal", () => ({ @@ -67,6 +100,10 @@ vi.mock("@components/project-details/deployments/AddDeploymentModal", () => ({ ) : null, })); +vi.mock("@components/project-details/deployments/EditDeploymentModal", () => ({ + default: () => null, +})); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); function renderWithProviders(ui: ReactElement) { diff --git a/apps/customer-portal/webapp/src/components/project-details/time-tracking/ProjectTimeTracking.tsx b/apps/customer-portal/webapp/src/components/project-details/time-tracking/ProjectTimeTracking.tsx index 3e09708624..913df3952c 100644 --- a/apps/customer-portal/webapp/src/components/project-details/time-tracking/ProjectTimeTracking.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/time-tracking/ProjectTimeTracking.tsx @@ -15,10 +15,11 @@ // under the License. import { Box, Grid } from "@wso2/oxygen-ui"; -import { type JSX } from "react"; -import useGetTimeTrackingDetails from "@api/useGetTimeTrackingDetails"; +import { useState, useMemo, type JSX } from "react"; +import useSearchProjectTimeCards from "@api/useSearchProjectTimeCards"; import useGetProjectTimeTrackingStat from "@api/useGetProjectTimeTrackingStat"; import TimeTrackingStatCards from "@time-tracking/TimeTrackingStatCards"; +import TimeCardsDateFilter from "@time-tracking/TimeCardsDateFilter"; import TimeTrackingCard from "@time-tracking/TimeTrackingCard"; import TimeTrackingCardSkeleton from "@time-tracking/TimeTrackingCardSkeleton"; import TimeTrackingErrorState from "@time-tracking/TimeTrackingErrorState"; @@ -28,9 +29,24 @@ interface ProjectTimeTrackingProps { projectId: string; } +/** Format date as YYYY-MM-DD for API. */ +function formatDateForApi(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** Default start: 12 months ago; default end: today. */ +function getDefaultDateRange(): { startDate: string; endDate: string } { + const end = new Date(); + const start = new Date(); + start.setFullYear(start.getFullYear() - 1); + return { + startDate: formatDateForApi(start), + endDate: formatDateForApi(end), + }; +} + /** - * ProjectTimeTracking component manages the display of time tracking statistics and logs. - * It handles loading skeletons and error reporting via a dedicated error state. + * ProjectTimeTracking component manages the display of time tracking statistics, date filter, and time cards. * * @param {ProjectTimeTrackingProps} props - Component props. * @returns {JSX.Element} The rendered component. @@ -38,6 +54,13 @@ interface ProjectTimeTrackingProps { export default function ProjectTimeTracking({ projectId, }: ProjectTimeTrackingProps): JSX.Element { + const { startDate: defaultStart, endDate: defaultEnd } = useMemo( + getDefaultDateRange, + [], + ); + const [startDate, setStartDate] = useState(defaultStart); + const [endDate, setEndDate] = useState(defaultEnd); + const { data: stats, isLoading: isStatsLoading, @@ -45,12 +68,19 @@ export default function ProjectTimeTracking({ } = useGetProjectTimeTrackingStat(projectId); const { - data: details, - isLoading: isDetailsLoading, - isError: isDetailsError, - } = useGetTimeTrackingDetails(projectId); + data: timeCardsData, + isLoading: isTimeCardsLoading, + isError: isTimeCardsError, + } = useSearchProjectTimeCards({ + projectId, + startDate, + endDate, + limit: 50, + offset: 0, + }); - const timeLogs = details?.timeLogs || []; + const timeCards = timeCardsData?.timeCards ?? []; + const totalRecords = timeCardsData?.totalRecords ?? 0; return ( @@ -60,24 +90,36 @@ export default function ProjectTimeTracking({ isError={isStatsError} /> - {isDetailsError ? ( + + + + + {isTimeCardsError ? ( ) : ( - {isDetailsLoading ? ( + {isTimeCardsLoading ? ( Array.from({ length: 7 }).map((_, index) => ( )) - ) : timeLogs.length === 0 ? ( + ) : timeCards.length === 0 ? ( ) : ( - timeLogs.map((log) => ( - - + timeCards.map((card) => ( + + )) )} diff --git a/apps/customer-portal/webapp/src/components/project-details/time-tracking/TimeCardsDateFilter.tsx b/apps/customer-portal/webapp/src/components/project-details/time-tracking/TimeCardsDateFilter.tsx new file mode 100644 index 0000000000..c662d9c12f --- /dev/null +++ b/apps/customer-portal/webapp/src/components/project-details/time-tracking/TimeCardsDateFilter.tsx @@ -0,0 +1,142 @@ +// 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 { + Card, + Box, + Typography, + TextField, + InputAdornment, +} from "@wso2/oxygen-ui"; +import { Calendar } from "@wso2/oxygen-ui-icons-react"; +import { type JSX } from "react"; + +export interface TimeCardsDateFilterProps { + startDate: string; + endDate: string; + onStartDateChange: (value: string) => void; + onEndDateChange: (value: string) => void; + shownCount: number; + totalCount: number; + isLoading?: boolean; +} + +/** + * TimeCardsDateFilter provides date range filters for time cards and shows count summary. + * + * @param {TimeCardsDateFilterProps} props - Date values, handlers, and counts. + * @returns {JSX.Element} The rendered filter card. + */ +export default function TimeCardsDateFilter({ + startDate, + endDate, + onStartDateChange, + onEndDateChange, + shownCount, + totalCount, + isLoading = false, +}: TimeCardsDateFilterProps): JSX.Element { + return ( + + + + + + Filter by Date Range: + + + + + + From: + + onStartDateChange(e.target.value)} + sx={{ minWidth: 200 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + To: + + onEndDateChange(e.target.value)} + sx={{ minWidth: 200 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {isLoading + ? "Loading..." + : `Showing ${shownCount} of ${totalCount} time logs`} + + + + + ); +} diff --git a/apps/customer-portal/webapp/src/components/project-details/time-tracking/TimeTrackingCard.tsx b/apps/customer-portal/webapp/src/components/project-details/time-tracking/TimeTrackingCard.tsx index 5479d6a88d..42cd4f944f 100644 --- a/apps/customer-portal/webapp/src/components/project-details/time-tracking/TimeTrackingCard.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/time-tracking/TimeTrackingCard.tsx @@ -14,22 +14,36 @@ // specific language governing permissions and limitations // under the License. -import { Card, Box, Typography, Chip, useTheme, alpha } from "@wso2/oxygen-ui"; -import { User, Shield, Calendar } from "@wso2/oxygen-ui-icons-react"; -import type { JSX } from "react"; -import type { TimeTrackingLog } from "@models/responses"; -import { getTimeTrackingBadgePaletteKey } from "@utils/projectDetails"; -import { TIME_TRACKING_BADGE_TYPES } from "@constants/projectDetailsConstants"; +import { Card, Box, Typography, Chip, useTheme } from "@wso2/oxygen-ui"; +import { type JSX } from "react"; +import type { TimeCard } from "@models/responses"; +import { getTimeCardStateColorPath } from "@utils/projectDetails"; +import { + getSupportOverviewChipSx, + getPlainChipSx, +} from "@utils/support"; interface TimeTrackingCardProps { - log: TimeTrackingLog; + card: TimeCard; } +/** + * TimeTrackingCard displays a single time card with case label, state, billable, case number, total time, and approver. + * + * @param {TimeTrackingCardProps} props - Time card data. + * @returns {JSX.Element} The rendered time card. + */ export default function TimeTrackingCard({ - log, + card, }: TimeTrackingCardProps): JSX.Element { - const { badges, description, user, role, date, hours } = log; const theme = useTheme(); + const { case: caseData, state, hasBillable, totalTime, approvedBy } = card; + + const label = caseData?.label?.trim() || "--"; + const caseNumber = caseData?.number?.trim() || "--"; + const approvedByName = approvedBy?.label?.trim() || "--"; + + const stateColorPath = getTimeCardStateColorPath(state); return ( - {badges.map((badge, index) => { - const rawPaletteKey = getTimeTrackingBadgePaletteKey(badge.type); - const paletteKey = - rawPaletteKey === "default" ? "primary" : rawPaletteKey; - const mainColor = theme.palette[paletteKey].main; - - return ( - - ); - })} + + + {hasBillable && ( + + )} - {description || "--"} + {label} + + + Approved by: {approvedByName} - - - - {user || "--"} - - - - {role || "--"} - - - - {date || "--"} - - - {hours !== undefined && hours !== null ? `${hours}h` : "--"} + {totalTime !== undefined && totalTime !== null + ? `${totalTime}h` + : "--"} diff --git a/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/ProjectTimeTracking.test.tsx b/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/ProjectTimeTracking.test.tsx index df3c90596d..6b26be2d9d 100644 --- a/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/ProjectTimeTracking.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/ProjectTimeTracking.test.tsx @@ -17,16 +17,14 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import ProjectTimeTracking from "@time-tracking/ProjectTimeTracking"; -import useGetTimeTrackingDetails from "@api/useGetTimeTrackingDetails"; +import useSearchProjectTimeCards from "@api/useSearchProjectTimeCards"; import useGetProjectTimeTrackingStat from "@api/useGetProjectTimeTrackingStat"; -// Mock the hooks -vi.mock("@api/useGetTimeTrackingDetails"); +vi.mock("@api/useSearchProjectTimeCards"); vi.mock("@api/useGetProjectTimeTrackingStat"); -// Mock sub-components vi.mock("@time-tracking/TimeTrackingStatCards", () => ({ - default: ({ isLoading, isError }: any) => ( + default: ({ isLoading, isError }: { isLoading?: boolean; isError?: boolean }) => (
{isLoading ? "Stats Loading" : "Stats Loaded"} {isError ? "Stats Error" : "Stats OK"} @@ -34,9 +32,15 @@ vi.mock("@time-tracking/TimeTrackingStatCards", () => ({ ), })); +vi.mock("@time-tracking/TimeCardsDateFilter", () => ({ + default: () =>
Date Filter
, +})); + vi.mock("@time-tracking/TimeTrackingCard", () => ({ - default: ({ log }: any) => ( -
{log.description}
+ default: ({ card }: { card: { case?: { label?: string } } }) => ( +
+ {card?.case?.label ?? "No label"} +
), })); @@ -49,7 +53,7 @@ vi.mock("@time-tracking/TimeTrackingErrorState", () => ({ })); vi.mock("@components/common/empty-state/EmptyState", () => ({ - default: ({ description }: any) => ( + default: ({ description }: { description: string }) => (
{description}
), })); @@ -61,14 +65,14 @@ describe("ProjectTimeTracking", () => { vi.clearAllMocks(); }); - it("should render 7 skeletons when details are loading", () => { + it("should render 7 skeletons when time cards are loading", () => { vi.mocked(useGetProjectTimeTrackingStat).mockReturnValue({ data: undefined, isLoading: false, isError: false, } as any); - vi.mocked(useGetTimeTrackingDetails).mockReturnValue({ + vi.mocked(useSearchProjectTimeCards).mockReturnValue({ data: undefined, isLoading: true, isError: false, @@ -79,14 +83,14 @@ describe("ProjectTimeTracking", () => { expect(screen.getAllByTestId("skeleton")).toHaveLength(7); }); - it("should render error state when details fail to load", () => { + it("should render error state when time cards fail to load", () => { vi.mocked(useGetProjectTimeTrackingStat).mockReturnValue({ data: undefined, isLoading: false, isError: false, } as any); - vi.mocked(useGetTimeTrackingDetails).mockReturnValue({ + vi.mocked(useSearchProjectTimeCards).mockReturnValue({ data: undefined, isLoading: false, isError: true, @@ -97,12 +101,33 @@ describe("ProjectTimeTracking", () => { expect(screen.getByTestId("error-state")).toBeInTheDocument(); }); - it("should render time log cards when data is loaded", () => { - const mockDetails = { - timeLogs: [ - { id: "1", description: "Log 1", badges: [] }, - { id: "2", description: "Log 2", badges: [] }, + it("should render time cards when data is loaded", () => { + const mockData = { + timeCards: [ + { + id: "1", + case: { label: "Log 1", number: "CS001", id: "c1" }, + totalTime: 60, + state: "Approved", + hasBillable: false, + approvedBy: null, + project: { id: "p1", label: "Project 1" }, + createdOn: "2025-12-10", + }, + { + id: "2", + case: { label: "Log 2", number: "CS002", id: "c2" }, + totalTime: 30, + state: "Submitted", + hasBillable: true, + approvedBy: null, + project: { id: "p1", label: "Project 1" }, + createdOn: "2025-12-11", + }, ], + totalRecords: 2, + offset: 0, + limit: 10, }; vi.mocked(useGetProjectTimeTrackingStat).mockReturnValue({ @@ -111,8 +136,8 @@ describe("ProjectTimeTracking", () => { isError: false, } as any); - vi.mocked(useGetTimeTrackingDetails).mockReturnValue({ - data: mockDetails, + vi.mocked(useSearchProjectTimeCards).mockReturnValue({ + data: mockData, isLoading: false, isError: false, } as any); @@ -121,17 +146,17 @@ describe("ProjectTimeTracking", () => { expect(screen.getByText("Log 1")).toBeInTheDocument(); expect(screen.getByText("Log 2")).toBeInTheDocument(); - expect(screen.getAllByTestId("time-log-card")).toHaveLength(2); + expect(screen.getAllByTestId("time-card")).toHaveLength(2); }); - it("should always render stat cards regardless of details state", () => { + it("should always render stat cards regardless of time cards state", () => { vi.mocked(useGetProjectTimeTrackingStat).mockReturnValue({ data: undefined, isLoading: false, isError: false, } as any); - vi.mocked(useGetTimeTrackingDetails).mockReturnValue({ + vi.mocked(useSearchProjectTimeCards).mockReturnValue({ data: undefined, isLoading: true, isError: false, @@ -142,15 +167,33 @@ describe("ProjectTimeTracking", () => { expect(screen.getByTestId("stat-cards")).toBeInTheDocument(); }); - it("should render empty state when there are no time logs", () => { + it("should render date filter between stats and time cards", () => { + vi.mocked(useGetProjectTimeTrackingStat).mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + } as any); + + vi.mocked(useSearchProjectTimeCards).mockReturnValue({ + data: { timeCards: [], totalRecords: 0, offset: 0, limit: 10 }, + isLoading: false, + isError: false, + } as any); + + render(); + + expect(screen.getByTestId("date-filter")).toBeInTheDocument(); + }); + + it("should render empty state when there are no time cards", () => { vi.mocked(useGetProjectTimeTrackingStat).mockReturnValue({ data: undefined, isLoading: false, isError: false, } as any); - vi.mocked(useGetTimeTrackingDetails).mockReturnValue({ - data: { timeLogs: [] }, + vi.mocked(useSearchProjectTimeCards).mockReturnValue({ + data: { timeCards: [], totalRecords: 0, offset: 0, limit: 10 }, isLoading: false, isError: false, } as any); diff --git a/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/TimeCardsDateFilter.test.tsx b/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/TimeCardsDateFilter.test.tsx new file mode 100644 index 0000000000..797d5dbf95 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/TimeCardsDateFilter.test.tsx @@ -0,0 +1,57 @@ +// 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 TimeCardsDateFilter from "@time-tracking/TimeCardsDateFilter"; + +describe("TimeCardsDateFilter", () => { + it("should render filter by date range label and date inputs", () => { + render( + {}} + onEndDateChange={() => {}} + shownCount={5} + totalCount={5} + />, + ); + + expect( + screen.getByText("Filter by Date Range:"), + ).toBeInTheDocument(); + expect( + screen.getByText("Showing 5 of 5 time logs"), + ).toBeInTheDocument(); + }); + + it("should show Loading when isLoading is true", () => { + render( + {}} + onEndDateChange={() => {}} + shownCount={0} + totalCount={0} + isLoading + />, + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/TimeTrackingCard.test.tsx b/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/TimeTrackingCard.test.tsx index 728c2e3bed..0129768489 100644 --- a/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/TimeTrackingCard.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/time-tracking/__tests__/TimeTrackingCard.test.tsx @@ -15,96 +15,62 @@ // under the License. import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import TimeTrackingCard from "@time-tracking/TimeTrackingCard"; -import { type TimeTrackingLog } from "@models/responses"; -import { TIME_TRACKING_BADGE_TYPES } from "@constants/projectDetailsConstants"; - -// Mock @wso2/oxygen-ui-icons-react -vi.mock("@wso2/oxygen-ui-icons-react", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - User: () =>
, - Shield: () =>
, - Calendar: () =>
, - }; -}); - -// Mock useTheme and alpha -vi.mock("@wso2/oxygen-ui", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useTheme: () => ({ - palette: { - warning: { main: "#ff9800" }, - success: { main: "#4caf50" }, - info: { main: "#2196f3" }, - secondary: { main: "#9c27b0" }, - primary: { main: "#3f51b5" }, - }, - }), - alpha: (color: string, opacity: number) => `rgba(${color}, ${opacity})`, - }; -}); +import type { TimeCard } from "@models/responses"; describe("TimeTrackingCard", () => { - const mockLog: TimeTrackingLog = { + const mockCard: TimeCard = { id: "1", - badges: [ - { text: "Support", type: TIME_TRACKING_BADGE_TYPES.SUPPORT }, - { text: "Billable", type: TIME_TRACKING_BADGE_TYPES.BILLABLE }, - ], - description: "Test description", - user: "Test User", - role: "Test Role", - date: "2026-01-16", - hours: 4.5, + totalTime: 60, + createdOn: "2025-12-10 03:47:10", + hasBillable: true, + state: "Approved", + approvedBy: { id: "a1", label: "Dileepa Peiris (Intern)" }, + project: { id: "p1", label: "Customer 3 Project 1" }, + case: { + number: "CS0437343", + id: "c1", + label: "test - image upload in Desc", + }, }; it("should render card with all information", () => { - render(); + render(); - expect(screen.getByText("Test description")).toBeInTheDocument(); - expect(screen.getByText("Test User")).toBeInTheDocument(); - expect(screen.getByText("Test Role")).toBeInTheDocument(); - expect(screen.getByText("2026-01-16")).toBeInTheDocument(); - expect(screen.getByText("4.5h")).toBeInTheDocument(); - expect(screen.getByText("Support")).toBeInTheDocument(); + expect(screen.getByText("test - image upload in Desc")).toBeInTheDocument(); + expect(screen.getByText("Approved")).toBeInTheDocument(); expect(screen.getByText("Billable")).toBeInTheDocument(); + expect(screen.getByText("CS0437343")).toBeInTheDocument(); + expect(screen.getByText("60h")).toBeInTheDocument(); + expect( + screen.getByText(/Approved by: Dileepa Peiris \(Intern\)/), + ).toBeInTheDocument(); }); it("should show fallback '--' for missing values", () => { - const incompleteLog: TimeTrackingLog = { - id: "2", - badges: [], - description: null, - user: null, - role: null, - date: null, - hours: null, + const incompleteCard: TimeCard = { + ...mockCard, + state: "", + approvedBy: null, + case: { number: "", id: "c1", label: "" }, + totalTime: 0, }; - render(); + render(); - const fallbacks = screen.getAllByText("--"); - expect(fallbacks.length).toBeGreaterThan(0); + expect(screen.getByText(/Approved by: --/)).toBeInTheDocument(); }); - it("should correctly render badge types with different colors", () => { - const badgeLog: TimeTrackingLog = { - ...mockLog, - badges: [ - { text: "Case", type: TIME_TRACKING_BADGE_TYPES.CASE }, - { text: "Maintenance", type: TIME_TRACKING_BADGE_TYPES.MAINTENANCE }, - ], + it("should not render Billable chip when hasBillable is false", () => { + const nonBillableCard: TimeCard = { + ...mockCard, + hasBillable: false, }; - render(); + render(); - expect(screen.getByText("Case")).toBeInTheDocument(); - expect(screen.getByText("Maintenance")).toBeInTheDocument(); + expect(screen.queryByText("Billable")).not.toBeInTheDocument(); + expect(screen.getByText("test - image upload in Desc")).toBeInTheDocument(); }); }); diff --git a/apps/customer-portal/webapp/src/components/support/case-details/attachments-tab/UploadAttachmentModal.tsx b/apps/customer-portal/webapp/src/components/support/case-details/attachments-tab/UploadAttachmentModal.tsx index ac55a67bde..d8081264ce 100644 --- a/apps/customer-portal/webapp/src/components/support/case-details/attachments-tab/UploadAttachmentModal.tsx +++ b/apps/customer-portal/webapp/src/components/support/case-details/attachments-tab/UploadAttachmentModal.tsx @@ -28,6 +28,7 @@ import { import { X } from "@wso2/oxygen-ui-icons-react"; import { useCallback, useRef, useState, type JSX } from "react"; import { usePostAttachments } from "@api/usePostAttachments"; +import { usePostDeploymentAttachment } from "@api/usePostDeploymentAttachment"; import { MAX_ATTACHMENT_SIZE_BYTES } from "@constants/supportConstants"; import UploadAttachmentDropZone from "@case-details-attachments/UploadAttachmentDropZone"; import SelectedFileDisplay from "@case-details-attachments/SelectedFileDisplay"; @@ -36,33 +37,44 @@ export { MAX_ATTACHMENT_SIZE_BYTES } from "@constants/supportConstants"; export interface UploadAttachmentModalProps { open: boolean; + /** Case ID for case attachment upload. */ caseId?: string; + /** Deployment ID for deployment document upload. */ + deploymentId?: string; onClose: () => void; onSuccess?: () => void; onSelect?: (file: File, attachmentName?: string) => void; } /** - * Modal for uploading a case attachment: drag-and-drop or file picker, optional name (defaults to file name). - * Max file size 15 MB; shows ErrorBanner when exceeded. + * Modal for uploading a case attachment or deployment document: drag-and-drop or file picker. + * Supports caseId (case attachments) or deploymentId (deployment documents). Max file size 15 MB. * - * @param {UploadAttachmentModalProps} props - open, caseId, onClose, optional onSuccess. + * @param {UploadAttachmentModalProps} props - open, caseId/deploymentId, onClose, optional onSuccess. * @returns {JSX.Element} The upload attachment modal. */ export default function UploadAttachmentModal({ open, caseId, + deploymentId, onClose, onSuccess, onSelect, }: UploadAttachmentModalProps): JSX.Element { const postAttachments = usePostAttachments(); + const postDeploymentAttachment = usePostDeploymentAttachment(); + const isDeploymentMode = !!deploymentId && !caseId; + const isPending = + isDeploymentMode + ? postDeploymentAttachment.isPending + : postAttachments.isPending; const fileInputRef = useRef(null); const [file, setFile] = useState(null); const [name, setName] = useState(""); const [dragOver, setDragOver] = useState(false); const [fileSizeErrorVisible, setFileSizeErrorVisible] = useState(false); + const [readErrorVisible, setReadErrorVisible] = useState(false); const fileTooLarge = file ? file.size > MAX_ATTACHMENT_SIZE_BYTES : false; const displayName = name.trim() || (file?.name ?? ""); @@ -70,12 +82,13 @@ export default function UploadAttachmentModal({ !!file && !fileTooLarge && !!displayName && - (!!onSelect || (!postAttachments.isPending && !!caseId)); + (!!onSelect || (!isPending && (!!caseId || !!deploymentId))); const reset = useCallback(() => { setFile(null); setName(""); setFileSizeErrorVisible(false); + setReadErrorVisible(false); }, []); const handleClose = useCallback(() => { @@ -141,37 +154,54 @@ export default function UploadAttachmentModal({ return; } - if (!caseId) return; + if (!caseId && !deploymentId) return; const reader = new FileReader(); reader.onload = () => { const base64 = typeof reader.result === "string" ? reader.result : ""; const commaIndex = base64.indexOf(","); const content = commaIndex >= 0 ? base64.slice(commaIndex + 1) : base64; - postAttachments.mutate( - { - caseId, - body: { - name: attachmentName, - type: file.type || "application/octet-stream", - content, + const body = { + name: attachmentName, + type: file.type || "application/octet-stream", + content, + }; + + if (isDeploymentMode) { + postDeploymentAttachment.mutate( + { deploymentId: deploymentId!, body }, + { + onSuccess: () => { + handleClose(); + onSuccess?.(); + }, }, - }, - { - onSuccess: () => { - handleClose(); - onSuccess?.(); + ); + } else if (caseId) { + postAttachments.mutate( + { caseId, body }, + { + onSuccess: () => { + handleClose(); + onSuccess?.(); + }, }, - }, - ); + ); + } + }; + reader.onerror = () => { + setReadErrorVisible(true); }; reader.readAsDataURL(file); }, [ caseId, + deploymentId, file, name, fileTooLarge, + isDeploymentMode, postAttachments, + postDeploymentAttachment, handleClose, onSuccess, onSelect, @@ -189,7 +219,7 @@ export default function UploadAttachmentModal({ id="upload-attachment-dialog-title" sx={{ pr: 6, position: "relative" }} > - Upload Attachment + {isDeploymentMode ? "Add Document" : "Upload Attachment"} )} + {readErrorVisible && ( + setReadErrorVisible(false)} + sx={{ mb: 2 }} + > + Failed to read file. Please try again. + + )} setName(e.target.value)} @@ -240,10 +279,10 @@ export default function UploadAttachmentModal({ /> - - {postAttachments.isPending ? ( + {isPending ? (