From 37f23dde382e73219291913a85b8004373b91757 Mon Sep 17 00:00:00 2001 From: Suwagath S Date: Mon, 23 Feb 2026 14:00:58 +0530 Subject: [PATCH 01/32] Add Service Requests list components - ServiceRequestsList: Card-based list displaying SR details with status chips, request type, metadata, and relative timestamps - ServiceRequestsSearchBar: Search input with status filter tabs (All, Pending, In Progress, Completed) - ServiceRequestsListSkeleton: Loading skeleton for the list --- .../service-requests/ServiceRequestsList.tsx | 254 ++++++++++++++++++ .../ServiceRequestsListSkeleton.tsx | 77 ++++++ .../ServiceRequestsSearchBar.tsx | 123 +++++++++ 3 files changed, 454 insertions(+) create mode 100644 apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx create mode 100644 apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsListSkeleton.tsx create mode 100644 apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsSearchBar.tsx diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx new file mode 100644 index 0000000000..2c16c9d18c --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx @@ -0,0 +1,254 @@ +// 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, + Chip, + Link, + Stack, + Typography, + alpha, + useTheme, +} from "@wso2/oxygen-ui"; +import { + Calendar, + ChevronRight, + Layers, + Package, + Users, +} from "@wso2/oxygen-ui-icons-react"; +import type { JSX } from "react"; +import type { CaseListItem } from "@models/responses"; +import { + formatRelativeTime, + getStatusColor, + getStatusIcon, + resolveColorFromTheme, + stripHtml, +} from "@utils/support"; +import ServiceRequestsListSkeleton from "@components/support/service-requests/ServiceRequestsListSkeleton"; + +export interface ServiceRequestsListProps { + serviceRequests: CaseListItem[]; + isLoading: boolean; + onServiceRequestClick?: (sr: CaseListItem) => void; +} + +/** + * Component to display service requests as cards. + * + * @param {ServiceRequestsListProps} props - Service requests array and loading state. + * @returns {JSX.Element} The rendered service request cards list. + */ +export default function ServiceRequestsList({ + serviceRequests, + isLoading, + onServiceRequestClick, +}: ServiceRequestsListProps): JSX.Element { + const theme = useTheme(); + + if (isLoading) { + return ; + } + + if (serviceRequests.length === 0) { + return ( + + + No service requests found. + + + ); + } + + return ( + + {serviceRequests.map((sr) => { + const StatusIcon = getStatusIcon(sr.status?.label); + const colorPath = getStatusColor(sr.status?.label); + const resolvedColor = resolveColorFromTheme(colorPath, theme); + + return ( + onServiceRequestClick?.(sr)} + > + + + + {sr.number || "--"} + + } + sx={{ + bgcolor: alpha(resolvedColor, 0.1), + color: resolvedColor, + height: 22, + fontSize: "0.75rem", + "& .MuiChip-icon": { + color: "inherit", + ml: "6px", + mr: "-2px", + }, + "& .MuiChip-label": { + px: 1, + }, + }} + /> + {sr.issueType?.label && ( + + )} + + + {formatRelativeTime(sr.createdOn)} + + + + + {sr.title || "--"} + + + + {stripHtml(sr.description) || "--"} + + + + + {sr.deployment?.label && ( + + + + {sr.deployment.label} + + + )} + {sr.deployedProduct?.label && ( + + + + {sr.deployedProduct.label} + + + )} + + + + {sr.createdOn + ? new Date(sr.createdOn).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "--"} + + + {sr.assignedEngineer && ( + + + + {typeof sr.assignedEngineer === "string" + ? sr.assignedEngineer + : sr.assignedEngineer.label || + sr.assignedEngineer.name || + "--"} + + + )} + + { + e.stopPropagation(); + onServiceRequestClick?.(sr); + }} + sx={{ + display: "flex", + alignItems: "center", + gap: 0.5, + color: "primary.main", + fontWeight: 500, + }} + > + View Details + + + + + ); + })} + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsListSkeleton.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsListSkeleton.tsx new file mode 100644 index 0000000000..27bad0d30f --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsListSkeleton.tsx @@ -0,0 +1,77 @@ +// 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, Skeleton, Stack } from "@wso2/oxygen-ui"; +import type { JSX } from "react"; + +/** + * Skeleton loader for the ServiceRequestsList component. + * + * @returns {JSX.Element} The rendered skeleton. + */ +export default function ServiceRequestsListSkeleton(): JSX.Element { + return ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + + + + + + + + + + + + + + + + + + + + ))} + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsSearchBar.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsSearchBar.tsx new file mode 100644 index 0000000000..cb3751da41 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsSearchBar.tsx @@ -0,0 +1,123 @@ +// 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, Paper, TextField, InputAdornment, Tabs, Tab } from "@wso2/oxygen-ui"; +import { Search } from "@wso2/oxygen-ui-icons-react"; +import type { JSX, ChangeEvent, SyntheticEvent } from "react"; +import type { ServiceRequestStatusFilter } from "@pages/ServiceRequestsPage"; + +export interface ServiceRequestsSearchBarProps { + searchTerm: string; + onSearchChange: (value: string) => void; + statusFilter: ServiceRequestStatusFilter; + onStatusFilterChange: (value: ServiceRequestStatusFilter) => void; +} + +const STATUS_TABS: { value: ServiceRequestStatusFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "pending", label: "Pending" }, + { value: "in_progress", label: "In Progress" }, + { value: "completed", label: "Completed" }, +]; + +/** + * Search bar with status filter tabs for Service Requests. + * + * @param {ServiceRequestsSearchBarProps} props - Search and filter props. + * @returns {JSX.Element} The rendered search bar. + */ +export default function ServiceRequestsSearchBar({ + searchTerm, + onSearchChange, + statusFilter, + onStatusFilterChange, +}: ServiceRequestsSearchBarProps): JSX.Element { + const handleSearchChange = (event: ChangeEvent) => { + onSearchChange(event.target.value); + }; + + const handleTabChange = ( + _event: SyntheticEvent, + newValue: ServiceRequestStatusFilter, + ) => { + onStatusFilterChange(newValue); + }; + + const currentTabIndex = STATUS_TABS.findIndex((t) => t.value === statusFilter); + + return ( + + + + + + + ), + }, + }} + /> + + = 0 ? currentTabIndex : 0} + onChange={(_e, idx) => handleTabChange(_e, STATUS_TABS[idx].value)} + sx={{ + minHeight: 36, + "& .MuiTabs-indicator": { + display: "none", + }, + }} + > + {STATUS_TABS.map((tab) => ( + + ))} + + + + ); +} From eca2365d619e1f7359ab2d2c34ceac1ea2b97eb9 Mon Sep 17 00:00:00 2001 From: Suwagath S Date: Mon, 23 Feb 2026 14:01:24 +0530 Subject: [PATCH 02/32] Add ServiceRequestsPage for listing all service requests - Filters cases by Service Request type - Search functionality with frontend filtering - Status filter tabs (All, Pending, In Progress, Completed) - Sort by newest/oldest - Pagination support --- .../webapp/src/pages/ServiceRequestsPage.tsx | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx diff --git a/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx b/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx new file mode 100644 index 0000000000..ccdf45b926 --- /dev/null +++ b/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx @@ -0,0 +1,302 @@ +// 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 { useParams, useNavigate } from "react-router"; +import { + useState, + useMemo, + useEffect, + type JSX, + type ChangeEvent, +} from "react"; +import { useLoader } from "@context/linear-loader/LoaderContext"; +import { + Box, + Button, + Stack, + Select, + MenuItem, + Typography, + FormControl, + InputLabel, + Pagination, +} from "@wso2/oxygen-ui"; +import { ArrowLeft, Plus } from "@wso2/oxygen-ui-icons-react"; +import useGetCasesFilters from "@api/useGetCasesFilters"; +import useGetProjectCases from "@api/useGetProjectCases"; +import ServiceRequestsList from "@components/support/service-requests/ServiceRequestsList"; +import ServiceRequestsSearchBar from "@components/support/service-requests/ServiceRequestsSearchBar"; + +const SERVICE_REQUEST_TYPE_LABEL = "Service Request"; + +export type ServiceRequestStatusFilter = + | "all" + | "pending" + | "in_progress" + | "completed"; + +/** + * ServiceRequestsPage displays all service requests with filters and search. + * + * @returns {JSX.Element} The rendered Service Requests page. + */ +export default function ServiceRequestsPage(): JSX.Element { + const navigate = useNavigate(); + const { projectId } = useParams<{ projectId: string }>(); + + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = + useState("all"); + const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc"); + const [page, setPage] = useState(1); + const pageSize = 10; + + const { data: filterMetadata } = useGetCasesFilters(projectId || ""); + + const serviceRequestTypeId = useMemo(() => { + const caseTypes = filterMetadata?.caseTypes ?? []; + const srType = caseTypes.find( + (t) => t.label === SERVICE_REQUEST_TYPE_LABEL, + ); + return srType?.id; + }, [filterMetadata]); + + const caseSearchRequest = useMemo( + () => ({ + filters: { + caseTypeIds: serviceRequestTypeId ? [serviceRequestTypeId] : undefined, + }, + sortBy: { + field: "createdOn" as const, + order: "desc" as const, + }, + }), + [serviceRequestTypeId], + ); + + const { + data, + isLoading: isCasesQueryLoading, + hasNextPage, + fetchNextPage, + } = useGetProjectCases(projectId || "", caseSearchRequest); + + const { showLoader, hideLoader } = useLoader(); + + const hasCasesResponse = data !== undefined; + const isCasesAreaLoading = + isCasesQueryLoading || (!!projectId && !hasCasesResponse); + + useEffect(() => { + if (isCasesAreaLoading) { + showLoader(); + return () => hideLoader(); + } + hideLoader(); + }, [isCasesAreaLoading, showLoader, hideLoader]); + + useEffect(() => { + if (!data || !hasNextPage) { + return; + } + void fetchNextPage(); + }, [data, hasNextPage, fetchNextPage]); + + const allServiceRequests = useMemo( + () => data?.pages.flatMap((p) => p.cases) ?? [], + [data], + ); + + const filteredServiceRequests = useMemo(() => { + let filtered = [...allServiceRequests]; + + if (searchTerm.trim()) { + const searchLower = searchTerm.toLowerCase(); + filtered = filtered.filter( + (sr) => + sr.number?.toLowerCase().includes(searchLower) || + sr.title?.toLowerCase().includes(searchLower) || + sr.description?.toLowerCase().includes(searchLower), + ); + } + + if (statusFilter !== "all") { + filtered = filtered.filter((sr) => { + const statusLabel = sr.status?.label?.toLowerCase() ?? ""; + switch (statusFilter) { + case "pending": + return ( + statusLabel.includes("open") || + statusLabel.includes("awaiting") || + statusLabel.includes("waiting") + ); + case "in_progress": + return statusLabel.includes("progress"); + case "completed": + return ( + statusLabel.includes("closed") || + statusLabel.includes("resolved") + ); + default: + return true; + } + }); + } + + filtered.sort((a, b) => { + const dateA = new Date(a.createdOn).getTime() || 0; + const dateB = new Date(b.createdOn).getTime() || 0; + return sortOrder === "desc" ? dateB - dateA : dateA - dateB; + }); + + return filtered; + }, [allServiceRequests, searchTerm, statusFilter, sortOrder]); + + const totalItems = filteredServiceRequests.length; + + const paginatedServiceRequests = useMemo(() => { + const startIndex = (page - 1) * pageSize; + return filteredServiceRequests.slice(startIndex, startIndex + pageSize); + }, [filteredServiceRequests, page]); + + const totalPages = Math.ceil(totalItems / pageSize); + + const handlePageChange = (_event: ChangeEvent, value: number) => { + setPage(value); + }; + + const handleStatusFilterChange = (value: ServiceRequestStatusFilter) => { + setStatusFilter(value); + setPage(1); + }; + + const handleSortChange = (value: "desc" | "asc") => { + setSortOrder(value); + setPage(1); + }; + + const handleSearchChange = (value: string) => { + setSearchTerm(value); + setPage(1); + }; + + const handleNewServiceRequest = () => { + navigate(`/${projectId}/support/chat/create-case`, { + state: { caseType: SERVICE_REQUEST_TYPE_LABEL }, + }); + }; + + return ( + + + + + + Service Requests + + + Manage deployments, and operations, infrastructure change, and + service configurations + + + + + + + + + + Showing {paginatedServiceRequests.length} of {totalItems} service + requests + + + + Sort + + labelId="sort-label" + id="sort" + value={sortOrder} + label="Sort" + onChange={(e) => + handleSortChange(e.target.value as "desc" | "asc") + } + > + + Newest First + + + Oldest First + + + + + + + + navigate(`/${projectId}/support/cases/${sr.id}`) + } + /> + + {totalPages > 1 && ( + + + + )} + + ); +} From 4b49adc9ff5a5ed2d2d2e03e9a360425686b0139 Mon Sep 17 00:00:00 2001 From: Suwagath S Date: Mon, 23 Feb 2026 14:01:39 +0530 Subject: [PATCH 03/32] Wire up Service Requests page route and navigation - Add /support/service-requests route in App.tsx - Update ServiceRequestCard to navigate to the new page - Fix New Service Request button to use skipChat mode --- apps/customer-portal/webapp/src/App.tsx | 5 +++++ .../support/request-cards/ServiceRequestCard.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/customer-portal/webapp/src/App.tsx b/apps/customer-portal/webapp/src/App.tsx index 3dfe9b37cd..543e297de3 100644 --- a/apps/customer-portal/webapp/src/App.tsx +++ b/apps/customer-portal/webapp/src/App.tsx @@ -32,6 +32,7 @@ import AnnouncementDetailsPage from "@pages/AnnouncementDetailsPage"; import AllConversationsPage from "@pages/AllConversationsPage"; import ConversationDetailsPage from "@pages/ConversationDetailsPage"; import CaseDetailsPage from "@pages/CaseDetailsPage"; +import ServiceRequestsPage from "@pages/ServiceRequestsPage"; import NoveraChatPage from "@pages/NoveraChatPage"; import DescribeIssuePage from "@pages/DescribeIssuePage"; import CreateCasePage from "@pages/CreateCasePage"; @@ -98,6 +99,10 @@ export default function App(): JSX.Element { element={} /> + } + /> } /> navigate("../dashboard")} + onSecondaryClick={() => navigate("service-requests")} primaryButton={{ label: "New Service Request", - onClick: () => navigate("../create-case"), + onClick: () => + navigate("chat/create-case", { + state: { caseType: "Service Request", skipChat: true }, + }), icon: Server, }} /> From b7c4973a6ab1477243a808b6d335eb6870dfc19a Mon Sep 17 00:00:00 2001 From: Suwagath S Date: Thu, 26 Feb 2026 21:44:48 +0530 Subject: [PATCH 04/32] Refine service requests list UI --- .../service-requests/ServiceRequestsList.tsx | 314 +++++++++--------- .../ServiceRequestsListSkeleton.tsx | 59 +--- .../ServiceRequestsSearchBar.tsx | 58 ++-- 3 files changed, 203 insertions(+), 228 deletions(-) diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx index 2c16c9d18c..de644fd971 100644 --- a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx +++ b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx @@ -17,29 +17,26 @@ import { Box, Chip, - Link, + Form, Stack, Typography, alpha, useTheme, } from "@wso2/oxygen-ui"; -import { - Calendar, - ChevronRight, - Layers, - Package, - Users, -} from "@wso2/oxygen-ui-icons-react"; +import { Calendar, FileText, User } from "@wso2/oxygen-ui-icons-react"; import type { JSX } from "react"; import type { CaseListItem } from "@models/responses"; import { - formatRelativeTime, + formatDateTime, + getAssignedEngineerLabel, + getSeverityColor, getStatusColor, getStatusIcon, + mapSeverityToDisplay, resolveColorFromTheme, stripHtml, } from "@utils/support"; -import ServiceRequestsListSkeleton from "@components/support/service-requests/ServiceRequestsListSkeleton"; +import ServiceRequestsListSkeleton from "./ServiceRequestsListSkeleton"; export interface ServiceRequestsListProps { serviceRequests: CaseListItem[]; @@ -82,171 +79,192 @@ export default function ServiceRequestsList({ const resolvedColor = resolveColorFromTheme(colorPath, theme); return ( - onServiceRequestClick?.(sr)} sx={{ p: 3, - bgcolor: "background.paper", display: "flex", flexDirection: "column", - gap: 1.5, - cursor: "pointer", - "&:hover": { - bgcolor: "action.hover", - }, + alignItems: "stretch", + gap: 1, }} - onClick={() => onServiceRequestClick?.(sr)} > - - - - {sr.number || "--"} - - } - sx={{ - bgcolor: alpha(resolvedColor, 0.1), - color: resolvedColor, - height: 22, - fontSize: "0.75rem", - "& .MuiChip-icon": { - color: "inherit", - ml: "6px", - mr: "-2px", - }, - "& .MuiChip-label": { - px: 1, - }, - }} - /> - {sr.issueType?.label && ( + + {sr.number || "--"} + + + + + {mapSeverityToDisplay(sr.severity?.label)} + + } sx={{ - height: 22, + bgcolor: alpha(resolvedColor, 0.1), + color: resolvedColor, + height: 20, fontSize: "0.75rem", - bgcolor: "action.hover", - borderColor: "transparent", + px: 0, + "& .MuiChip-icon": { + color: "inherit", + ml: "6px", + mr: "6px", + }, + "& .MuiChip-label": { + pl: 0, + pr: "6px", + }, }} /> - )} - - - {formatRelativeTime(sr.createdOn)} - - + {sr.issueType?.label && ( + + )} + + } + /> - - {sr.title || "--"} - + + + {sr.title || "--"} + - - {stripHtml(sr.description) || "--"} - + + {stripHtml(sr.description) || "--"} + + - - - {sr.deployment?.label && ( - - - - {sr.deployment.label} - - - )} - {sr.deployedProduct?.label && ( - - - - {sr.deployedProduct.label} - - - )} - - - - {sr.createdOn - ? new Date(sr.createdOn).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }) - : "--"} + + + + Created {formatDateTime(sr.createdOn) || "--"} - {sr.assignedEngineer && ( - - - - {typeof sr.assignedEngineer === "string" - ? sr.assignedEngineer - : sr.assignedEngineer.label || - sr.assignedEngineer.name || - "--"} + {(() => { + const assignedLabel = getAssignedEngineerLabel( + sr.assignedEngineer, + ); + return assignedLabel ? ( + + + + Assigned to {assignedLabel} + + + ) : null; + })()} + {sr.deployment?.label && ( + + + + {sr.deployment.label} )} - - { - e.stopPropagation(); - onServiceRequestClick?.(sr); - }} - sx={{ - display: "flex", - alignItems: "center", - gap: 0.5, - color: "primary.main", - fontWeight: 500, - }} - > - View Details - - - - + + + ); })} diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsListSkeleton.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsListSkeleton.tsx index 27bad0d30f..be53c2c922 100644 --- a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsListSkeleton.tsx +++ b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsListSkeleton.tsx @@ -14,7 +14,7 @@ // specific language governing permissions and limitations // under the License. -import { Box, Skeleton, Stack } from "@wso2/oxygen-ui"; +import { Box, Card, Stack, Skeleton } from "@wso2/oxygen-ui"; import type { JSX } from "react"; /** @@ -26,51 +26,24 @@ export default function ServiceRequestsListSkeleton(): JSX.Element { return ( {[1, 2, 3, 4, 5].map((i) => ( - - - - - - + + + + + + + - - - - - - - - - - - - + + + + + + + - - + ))} ); diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsSearchBar.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsSearchBar.tsx index cb3751da41..74819fb8bf 100644 --- a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsSearchBar.tsx +++ b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsSearchBar.tsx @@ -14,9 +14,15 @@ // specific language governing permissions and limitations // under the License. -import { Box, Paper, TextField, InputAdornment, Tabs, Tab } from "@wso2/oxygen-ui"; +import { + Box, + Button, + Paper, + TextField, + InputAdornment, +} from "@wso2/oxygen-ui"; import { Search } from "@wso2/oxygen-ui-icons-react"; -import type { JSX, ChangeEvent, SyntheticEvent } from "react"; +import type { JSX, ChangeEvent } from "react"; import type { ServiceRequestStatusFilter } from "@pages/ServiceRequestsPage"; export interface ServiceRequestsSearchBarProps { @@ -49,15 +55,6 @@ export default function ServiceRequestsSearchBar({ onSearchChange(event.target.value); }; - const handleTabChange = ( - _event: SyntheticEvent, - newValue: ServiceRequestStatusFilter, - ) => { - onStatusFilterChange(newValue); - }; - - const currentTabIndex = STATUS_TABS.findIndex((t) => t.value === statusFilter); - return ( - + - = 0 ? currentTabIndex : 0} - onChange={(_e, idx) => handleTabChange(_e, STATUS_TABS[idx].value)} - sx={{ - minHeight: 36, - "& .MuiTabs-indicator": { - display: "none", - }, - }} - > + {STATUS_TABS.map((tab) => ( - onStatusFilterChange(tab.value)} sx={{ - minHeight: 36, - px: 2, - py: 0.5, textTransform: "none", - fontSize: "0.875rem", fontWeight: statusFilter === tab.value ? 600 : 400, - color: - statusFilter === tab.value - ? "primary.main" - : "text.secondary", - bgcolor: - statusFilter === tab.value ? "action.selected" : "transparent", + minWidth: "auto", + px: 2, }} - /> + > + {tab.label} + ))} - + ); From f64262870b5ca6d6e00ce8cfdc7e59581b7d0da0 Mon Sep 17 00:00:00 2001 From: Suwagath S Date: Thu, 26 Feb 2026 21:44:59 +0530 Subject: [PATCH 05/32] Integrate service requests page with case search API --- .../webapp/src/pages/ServiceRequestsPage.tsx | 55 +++---------------- 1 file changed, 8 insertions(+), 47 deletions(-) diff --git a/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx b/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx index ccdf45b926..4598d4ea95 100644 --- a/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx +++ b/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx @@ -27,16 +27,12 @@ import { Box, Button, Stack, - Select, - MenuItem, Typography, - FormControl, - InputLabel, Pagination, } from "@wso2/oxygen-ui"; import { ArrowLeft, Plus } from "@wso2/oxygen-ui-icons-react"; -import useGetCasesFilters from "@api/useGetCasesFilters"; import useGetProjectCases from "@api/useGetProjectCases"; +import { CaseType } from "@constants/supportConstants"; import ServiceRequestsList from "@components/support/service-requests/ServiceRequestsList"; import ServiceRequestsSearchBar from "@components/support/service-requests/ServiceRequestsSearchBar"; @@ -60,31 +56,21 @@ export default function ServiceRequestsPage(): JSX.Element { const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); - const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc"); const [page, setPage] = useState(1); const pageSize = 10; - const { data: filterMetadata } = useGetCasesFilters(projectId || ""); - - const serviceRequestTypeId = useMemo(() => { - const caseTypes = filterMetadata?.caseTypes ?? []; - const srType = caseTypes.find( - (t) => t.label === SERVICE_REQUEST_TYPE_LABEL, - ); - return srType?.id; - }, [filterMetadata]); - const caseSearchRequest = useMemo( () => ({ filters: { - caseTypeIds: serviceRequestTypeId ? [serviceRequestTypeId] : undefined, + caseTypes: [CaseType.SERVICE_REQUEST], + searchQuery: searchTerm.trim() || undefined, }, sortBy: { field: "createdOn" as const, order: "desc" as const, }, }), - [serviceRequestTypeId], + [searchTerm], ); const { @@ -159,11 +145,11 @@ export default function ServiceRequestsPage(): JSX.Element { filtered.sort((a, b) => { const dateA = new Date(a.createdOn).getTime() || 0; const dateB = new Date(b.createdOn).getTime() || 0; - return sortOrder === "desc" ? dateB - dateA : dateA - dateB; + return dateB - dateA; }); - + return filtered; - }, [allServiceRequests, searchTerm, statusFilter, sortOrder]); + }, [allServiceRequests, searchTerm, statusFilter]); const totalItems = filteredServiceRequests.length; @@ -183,11 +169,6 @@ export default function ServiceRequestsPage(): JSX.Element { setPage(1); }; - const handleSortChange = (value: "desc" | "asc") => { - setSortOrder(value); - setPage(1); - }; - const handleSearchChange = (value: string) => { setSearchTerm(value); setPage(1); @@ -254,27 +235,7 @@ export default function ServiceRequestsPage(): JSX.Element { Showing {paginatedServiceRequests.length} of {totalItems} service requests - - - Sort - - labelId="sort-label" - id="sort" - value={sortOrder} - label="Sort" - onChange={(e) => - handleSortChange(e.target.value as "desc" | "asc") - } - > - - Newest First - - - Oldest First - - - - + Date: Thu, 26 Feb 2026 21:55:32 +0530 Subject: [PATCH 06/32] Show environment, product, and team on service request cards --- .../service-requests/ServiceRequestsList.tsx | 82 ++++++++++++------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx index de644fd971..df2e400e4a 100644 --- a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx +++ b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestsList.tsx @@ -23,7 +23,12 @@ import { alpha, useTheme, } from "@wso2/oxygen-ui"; -import { Calendar, FileText, User } from "@wso2/oxygen-ui-icons-react"; +import { + Calendar, + Layers, + Package, + Users, +} from "@wso2/oxygen-ui-icons-react"; import type { JSX } from "react"; import type { CaseListItem } from "@models/responses"; import { @@ -77,6 +82,9 @@ export default function ServiceRequestsList({ const StatusIcon = getStatusIcon(sr.status?.label); const colorPath = getStatusColor(sr.status?.label); const resolvedColor = resolveColorFromTheme(colorPath, theme); + const assignedLabel = getAssignedEngineerLabel(sr.assignedEngineer); + const environmentLabel = sr.deployment?.label; + const productLabel = sr.deployedProduct?.label; return ( @@ -216,34 +224,48 @@ export default function ServiceRequestsList({ color="text.secondary" sx={{ lineHeight: 1 }} > - Created {formatDateTime(sr.createdOn) || "--"} + {formatDateTime(sr.createdOn) || "--"} - {(() => { - const assignedLabel = getAssignedEngineerLabel( - sr.assignedEngineer, - ); - return assignedLabel ? ( - + + - - - Assigned to {assignedLabel} - - - ) : null; - })()} - {sr.deployment?.label && ( + {environmentLabel} + + + )} + {productLabel && ( + + + + {productLabel} + + + )} + {assignedLabel && ( - + - {sr.deployment.label} + {assignedLabel} )} From 818b26049dd7e194d03fe165d9c8a472edb1e72c Mon Sep 17 00:00:00 2001 From: Suwagath S Date: Thu, 26 Feb 2026 22:17:00 +0530 Subject: [PATCH 07/32] Add dedicated service request details route and page --- apps/customer-portal/webapp/src/App.tsx | 12 ++- .../src/pages/ServiceRequestDetailsPage.tsx | 90 +++++++++++++++++++ .../webapp/src/pages/ServiceRequestsPage.tsx | 2 +- 3 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 apps/customer-portal/webapp/src/pages/ServiceRequestDetailsPage.tsx diff --git a/apps/customer-portal/webapp/src/App.tsx b/apps/customer-portal/webapp/src/App.tsx index 543e297de3..64a802cb16 100644 --- a/apps/customer-portal/webapp/src/App.tsx +++ b/apps/customer-portal/webapp/src/App.tsx @@ -33,6 +33,7 @@ import AllConversationsPage from "@pages/AllConversationsPage"; import ConversationDetailsPage from "@pages/ConversationDetailsPage"; import CaseDetailsPage from "@pages/CaseDetailsPage"; import ServiceRequestsPage from "@pages/ServiceRequestsPage"; +import ServiceRequestDetailsPage from "@pages/ServiceRequestDetailsPage"; import NoveraChatPage from "@pages/NoveraChatPage"; import DescribeIssuePage from "@pages/DescribeIssuePage"; import CreateCasePage from "@pages/CreateCasePage"; @@ -99,10 +100,13 @@ export default function App(): JSX.Element { element={} /> - } - /> + + } /> + } + /> + } /> (); + + const { showLoader, hideLoader } = useLoader(); + const { showError } = useErrorBanner(); + + const { + data, + isLoading, + isFetching, + isError, + } = useGetCaseDetails(projectId || "", serviceRequestId || ""); + + const showSkeletons = + isLoading || isFetching || (data === undefined && !isError); + + useEffect(() => { + if (showSkeletons) { + showLoader(); + return () => hideLoader(); + } + hideLoader(); + }, [showSkeletons, showLoader, hideLoader]); + + const hasShownErrorRef = useRef(false); + + useEffect(() => { + hasShownErrorRef.current = false; + }, [serviceRequestId]); + + useEffect(() => { + if (!isError) { + hasShownErrorRef.current = false; + return; + } + if (!hasShownErrorRef.current) { + hasShownErrorRef.current = true; + showError("Could not load service request details."); + } + }, [isError, showError]); + + const handleBack = () => { + navigate(`/${projectId}/support/service-requests`); + }; + + return ( + + ); +} + diff --git a/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx b/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx index 4598d4ea95..e3173b6ba2 100644 --- a/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx +++ b/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx @@ -242,7 +242,7 @@ export default function ServiceRequestsPage(): JSX.Element { serviceRequests={paginatedServiceRequests} isLoading={isCasesAreaLoading} onServiceRequestClick={(sr) => - navigate(`/${projectId}/support/cases/${sr.id}`) + navigate(`/${projectId}/support/service-requests/${sr.id}`) } /> From 86a9136274bb0093d741f6b2f54ec114ac26dfbf Mon Sep 17 00:00:00 2001 From: Suwagath S Date: Fri, 27 Feb 2026 15:42:14 +0530 Subject: [PATCH 08/32] Implement service request detail view UI --- .../ServiceRequestDetailContent.tsx | 752 ++++++++++++++++++ .../src/pages/ServiceRequestDetailsPage.tsx | 8 +- 2 files changed, 756 insertions(+), 4 deletions(-) create mode 100644 apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestDetailContent.tsx diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestDetailContent.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestDetailContent.tsx new file mode 100644 index 0000000000..a3866b0f45 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestDetailContent.tsx @@ -0,0 +1,752 @@ +// 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 { useState, useMemo, type JSX, type ReactElement } from "react"; +import { + Box, + Button, + Chip, + Paper, + Stack, + Typography, + Avatar, + TextField, + alpha, + useTheme, +} from "@wso2/oxygen-ui"; +import { + ArrowLeft, + Calendar, + Layers, + MessageSquare, + Package, + User, + Users, + Folder, + Globe, + CirclePlay, +} from "@wso2/oxygen-ui-icons-react"; +import useGetCasesFilters from "@api/useGetCasesFilters"; +import { usePatchCase } from "@api/usePatchCase"; +import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; +import { useSuccessBanner } from "@context/success-banner/SuccessBannerContext"; +import useGetCaseComments from "@api/useGetCaseComments"; +import { usePostComment } from "@api/usePostComment"; +import type { CaseDetails } from "@models/responses"; +import type { CaseComment } from "@models/responses"; +import { + formatDateTime, + formatRelativeTime, + getAssignedEngineerLabel, + getInitials, + getStatusColor, + getStatusIconElement, + mapSeverityToDisplay, + resolveColorFromTheme, + stripHtml, + hasDisplayableContent, + ACTION_TO_CASE_STATE_LABEL, + getAvailableCaseActions, + toPresentContinuousActionLabel, + toPresentTenseActionLabel, +} from "@utils/support"; +import { CASE_STATUS_ACTIONS, CommentType } from "@constants/supportConstants"; +import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; + +export interface ServiceRequestDetailContentProps { + data: CaseDetails | undefined; + isLoading: boolean; + isError: boolean; + caseId: string; + projectId: string | undefined; + onBack: () => void; +} + +interface RequestDetailSection { + label: string; + value: string; +} + +function parseRequestDetails( + descriptionHtml: string | null | undefined, +): RequestDetailSection[] { + if (!descriptionHtml) { + return []; + } + + if (typeof document === "undefined") { + const text = stripHtml(descriptionHtml); + return text ? [{ label: "Details", value: text }] : []; + } + + const container = document.createElement("div"); + container.innerHTML = descriptionHtml; + + const sections: RequestDetailSection[] = []; + let currentLabel: string | null = null; + let currentValueParts: string[] = []; + + const flush = () => { + const rawLabel = currentLabel?.trim().replace(/:$/, "") ?? ""; + const valueText = stripHtml(currentValueParts.join(" ").trim()); + if (rawLabel && valueText) { + sections.push({ + label: rawLabel, + value: valueText, + }); + } + currentLabel = null; + currentValueParts = []; + }; + + const walk = (node: Node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + const tag = el.tagName.toLowerCase(); + + if (tag === "strong") { + flush(); + currentLabel = el.textContent ?? ""; + return; + } + + if (tag === "br") { + currentValueParts.push("\n"); + return; + } + + Array.from(el.childNodes).forEach(walk); + return; + } + + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? ""; + if (text.trim()) { + currentValueParts.push(text); + } + } + }; + + Array.from(container.childNodes).forEach(walk); + flush(); + + return sections; +} + +export default function ServiceRequestDetailContent({ + data, + isLoading, + isError, + caseId, + projectId, + onBack, +}: ServiceRequestDetailContentProps): JSX.Element { + const theme = useTheme(); + const [commentText, setCommentText] = useState(""); + + const { data: commentsData } = useGetCaseComments(projectId ?? "", caseId, { + offset: 0, + limit: 50, + }); + const postComment = usePostComment(); + + const { data: filterMetadata } = useGetCasesFilters(projectId ?? ""); + const caseStates = filterMetadata?.caseStates; + const patchCase = usePatchCase(projectId ?? "", caseId); + const { showSuccess } = useSuccessBanner(); + const { showError } = useErrorBanner(); + const [pendingActionLabel, setPendingActionLabel] = useState( + null, + ); + + const statusLabel = data?.status?.label; + const severityLabel = data?.severity?.label; + const statusColorPath = getStatusColor(statusLabel ?? undefined); + const resolvedStatusColor = resolveColorFromTheme(statusColorPath, theme); + const statusChipIcon = getStatusIconElement( + statusLabel, + 12, + ) as ReactElement | undefined; + + const assignedLabel = getAssignedEngineerLabel(data?.assignedEngineer); + const environmentLabel = data?.deployment?.label ?? null; + const productLabel = data?.deployedProduct?.label + ? data.deployedProduct.version + ? `${data.deployedProduct.label} ${data.deployedProduct.version}` + : data.deployedProduct.label + : null; + + const raw: Record = + ((data as unknown as Record) ?? {}) || {}; + const srMeta = + (raw.serviceRequestDetails as Record | undefined) ?? + (raw.serviceRequest as Record | undefined) ?? + raw; + + const requestedBy: string | null = + (srMeta.requestedBy as string | undefined) ?? + (srMeta.requester as string | undefined) ?? + (srMeta.requestedFor as string | undefined) ?? + (srMeta.openedBy as string | undefined) ?? + (srMeta.createdBy as string | undefined) ?? + null; + + const requestDetailSections = useMemo( + () => parseRequestDetails(data?.description), + [data?.description], + ); + + const commentsSorted = useMemo(() => { + const list = commentsData?.comments ?? []; + return [...list].sort( + (a, b) => + new Date(a.createdOn).getTime() - new Date(b.createdOn).getTime(), + ); + }, [commentsData?.comments]); + + const commentsToShow = useMemo( + () => commentsSorted.filter(hasDisplayableContent), + [commentsSorted], + ); + + const timelineEntries = useMemo(() => { + const entries: { type: string; label: string; date: string; desc?: string }[] = [ + { + type: "created", + label: "Service Request Created", + date: data?.createdOn ?? "", + desc: "New service request submitted", + }, + ]; + commentsToShow.forEach((c) => { + entries.push({ + type: "comment", + label: "Comment Added", + date: c.createdOn, + desc: `${c.createdBy} - ${formatRelativeTime(c.createdOn)}`, + }); + }); + return entries.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + }, [data?.createdOn, commentsToShow]); + + const handleAddComment = () => { + const content = commentText.trim(); + if (!content) return; + postComment.mutate( + { + caseId, + body: { content, type: CommentType.COMMENT }, + }, + { + onSuccess: () => setCommentText(""), + }, + ); + }; + + if (isLoading) { + return ( + + + + + + + + + {[1, 2, 3, 4].map((i) => ( + + ))} + + + + + ); + } + + if (isError) { + return ( + + + + + ); + } + + return ( + + + + + + + + {data?.number ?? "--"} + + + + Priority: {mapSeverityToDisplay(severityLabel ?? undefined)} + + + + {data?.title ?? "--"} + + + {/* Top SR meta row: Environment, Product, Requested By, Created */} + + + + + + + + + Environment + + + + {environmentLabel ?? "--"} + + + + + + + + + + + Product + + + + {productLabel ?? "--"} + + + + + + + + + + + Requested By + + + + {requestedBy ?? "--"} + + + + + + + + + + + Created + + + + {data?.createdOn + ? formatDateTime(data.createdOn, "long") ?? "--" + : "--"} + + + + + + + + + + + + Request Details + + {requestDetailSections.length > 0 ? ( + requestDetailSections.map((section, index) => ( + + + {section.label} + + + {section.value} + + {index < requestDetailSections.length - 1 && ( + + )} + + )) + ) : ( + + {stripHtml(data?.description ?? "") || "--"} + + )} + + + + + + Communication + + + {commentsToShow.length === 0 ? ( + + No comments yet. + + ) : ( + commentsToShow.map((comment: CaseComment) => ( + + + {getInitials(comment.createdBy)} + + + + {comment.createdBy} • {formatRelativeTime(comment.createdOn)} + + + {stripHtml(comment.content)} + + + + )) + )} + + + setCommentText(e.target.value)} + size="small" + /> + + + + + + + + + Assignment + + + + + + Assigned To: {assignedLabel ?? "--"} + + + {data?.issueType?.label && ( + + + + Categorized: {data.issueType.label} + + + )} + + + + Manage service request status + + + + {CASE_STATUS_ACTIONS.filter((action) => + getAvailableCaseActions(statusLabel).includes(action.label), + ) + .filter((action) => action.label !== "Open Related Case") + .map(({ label, Icon }) => { + const stateLabel = ACTION_TO_CASE_STATE_LABEL[label]; + const stateKeyEntry = caseStates?.find( + (s) => + s.label.toLowerCase() === + (stateLabel ?? "").toLowerCase(), + ); + const stateKey = + stateKeyEntry && !Number.isNaN(Number(stateKeyEntry.id)) + ? Number(stateKeyEntry.id) + : undefined; + const canPatch = stateKey != null && !!caseId; + const isThisPending = + patchCase.isPending && pendingActionLabel === label; + + return ( + + ); + })} + + + + + + + Activity Timeline + + + {timelineEntries.map((entry, idx) => ( + + + + + {entry.label} + + {entry.desc && ( + + {entry.desc} + + )} + + {formatRelativeTime(entry.date)} + + + + ))} + + + + + + Related Information + + + {data?.project?.label && ( + + + + + + + Project + + + + {data.project.label} + + + )} + + + + + + + Last Updated + + + + {data?.updatedOn + ? formatDateTime(data.updatedOn, "long") ?? "--" + : "--"} + + + + + + + + ); +} diff --git a/apps/customer-portal/webapp/src/pages/ServiceRequestDetailsPage.tsx b/apps/customer-portal/webapp/src/pages/ServiceRequestDetailsPage.tsx index a8bc88de2f..0ab02229a8 100644 --- a/apps/customer-portal/webapp/src/pages/ServiceRequestDetailsPage.tsx +++ b/apps/customer-portal/webapp/src/pages/ServiceRequestDetailsPage.tsx @@ -19,11 +19,11 @@ import { useNavigate, useParams } from "react-router"; import { useLoader } from "@context/linear-loader/LoaderContext"; import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; import useGetCaseDetails from "@api/useGetCaseDetails"; -import CaseDetailsContent from "@case-details-details/CaseDetailsContent"; +import ServiceRequestDetailContent from "@components/support/service-requests/ServiceRequestDetailContent"; /** - * ServiceRequestDetailsPage displays details for a single service request. - * It reuses the generic case details layout but is routed under service-requests. + * ServiceRequestDetailsPage displays the SR-specific detail view for a single + * service request. Data is fetched via GET cases/[id]; layout is SR-only. * * URL: /:projectId/support/service-requests/:serviceRequestId */ @@ -77,7 +77,7 @@ export default function ServiceRequestDetailsPage(): JSX.Element { }; return ( - Date: Mon, 2 Mar 2026 09:59:31 +0530 Subject: [PATCH 09/32] feat(customer-portal): add Create Service Request page with catalog flow - Add catalog types and CreateServiceRequestPayload to models - Add useSearchCatalogs and useGetCatalogItemVariables API hooks - Add CatalogSelector with category icons and VariableFormFields - Add CreateServiceRequestPage with deployment/product/catalog/variables flow - Update navigation: ServiceRequestCard, GetHelpDropdown, ServiceRequestsPage - Add route /support/service-requests/create - Extend usePostCase to accept CreateServiceRequestPayload --- apps/customer-portal/webapp/src/App.tsx | 5 + .../src/api/useGetCatalogItemVariables.ts | 72 ++++ .../webapp/src/api/usePostCase.ts | 26 +- .../webapp/src/api/useSearchCatalogs.ts | 73 ++++ .../common/header/GetHelpDropdown.tsx | 4 +- .../request-cards/ServiceRequestCard.tsx | 5 +- .../service-requests/CatalogSelector.tsx | 350 ++++++++++++++++ .../service-requests/VariableFormFields.tsx | 155 +++++++ .../webapp/src/constants/apiConstants.ts | 2 + .../webapp/src/models/requests.ts | 11 + .../webapp/src/models/responses.ts | 34 ++ .../src/pages/CreateServiceRequestPage.tsx | 391 ++++++++++++++++++ .../webapp/src/pages/ServiceRequestsPage.tsx | 6 +- 13 files changed, 1113 insertions(+), 21 deletions(-) create mode 100644 apps/customer-portal/webapp/src/api/useGetCatalogItemVariables.ts create mode 100644 apps/customer-portal/webapp/src/api/useSearchCatalogs.ts create mode 100644 apps/customer-portal/webapp/src/components/support/service-requests/CatalogSelector.tsx create mode 100644 apps/customer-portal/webapp/src/components/support/service-requests/VariableFormFields.tsx create mode 100644 apps/customer-portal/webapp/src/pages/CreateServiceRequestPage.tsx diff --git a/apps/customer-portal/webapp/src/App.tsx b/apps/customer-portal/webapp/src/App.tsx index 64a802cb16..9f27006374 100644 --- a/apps/customer-portal/webapp/src/App.tsx +++ b/apps/customer-portal/webapp/src/App.tsx @@ -34,6 +34,7 @@ import ConversationDetailsPage from "@pages/ConversationDetailsPage"; import CaseDetailsPage from "@pages/CaseDetailsPage"; import ServiceRequestsPage from "@pages/ServiceRequestsPage"; import ServiceRequestDetailsPage from "@pages/ServiceRequestDetailsPage"; +import CreateServiceRequestPage from "@pages/CreateServiceRequestPage"; import NoveraChatPage from "@pages/NoveraChatPage"; import DescribeIssuePage from "@pages/DescribeIssuePage"; import CreateCasePage from "@pages/CreateCasePage"; @@ -102,6 +103,10 @@ export default function App(): JSX.Element { } /> + } + /> } diff --git a/apps/customer-portal/webapp/src/api/useGetCatalogItemVariables.ts b/apps/customer-portal/webapp/src/api/useGetCatalogItemVariables.ts new file mode 100644 index 0000000000..a10a0b4ef1 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/useGetCatalogItemVariables.ts @@ -0,0 +1,72 @@ +// 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 { useAuthApiClient } from "@context/AuthApiContext"; +import { useLogger } from "@hooks/useLogger"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import type { CatalogItemVariablesResponse } from "@models/responses"; + +/** + * Fetches catalog item variables (GET /catalogs/:catalogId/items/:itemId). + * + * @param {string} catalogId - The catalog ID. + * @param {string} itemId - The catalog item ID. + * @returns {UseQueryResult} The query result. + */ +export function useGetCatalogItemVariables( + catalogId: string, + itemId: string, +): UseQueryResult { + const logger = useLogger(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useQuery({ + queryKey: [ApiQueryKeys.CATALOG_ITEM_VARIABLES, catalogId, itemId], + queryFn: async (): Promise => { + logger.debug( + `Fetching catalog item variables for catalog ${catalogId}, item ${itemId}`, + ); + + 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}/catalogs/${catalogId}/items/${itemId}`; + + const response = await fetchFn(requestUrl, { method: "GET" }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Error fetching catalog item variables: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`, + ); + } + + const data: CatalogItemVariablesResponse = await response.json(); + logger.debug( + `Catalog item variables fetched for catalog ${catalogId}, item ${itemId}`, + data, + ); + return data; + }, + enabled: + !!catalogId && !!itemId && isSignedIn && !isAuthLoading, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/customer-portal/webapp/src/api/usePostCase.ts b/apps/customer-portal/webapp/src/api/usePostCase.ts index 439311c4df..86dca2e2eb 100644 --- a/apps/customer-portal/webapp/src/api/usePostCase.ts +++ b/apps/customer-portal/webapp/src/api/usePostCase.ts @@ -18,32 +18,40 @@ import { useMutation, type UseMutationResult } from "@tanstack/react-query"; import { useAsgardeo } from "@asgardeo/react"; import { useLogger } from "@hooks/useLogger"; import { useAuthApiClient } from "@context/AuthApiContext"; -import type { CreateCaseRequest } from "@models/requests"; +import type { + CreateCaseRequest, + CreateServiceRequestPayload, +} from "@models/requests"; import type { CreateCaseResponse } from "@models/responses"; /** - * Posts a new support case to the backend. + * Posts a new support case or service request to the backend. * - * @returns {UseMutationResult} Mutation result. + * @returns {UseMutationResult} Mutation result. */ export function usePostCase(): UseMutationResult< CreateCaseResponse, Error, - CreateCaseRequest + CreateCaseRequest | CreateServiceRequestPayload > { const logger = useLogger(); const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); const fetchFn = useAuthApiClient(); - return useMutation({ + return useMutation< + CreateCaseResponse, + Error, + CreateCaseRequest | CreateServiceRequestPayload + >({ mutationFn: async ( - body: CreateCaseRequest, + body: CreateCaseRequest | CreateServiceRequestPayload, ): Promise => { logger.debug("[usePostCase] Request payload:", { ...body, - description: body.description - ? `${body.description.slice(0, 80)}...` - : "", + description: + "description" in body && body.description + ? `${body.description.slice(0, 80)}...` + : "", }); try { diff --git a/apps/customer-portal/webapp/src/api/useSearchCatalogs.ts b/apps/customer-portal/webapp/src/api/useSearchCatalogs.ts new file mode 100644 index 0000000000..b8d59b7511 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/useSearchCatalogs.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { useAsgardeo } from "@asgardeo/react"; +import { useAuthApiClient } from "@context/AuthApiContext"; +import { useLogger } from "@hooks/useLogger"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import type { CatalogSearchResponse } from "@models/responses"; + +/** + * Searches catalogs for a deployed product (POST /deployments/products/:deployedProductId/catalogs/search). + * + * @param {string} deployedProductId - The deployed product ID. + * @returns {UseQueryResult} The query result. + */ +export function useSearchCatalogs( + deployedProductId: string, +): UseQueryResult { + const logger = useLogger(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const fetchFn = useAuthApiClient(); + + return useQuery({ + queryKey: [ApiQueryKeys.CATALOGS_SEARCH, deployedProductId], + queryFn: async (): Promise => { + logger.debug( + `Searching catalogs for deployed product ID: ${deployedProductId}`, + ); + + 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/products/${deployedProductId}/catalogs/search`; + + const response = await fetchFn(requestUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Error searching catalogs: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`, + ); + } + + const data: CatalogSearchResponse = await response.json(); + logger.debug( + `Catalogs fetched for deployed product ID: ${deployedProductId}`, + data, + ); + return data; + }, + enabled: !!deployedProductId && isSignedIn && !isAuthLoading, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/customer-portal/webapp/src/components/common/header/GetHelpDropdown.tsx b/apps/customer-portal/webapp/src/components/common/header/GetHelpDropdown.tsx index bd06566535..59b6ea3c8e 100644 --- a/apps/customer-portal/webapp/src/components/common/header/GetHelpDropdown.tsx +++ b/apps/customer-portal/webapp/src/components/common/header/GetHelpDropdown.tsx @@ -79,9 +79,7 @@ export default function GetHelpDropdown(): JSX.Element { const handleServiceRequest = () => { handleClose(); if (projectId) { - navigate(`/${projectId}/support/chat/create-case?type=service_request`, { - state: { skipChat: true }, - }); + navigate(`/${projectId}/support/service-requests/create`); } }; diff --git a/apps/customer-portal/webapp/src/components/support/request-cards/ServiceRequestCard.tsx b/apps/customer-portal/webapp/src/components/support/request-cards/ServiceRequestCard.tsx index 1da648c6b9..fdf78bd682 100644 --- a/apps/customer-portal/webapp/src/components/support/request-cards/ServiceRequestCard.tsx +++ b/apps/customer-portal/webapp/src/components/support/request-cards/ServiceRequestCard.tsx @@ -44,10 +44,7 @@ export default function ServiceRequestCard(): JSX.Element { onSecondaryClick={() => navigate("service-requests")} primaryButton={{ label: "New Service Request", - onClick: () => - navigate("chat/create-case", { - state: { caseType: "Service Request", skipChat: true }, - }), + onClick: () => navigate("service-requests/create"), icon: Server, }} /> diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/CatalogSelector.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/CatalogSelector.tsx new file mode 100644 index 0000000000..426e512ec7 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/service-requests/CatalogSelector.tsx @@ -0,0 +1,350 @@ +// 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 { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Paper, + Skeleton, + Typography, +} from "@wso2/oxygen-ui"; +import { + ChevronDown, + FileText, + Network, + Package, + RefreshCw, + Settings, + Shield, + ShieldAlert, +} from "@wso2/oxygen-ui-icons-react"; +import type { ComponentType, JSX } from "react"; +import type { Catalog, CatalogItem } from "@models/responses"; + +export interface CatalogSelectorProps { + catalogs: Catalog[] | undefined; + isLoading: boolean; + selectedCatalogId: string; + selectedCatalogItemId: string; + onSelectCatalogItem: (catalogId: string, catalogItemId: string) => void; +} + +interface CatalogIconConfig { + Icon: ComponentType<{ size?: number; color?: string }>; + iconColor: string; + bgColor: string; +} + +const CATALOG_ICON_MAP: Array<{ + pattern: RegExp; + config: CatalogIconConfig; +}> = [ + { + pattern: /operational\s*request/i, + config: { + Icon: RefreshCw, + iconColor: "#3B82F6", + bgColor: "#DBEAFE", + }, + }, + { + pattern: /certificate/i, + config: { + Icon: Shield, + iconColor: "#22C55E", + bgColor: "#DCFCE7", + }, + }, + { + pattern: /information\s*request/i, + config: { + Icon: FileText, + iconColor: "#8B5CF6", + bgColor: "#EDE9FE", + }, + }, + { + pattern: /artifact\s*deployment/i, + config: { + Icon: Package, + iconColor: "#F97316", + bgColor: "#FFEDD5", + }, + }, + { + pattern: /security|vulnerability|patching/i, + config: { + Icon: ShieldAlert, + iconColor: "#EC4899", + bgColor: "#FCE7F3", + }, + }, + { + pattern: /product\s*change/i, + config: { + Icon: Settings, + iconColor: "#6366F1", + bgColor: "#EEF2FF", + }, + }, + { + pattern: /infrastructure/i, + config: { + Icon: Network, + iconColor: "#06B6D4", + bgColor: "#CFFAFE", + }, + }, +]; + +function getCatalogIconConfig(catalogName: string): CatalogIconConfig { + const match = CATALOG_ICON_MAP.find(({ pattern }) => + pattern.test(catalogName), + ); + return ( + match?.config ?? { + Icon: FileText, + iconColor: "#6B7280", + bgColor: "#F3F4F6", + } + ); +} + +/** + * Renders catalogs as expandable groups with selectable catalog items. + * Matches the design: category cards with colored icons, option counts, and flat sub-items. + * + * @param {CatalogSelectorProps} props - Catalogs data and selection state. + * @returns {JSX.Element} The catalog selector UI. + */ +export default function CatalogSelector({ + catalogs, + isLoading, + selectedCatalogId, + selectedCatalogItemId, + onSelectCatalogItem, +}: CatalogSelectorProps): JSX.Element { + if (isLoading) { + return ( + + + Select Request Type * + + + Choose the type of service request you need from the categories below + + + + + + + + ); + } + + if (!catalogs?.length) { + return ( + + + Select Request Type * + + + Choose the type of service request you need from the categories below + + + No catalogs available for the selected product. + + + ); + } + + return ( + + + Select Request Type{" "} + + * + + + + Choose the type of service request you need from the categories below + + + + {catalogs.map((catalog) => { + const { Icon, iconColor, bgColor } = getCatalogIconConfig( + catalog.name, + ); + const itemCount = catalog.catalogItems?.length ?? 0; + const optionsLabel = + itemCount === 1 ? "1 option" : `${itemCount} options`; + + return ( + + + + + } + sx={{ + borderRadius: 0, + px: 2, + py: 1.5, + minHeight: 56, + "& .MuiAccordionSummary-expandIconWrapper.Mui-expanded": { + "& .accordion-chevron": { + transform: "rotate(180deg)", + }, + }, + }} + > + + + + + + + {catalog.name} + + + {optionsLabel} + + + + + + + {(catalog.catalogItems ?? []).map((item: CatalogItem, idx) => { + const isSelected = + selectedCatalogId === catalog.id && + selectedCatalogItemId === item.id; + return ( + + onSelectCatalogItem(catalog.id, item.id) + } + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "flex-start", + width: "100%", + px: 2, + py: 1.5, + textAlign: "left", + border: "none", + borderBottom: + idx < (catalog.catalogItems?.length ?? 1) - 1 + ? 1 + : "none", + borderColor: "divider", + background: isSelected + ? "action.selected" + : "transparent", + cursor: "pointer", + borderRadius: 0, + }} + > + + {item.label} + + + ); + })} + {(!catalog.catalogItems || + catalog.catalogItems.length === 0) && ( + + + No items in this catalog + + + )} + + + + ); + })} + + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/VariableFormFields.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/VariableFormFields.tsx new file mode 100644 index 0000000000..a87ee23640 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/service-requests/VariableFormFields.tsx @@ -0,0 +1,155 @@ +// 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, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Skeleton, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import type { JSX } from "react"; +import type { CatalogItemVariable } from "@models/responses"; + +export interface VariableFormFieldsProps { + variables: CatalogItemVariable[] | undefined; + isLoading: boolean; + values: Record; + onChange: (variableId: string, value: string) => void; +} + +const VARIABLE_TYPE_SINGLE_LINE = "Single Line Text"; +const VARIABLE_TYPE_MULTI_LINE = "Multi Line Text"; +const VARIABLE_TYPE_SELECT = "Select Box"; +const VARIABLE_TYPE_CHECKBOX = "Checkbox"; +const VARIABLE_TYPE_RADIO = "Radio Buttons"; + +/** + * Renders dynamic form fields based on catalog item variable types. + * + * @param {VariableFormFieldsProps} props - Variables schema and current values. + * @returns {JSX.Element} The variable form fields. + */ +export default function VariableFormFields({ + variables, + isLoading, + values, + onChange, +}: VariableFormFieldsProps): JSX.Element { + if (isLoading) { + return ( + + + Request Details + + + + + + + + ); + } + + if (!variables?.length) { + return ( + + + Request Details + + + No additional fields required for this request type. + + + ); + } + + const sortedVariables = [...variables].sort((a, b) => a.order - b.order); + + return ( + + + Request Details + + + {sortedVariables.map((variable) => { + const value = values[variable.id] ?? ""; + const type = variable.type ?? VARIABLE_TYPE_SINGLE_LINE; + + if ( + type === VARIABLE_TYPE_SELECT || + type === VARIABLE_TYPE_RADIO || + type === VARIABLE_TYPE_CHECKBOX + ) { + return ( + + + {variable.questionText} + + + + ); + } + + if (type === VARIABLE_TYPE_MULTI_LINE) { + return ( + onChange(variable.id, e.target.value)} + sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} + /> + ); + } + + return ( + onChange(variable.id, e.target.value)} + sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} + /> + ); + })} + + + ); +} diff --git a/apps/customer-portal/webapp/src/constants/apiConstants.ts b/apps/customer-portal/webapp/src/constants/apiConstants.ts index 04b177e7e7..2eb36e2175 100644 --- a/apps/customer-portal/webapp/src/constants/apiConstants.ts +++ b/apps/customer-portal/webapp/src/constants/apiConstants.ts @@ -51,6 +51,8 @@ export const ApiQueryKeys = { CHANGE_REQUESTS: "change-requests", CHANGE_REQUEST_DETAILS: "change-request-details", CHANGE_REQUEST_COMMENTS: "change-request-comments", + CATALOGS_SEARCH: "catalogs-search", + CATALOG_ITEM_VARIABLES: "catalog-item-variables", } as const; // Constants for API-related mutation keys. diff --git a/apps/customer-portal/webapp/src/models/requests.ts b/apps/customer-portal/webapp/src/models/requests.ts index 47995c5bf4..8910bf1353 100644 --- a/apps/customer-portal/webapp/src/models/requests.ts +++ b/apps/customer-portal/webapp/src/models/requests.ts @@ -108,6 +108,17 @@ export interface CreateCaseRequest { parentCaseId?: string; } +// Request body for creating a service request (POST /cases with caseType: "service_request"). +export interface CreateServiceRequestPayload { + caseType: "service_request"; + projectId: string; + deploymentId: string; + deployedProductId: string; + catalogId: string; + catalogItemId: string; + variables: { id: string; value: string }[]; +} + // Request body for product vulnerabilities search. export interface ProductVulnerabilitiesSearchRequest { filters?: { diff --git a/apps/customer-portal/webapp/src/models/responses.ts b/apps/customer-portal/webapp/src/models/responses.ts index f01b3232e6..6b88b50948 100644 --- a/apps/customer-portal/webapp/src/models/responses.ts +++ b/apps/customer-portal/webapp/src/models/responses.ts @@ -1013,3 +1013,37 @@ export interface UpdateLevelEntry { // Response for POST /updates/levels/search (map keyed by update level string). export type UpdateLevelsSearchResponse = Record; + +// Catalog item within a catalog (from POST /deployments/products/:id/catalogs/search). +export interface CatalogItem { + id: string; + label: string; +} + +// Catalog with its items (from POST /deployments/products/:id/catalogs/search). +export interface Catalog { + id: string; + name: string; + catalogItems: CatalogItem[]; +} + +// Response for POST /deployments/products/:id/catalogs/search. +export interface CatalogSearchResponse { + catalogs: Catalog[]; + totalRecords: number; + limit?: number; + offset?: number; +} + +// Variable definition for a catalog item (from GET /catalogs/:catalogId/items/:itemId). +export interface CatalogItemVariable { + id: string; + questionText: string; + order: number; + type: string; +} + +// Response for GET /catalogs/:catalogId/items/:itemId. +export interface CatalogItemVariablesResponse { + variables: CatalogItemVariable[]; +} diff --git a/apps/customer-portal/webapp/src/pages/CreateServiceRequestPage.tsx b/apps/customer-portal/webapp/src/pages/CreateServiceRequestPage.tsx new file mode 100644 index 0000000000..2a6351162b --- /dev/null +++ b/apps/customer-portal/webapp/src/pages/CreateServiceRequestPage.tsx @@ -0,0 +1,391 @@ +// 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, + FormControl, + Grid, + MenuItem, + Paper, + Select, + Skeleton, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { ArrowLeft, CircleCheck } from "@wso2/oxygen-ui-icons-react"; +import { + useState, + useMemo, + useCallback, + useEffect, + type FormEvent, + type JSX, +} from "react"; +import { useNavigate, useParams } from "react-router"; +import { useGetProjectDeployments } from "@api/useGetProjectDeployments"; +import { useGetDeploymentsProducts } from "@api/useGetDeploymentsProducts"; +import { useSearchCatalogs } from "@api/useSearchCatalogs"; +import { useGetCatalogItemVariables } from "@api/useGetCatalogItemVariables"; +import { usePostCase } from "@api/usePostCase"; +import useGetProjectDetails from "@api/useGetProjectDetails"; +import { useLoader } from "@context/linear-loader/LoaderContext"; +import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; +import { useSuccessBanner } from "@context/success-banner/SuccessBannerContext"; +import { + getBaseDeploymentOptions, + getBaseProductOptions, + resolveDeploymentMatch, + resolveProductId, +} from "@utils/caseCreation"; +import type { CreateServiceRequestPayload } from "@models/requests"; +import CatalogSelector from "@components/support/service-requests/CatalogSelector"; +import VariableFormFields from "@components/support/service-requests/VariableFormFields"; + +/** + * CreateServiceRequestPage - multi-step form to create a service request. + * Flow: Select Deployment -> Select Product -> Select Catalog Item -> Fill Variables -> Submit. + * + * @returns {JSX.Element} The Create Service Request page. + */ +export default function CreateServiceRequestPage(): JSX.Element { + const navigate = useNavigate(); + const { projectId } = useParams<{ projectId: string }>(); + const { showLoader, hideLoader } = useLoader(); + const { showError } = useErrorBanner(); + const { showSuccess } = useSuccessBanner(); + + const [deployment, setDeployment] = useState(""); + const [product, setProduct] = useState(""); + const [selectedCatalogId, setSelectedCatalogId] = useState(""); + const [selectedCatalogItemId, setSelectedCatalogItemId] = useState(""); + const [variableValues, setVariableValues] = useState>( + {}, + ); + + const { data: projectDetails, isLoading: isProjectLoading } = + useGetProjectDetails(projectId || ""); + const { data: projectDeployments, isLoading: isDeploymentsLoading } = + useGetProjectDeployments(projectId || ""); + + const baseDeploymentOptions = getBaseDeploymentOptions(projectDeployments); + const selectedDeploymentMatch = useMemo( + () => resolveDeploymentMatch(deployment, projectDeployments, undefined), + [deployment, projectDeployments], + ); + const selectedDeploymentId = selectedDeploymentMatch?.id ?? ""; + + const { + data: deploymentProductsData, + isLoading: deploymentProductsLoading, + } = useGetDeploymentsProducts(selectedDeploymentId); + + const allDeploymentProducts = useMemo( + () => + (deploymentProductsData ?? []).filter((item) => + item.product?.label?.trim(), + ), + [deploymentProductsData], + ); + const baseProductOptions = getBaseProductOptions(allDeploymentProducts); + const sortedProductOptions = useMemo( + () => + [...baseProductOptions].sort((a, b) => + a.label.localeCompare(b.label, undefined, { numeric: true }), + ), + [baseProductOptions], + ); + + const productId = resolveProductId(product, allDeploymentProducts); + const { data: catalogsData, isLoading: isCatalogsLoading } = + useSearchCatalogs(productId); + const { data: variablesData, isLoading: isVariablesLoading } = + useGetCatalogItemVariables(selectedCatalogId, selectedCatalogItemId); + + const { mutate: postCase, isPending: isCreatePending } = usePostCase(); + + const isInitialLoading = + isProjectLoading || isDeploymentsLoading || !projectId; + useEffect(() => { + if (isInitialLoading) { + showLoader(); + } else { + hideLoader(); + } + return () => hideLoader(); + }, [isInitialLoading, showLoader, hideLoader]); + + const handleDeploymentChange = useCallback((value: string) => { + setDeployment(value); + setProduct(""); + setSelectedCatalogId(""); + setSelectedCatalogItemId(""); + setVariableValues({}); + }, []); + + const handleProductChange = useCallback((value: string) => { + setProduct(value); + setSelectedCatalogId(""); + setSelectedCatalogItemId(""); + setVariableValues({}); + }, []); + + const handleSelectCatalogItem = useCallback( + (catalogId: string, catalogItemId: string) => { + setSelectedCatalogId(catalogId); + setSelectedCatalogItemId(catalogItemId); + setVariableValues({}); + }, + [], + ); + + const handleVariableChange = useCallback((variableId: string, value: string) => { + setVariableValues((prev) => ({ ...prev, [variableId]: value })); + }, []); + + const handleBack = () => { + if (window.history.length > 1) { + navigate(-1); + } else if (projectId) { + navigate(`/${projectId}/support/service-requests`); + } else { + navigate("/"); + } + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!projectId) return; + + const deploymentMatch = resolveDeploymentMatch( + deployment, + projectDeployments, + undefined, + ); + if (!deploymentMatch) { + showError("Please select a deployment type."); + return; + } + + if (!productId) { + showError("Please select a product version."); + return; + } + + if (!selectedCatalogId || !selectedCatalogItemId) { + showError("Please select a request type (catalog item)."); + return; + } + + const variables = variablesData?.variables ?? []; + const variablePayload = variables.map((v) => ({ + id: v.id, + value: variableValues[v.id] ?? "", + })); + + if (variablePayload.length === 0) { + showError("At least one variable is required. Please fill in the request details."); + return; + } + + const payload: CreateServiceRequestPayload = { + caseType: "service_request", + projectId, + deploymentId: deploymentMatch.id, + deployedProductId: productId, + catalogId: selectedCatalogId, + catalogItemId: selectedCatalogItemId, + variables: variablePayload, + }; + + postCase(payload, { + onSuccess: (data) => { + showSuccess("Service request created successfully"); + navigate(`/${projectId}/support/service-requests/${data.id}`); + }, + onError: (error) => { + const msg = + error?.message?.trim() || + "We couldn't create your service request. Please try again."; + showError(msg); + }, + }); + }; + + const projectDisplay = projectDetails?.name ?? ""; + const isProductDropdownDisabled = + !selectedDeploymentId || deploymentProductsLoading; + const canSubmit = + !!projectId && + !!selectedDeploymentId && + !!productId && + !!selectedCatalogId && + !!selectedCatalogItemId && + (variablesData?.variables?.length ?? 0) > 0 && + !isCreatePending; + + return ( + + + + + New Service Request + + + + + + + Basic Information + + + + + Project + + + + + + Deployment Type * + + {isDeploymentsLoading ? ( + + ) : ( + + + + )} + + + + Product Version * + + {deploymentProductsLoading ? ( + + ) : ( + + + + )} + + + + + {!!productId && ( + + )} + + {!!selectedCatalogId && !!selectedCatalogItemId && ( + + )} + + + + + + + ); +} diff --git a/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx b/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx index e3173b6ba2..7bfeeb016a 100644 --- a/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx +++ b/apps/customer-portal/webapp/src/pages/ServiceRequestsPage.tsx @@ -36,8 +36,6 @@ import { CaseType } from "@constants/supportConstants"; import ServiceRequestsList from "@components/support/service-requests/ServiceRequestsList"; import ServiceRequestsSearchBar from "@components/support/service-requests/ServiceRequestsSearchBar"; -const SERVICE_REQUEST_TYPE_LABEL = "Service Request"; - export type ServiceRequestStatusFilter = | "all" | "pending" @@ -175,9 +173,7 @@ export default function ServiceRequestsPage(): JSX.Element { }; const handleNewServiceRequest = () => { - navigate(`/${projectId}/support/chat/create-case`, { - state: { caseType: SERVICE_REQUEST_TYPE_LABEL }, - }); + navigate(`/${projectId}/support/service-requests/create`); }; return ( From d397a1f49af61bd8bb5132be4a38543af9318c5f Mon Sep 17 00:00:00 2001 From: Suwagath S Date: Mon, 2 Mar 2026 15:16:11 +0530 Subject: [PATCH 10/32] feat(service-requests): RTE for description, attachments, required asterisks, SR detail styling - Use Rich Text Editor for Description fields in VariableFormFields - Add file input for attachment variables with onAttachmentAdd - Add red asterisk for required fields, remove placeholders - Extend CreateCaseResponse with backend fields (number, createdBy, etc.) - SR detail: activity timeline with vertical flow, assigned/category cards - Communication: chat icons blue (user) vs orange (team) per case chat style --- apps/customer-portal/backend/service.bal | 9 + .../ServiceRequestDetailContent.tsx | 201 +++++-- .../service-requests/VariableFormFields.tsx | 510 +++++++++++++++--- .../webapp/src/models/requests.ts | 5 +- .../webapp/src/models/responses.ts | 7 + .../src/pages/CreateServiceRequestPage.tsx | 94 +++- 6 files changed, 702 insertions(+), 124 deletions(-) diff --git a/apps/customer-portal/backend/service.bal b/apps/customer-portal/backend/service.bal index baf7aa4c8f..3b3ff08689 100644 --- a/apps/customer-portal/backend/service.bal +++ b/apps/customer-portal/backend/service.bal @@ -67,6 +67,15 @@ http:ListenerConfiguration listenerConf = { label: "Customer Portal", id: "cs/customer-portal" } +@http:ServiceConfig { + cors: { + allowOrigins: ["*"], + allowCredentials: true, + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowHeaders: ["*"], + exposeHeaders: ["*"] + } +} service http:InterceptableService / on new http:Listener(9090, listenerConf) { public function createInterceptors() returns http:Interceptor[] => [new authorization:JwtInterceptor(), new ErrorInterceptor()]; diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestDetailContent.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestDetailContent.tsx index a3866b0f45..f9b4b21710 100644 --- a/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestDetailContent.tsx +++ b/apps/customer-portal/webapp/src/components/support/service-requests/ServiceRequestDetailContent.tsx @@ -44,6 +44,7 @@ import { usePatchCase } from "@api/usePatchCase"; import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; import { useSuccessBanner } from "@context/success-banner/SuccessBannerContext"; import useGetCaseComments from "@api/useGetCaseComments"; +import useGetUserDetails from "@api/useGetUserDetails"; import { usePostComment } from "@api/usePostComment"; import type { CaseDetails } from "@models/responses"; import type { CaseComment } from "@models/responses"; @@ -155,6 +156,8 @@ export default function ServiceRequestDetailContent({ onBack, }: ServiceRequestDetailContentProps): JSX.Element { const theme = useTheme(); + const { data: userDetails } = useGetUserDetails(); + const currentUserEmail = userDetails?.email?.toLowerCase() ?? ""; const [commentText, setCommentText] = useState(""); const { data: commentsData } = useGetCaseComments(projectId ?? "", caseId, { @@ -406,7 +409,7 @@ export default function ServiceRequestDetailContent({ - Created + Requested On @@ -487,41 +490,53 @@ export default function ServiceRequestDetailContent({ No comments yet. ) : ( - commentsToShow.map((comment: CaseComment) => ( - - { + const isCurrentUser = + (comment.createdBy?.toLowerCase() ?? "") === currentUserEmail; + const avatarBg = isCurrentUser + ? alpha(theme.palette.info?.light ?? "#0288d1", 0.2) + : alpha(theme.palette.primary?.light ?? "#fa7b3f", 0.2); + const avatarColor = isCurrentUser + ? (theme.palette.info?.main ?? "#0288d1") + : (theme.palette.primary?.main ?? "#fa7b3f"); + return ( + - {getInitials(comment.createdBy)} - - - - {comment.createdBy} • {formatRelativeTime(comment.createdOn)} - - - {stripHtml(comment.content)} - - - - )) + {getInitials(comment.createdBy)} + + + + {comment.createdBy} • {formatRelativeTime(comment.createdOn)} + + + {stripHtml(comment.content)} + + + + ); + }) )} @@ -552,17 +567,54 @@ export default function ServiceRequestDetailContent({ Assignment - - + + Assigned To: {assignedLabel ?? "--"} {data?.issueType?.label && ( - - - - Categorized: {data.issueType.label} + + + + Category: {data.issueType.label} )} @@ -660,25 +712,78 @@ export default function ServiceRequestDetailContent({ Activity Timeline - + {timelineEntries.map((entry, idx) => ( - + > + {idx === 0 ? ( + + ) : entry.type === "comment" ? ( + + ) : ( + + )} + + ))} - + diff --git a/apps/customer-portal/webapp/src/components/support/service-requests/VariableFormFields.tsx b/apps/customer-portal/webapp/src/components/support/service-requests/VariableFormFields.tsx index a87ee23640..5aba767e93 100644 --- a/apps/customer-portal/webapp/src/components/support/service-requests/VariableFormFields.tsx +++ b/apps/customer-portal/webapp/src/components/support/service-requests/VariableFormFields.tsx @@ -15,8 +15,10 @@ import { Box, + Button, FormControl, - InputLabel, + Grid, + IconButton, MenuItem, Paper, Select, @@ -24,14 +26,30 @@ import { TextField, Typography, } from "@wso2/oxygen-ui"; -import type { JSX } from "react"; +import { Upload, X } from "@wso2/oxygen-ui-icons-react"; +import { useEffect, type JSX } from "react"; import type { CatalogItemVariable } from "@models/responses"; +import Editor from "@components/common/rich-text-editor/Editor"; export interface VariableFormFieldsProps { variables: CatalogItemVariable[] | undefined; isLoading: boolean; values: Record; onChange: (variableId: string, value: string) => void; + /** Selected service request type label (e.g. "Configuration Changes"). */ + selectedRequestTypeLabel?: string; + /** Auto-populated context: Project, Deployment, Product (from form above). */ + contextValues?: { + projectDisplay: string; + deploymentDisplay: string; + productDisplay: string; + }; + /** Attachments for case creation (files selected by user). */ + attachments?: Array<{ id: string; file: File }>; + onAttachmentClick?: () => void; + onAttachmentRemove?: (index: number) => void; + /** When user selects file(s) via attachment variable's file input. */ + onAttachmentAdd?: (file: File, variableLabel?: string) => void; } const VARIABLE_TYPE_SINGLE_LINE = "Single Line Text"; @@ -40,10 +58,142 @@ const VARIABLE_TYPE_SELECT = "Select Box"; const VARIABLE_TYPE_CHECKBOX = "Checkbox"; const VARIABLE_TYPE_RADIO = "Radio Buttons"; +/** Description fields use the Rich Text Editor (RTE) per decommissioned portal. */ +function isDescriptionField(questionText: string): boolean { + const normalized = (questionText ?? "") + .replace(/^\s*\*?\s*/, "") + .trim() + .toLowerCase(); + return normalized === "description"; +} + +/** Attachment/File type variants from API (case-insensitive). */ +function isAttachmentType(type: string): boolean { + const t = (type ?? "").trim().toLowerCase(); + return ( + t === "attachment" || + t === "file" || + t === "file upload" || + t.includes("attachment") || + t.includes("file upload") || + (t.includes("file") && !t.includes("configuration")) || + t.includes("attach") + ); +} + +/** Required if questionText starts with asterisk. Returns [displayLabel, isRequired]. */ +function parseRequiredLabel(questionText: string): { + label: string; + isRequired: boolean; +} { + const raw = (questionText ?? "").trim(); + const isRequired = /^\s*\*/.test(raw); + const label = raw.replace(/^\s*\*?\s*/, "").trim() || raw; + return { label, isRequired }; +} + +const CONTEXT_FIELD_PATTERNS: Array<{ + pattern: RegExp; + getValue: (ctx: { + projectDisplay: string; + deploymentDisplay: string; + productDisplay: string; + }) => string; +}> = [ + { pattern: /^project$/i, getValue: (c) => c.projectDisplay }, + { pattern: /^deployments?$/i, getValue: (c) => c.deploymentDisplay }, + { pattern: /^product$/i, getValue: (c) => c.productDisplay }, + { pattern: /^wso2\s*product$/i, getValue: (c) => c.productDisplay }, + { pattern: /^environment$/i, getValue: (c) => c.deploymentDisplay }, +]; + +/** Fields hidden from customers but still sent in the payload (internal/system use). */ +const HIDDEN_FIELD_PATTERNS: RegExp[] = [ + /^case\s*type$/i, + /^service\s*request\s*category$/i, + /^classification$/i, + /^class\s*fication$/i, + /^srns$/i, + /^state$/i, + /^assignment\s*group$/i, + /^assigned\s*to$/i, + /^priority$/i, + /^impact$/i, +]; + +function isContextField( + questionText: string, + contextValues?: VariableFormFieldsProps["contextValues"], +): boolean { + if (!contextValues) return false; + const normalized = questionText?.trim().toLowerCase() ?? ""; + return CONTEXT_FIELD_PATTERNS.some(({ pattern }) => pattern.test(normalized)); +} + +function getContextValue( + questionText: string, + contextValues: { + projectDisplay: string; + deploymentDisplay: string; + productDisplay: string; + }, +): string { + const normalized = questionText?.trim().toLowerCase() ?? ""; + const match = CONTEXT_FIELD_PATTERNS.find(({ pattern }) => + pattern.test(normalized), + ); + return match?.getValue(contextValues) ?? ""; +} + +function isHiddenField(questionText: string): boolean { + const normalized = questionText?.trim().toLowerCase() ?? ""; + return HIDDEN_FIELD_PATTERNS.some((p) => p.test(normalized)); +} + + +/** Label with optional red asterisk for required fields. */ +function FieldLabel({ + questionText, + isRequired, +}: { + questionText: string; + isRequired: boolean; +}): JSX.Element { + const { label } = parseRequiredLabel(questionText); + return ( + + {label} + {isRequired && ( + + * + + )} + + ); +} + +/** Deduplicate variables by questionText - keep first occurrence. */ +function deduplicateVariables( + variables: CatalogItemVariable[], +): CatalogItemVariable[] { + const seen = new Set(); + return variables.filter((v) => { + const key = (v.questionText ?? "").trim().toLowerCase(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + /** * Renders dynamic form fields based on catalog item variable types. + * Adopts Create Case styling: Paper, Grid, outlined TextFields. * - * @param {VariableFormFieldsProps} props - Variables schema and current values. + * @param {VariableFormFieldsProps} props - Variables schema, values, and context. * @returns {JSX.Element} The variable form fields. */ export default function VariableFormFields({ @@ -51,7 +201,40 @@ export default function VariableFormFields({ isLoading, values, onChange, + selectedRequestTypeLabel, + contextValues, + attachments = [], + onAttachmentClick, + onAttachmentRemove, + onAttachmentAdd, }: VariableFormFieldsProps): JSX.Element { + const sortedVariables = variables + ? [...variables].sort((a, b) => a.order - b.order) + : []; + const deduplicatedForDisplay = deduplicateVariables(sortedVariables); + + const allContextFields = contextValues + ? sortedVariables.filter((v) => + isContextField(v.questionText, contextValues), + ) + : []; + const contextFieldsForDisplay = deduplicateVariables(allContextFields); + const userFields = deduplicatedForDisplay.filter( + (v) => + !isContextField(v.questionText, contextValues) && + !isHiddenField(v.questionText ?? ""), + ); + + useEffect(() => { + if (!contextValues || !allContextFields.length) return; + allContextFields.forEach((v) => { + const val = getContextValue(v.questionText, contextValues); + if (val && (values[v.id] ?? "") !== val) { + onChange(v.id, val); + } + }); + }, [contextValues, allContextFields, onChange]); + if (isLoading) { return ( @@ -59,15 +242,15 @@ export default function VariableFormFields({ Request Details - - - + + + ); } - if (!variables?.length) { + if (!sortedVariables.length) { return ( @@ -80,75 +263,260 @@ export default function VariableFormFields({ ); } - const sortedVariables = [...variables].sort((a, b) => a.order - b.order); + const renderField = (variable: CatalogItemVariable) => { + const value = values[variable.id] ?? ""; + const type = (variable.type ?? VARIABLE_TYPE_SINGLE_LINE).trim(); + const isContext = contextValues + ? isContextField(variable.questionText, contextValues) + : false; + const displayValue = isContext + ? getContextValue(variable.questionText!, contextValues!) + : value; + + if ( + type === VARIABLE_TYPE_SELECT || + type === VARIABLE_TYPE_RADIO || + type === VARIABLE_TYPE_CHECKBOX + ) { + return ( + + + + + + + + + ); + } + + if (isDescriptionField(variable.questionText ?? "")) { + return ( + + + + + onChange(variable.id, html)} + disabled={isContext} + placeholder="" + minHeight={150} + maxHeight="300px" + showToolbar + toolbarVariant="full" + onAttachmentClick={onAttachmentClick} + attachments={attachments.map((a) => a.file)} + onAttachmentRemove={onAttachmentRemove} + /> + + ); + } + + if ( + (type === VARIABLE_TYPE_MULTI_LINE || + (variable.type ?? "").toLowerCase().includes("multi")) && + !isDescriptionField(variable.questionText ?? "") + ) { + return ( + + + + + onChange(variable.id, e.target.value)} + disabled={isContext} + sx={{ + "& .MuiOutlinedInput-root": { borderRadius: 0 }, + }} + /> + + ); + } + + if (isAttachmentType(type)) { + return ( + + + + + + {onAttachmentAdd ? ( + + ) : ( + + )} + {attachments.length > 0 && ( + + {attachments.map((item, idx) => ( + + + {item.file.name} + + {onAttachmentRemove && ( + onAttachmentRemove(idx)} + aria-label={`Remove ${item.file.name}`} + sx={{ borderRadius: 0 }} + > + + + )} + + ))} + + )} + + + ); + } + + return ( + + + + + onChange(variable.id, e.target.value)} + disabled={isContext} + sx={{ + "& .MuiOutlinedInput-root": { borderRadius: 0 }, + }} + /> + + ); + }; return ( - - Request Details + + + Request Details + + {selectedRequestTypeLabel && ( + + Service request type: {selectedRequestTypeLabel} + + )} + + + + * Indicates required + + {contextFieldsForDisplay.length > 0 && contextValues && ( + + + Context (from selection above) + + + {contextFieldsForDisplay.map((v) => ( + + + + {v.questionText} + + + + + ))} + + + )} + - {sortedVariables.map((variable) => { - const value = values[variable.id] ?? ""; - const type = variable.type ?? VARIABLE_TYPE_SINGLE_LINE; - - if ( - type === VARIABLE_TYPE_SELECT || - type === VARIABLE_TYPE_RADIO || - type === VARIABLE_TYPE_CHECKBOX - ) { - return ( - - - {variable.questionText} - - - - ); - } - - if (type === VARIABLE_TYPE_MULTI_LINE) { - return ( - onChange(variable.id, e.target.value)} - sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} - /> - ); - } - - return ( - onChange(variable.id, e.target.value)} - sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} - /> - ); - })} + + {userFields.map(renderField)} + ); diff --git a/apps/customer-portal/webapp/src/models/requests.ts b/apps/customer-portal/webapp/src/models/requests.ts index 8910bf1353..c8b2ada1f4 100644 --- a/apps/customer-portal/webapp/src/models/requests.ts +++ b/apps/customer-portal/webapp/src/models/requests.ts @@ -108,15 +108,16 @@ export interface CreateCaseRequest { parentCaseId?: string; } -// Request body for creating a service request (POST /cases with caseType: "service_request"). +// Request body for creating a service request (POST /cases with type: "service_request"). export interface CreateServiceRequestPayload { - caseType: "service_request"; + type: "service_request"; projectId: string; deploymentId: string; deployedProductId: string; catalogId: string; catalogItemId: string; variables: { id: string; value: string }[]; + attachments?: Array<{ name: string; file: string }>; } // Request body for product vulnerabilities search. diff --git a/apps/customer-portal/webapp/src/models/responses.ts b/apps/customer-portal/webapp/src/models/responses.ts index 6b88b50948..a8e9d492c7 100644 --- a/apps/customer-portal/webapp/src/models/responses.ts +++ b/apps/customer-portal/webapp/src/models/responses.ts @@ -902,8 +902,15 @@ export interface ConversationResponse { } // Response for creating a support case. Used to navigate to case details. +// Backend returns additional fields that can be used to populate SR display. export interface CreateCaseResponse { id: string; + internalId?: string; + number?: string; + createdBy?: string; + createdOn?: string; + state?: { id: string; label: string }; + type?: { id: string; label: string }; } // Product vulnerability item from search response. diff --git a/apps/customer-portal/webapp/src/pages/CreateServiceRequestPage.tsx b/apps/customer-portal/webapp/src/pages/CreateServiceRequestPage.tsx index 2a6351162b..3ef6e822b6 100644 --- a/apps/customer-portal/webapp/src/pages/CreateServiceRequestPage.tsx +++ b/apps/customer-portal/webapp/src/pages/CreateServiceRequestPage.tsx @@ -31,6 +31,7 @@ import { useMemo, useCallback, useEffect, + useRef, type FormEvent, type JSX, } from "react"; @@ -53,6 +54,7 @@ import { import type { CreateServiceRequestPayload } from "@models/requests"; import CatalogSelector from "@components/support/service-requests/CatalogSelector"; import VariableFormFields from "@components/support/service-requests/VariableFormFields"; +import UploadAttachmentModal from "@components/support/case-details/attachments-tab/UploadAttachmentModal"; /** * CreateServiceRequestPage - multi-step form to create a service request. @@ -74,6 +76,11 @@ export default function CreateServiceRequestPage(): JSX.Element { const [variableValues, setVariableValues] = useState>( {}, ); + type AttachmentItem = { id: string; file: File }; + const [attachments, setAttachments] = useState([]); + const attachmentNamesRef = useRef>(new Map()); + const attachmentIdCounterRef = useRef(0); + const [isAttachmentModalOpen, setIsAttachmentModalOpen] = useState(false); const { data: projectDetails, isLoading: isProjectLoading } = useGetProjectDetails(projectId || ""); @@ -114,6 +121,16 @@ export default function CreateServiceRequestPage(): JSX.Element { const { data: variablesData, isLoading: isVariablesLoading } = useGetCatalogItemVariables(selectedCatalogId, selectedCatalogItemId); + const selectedCatalogItemLabel = useMemo(() => { + if (!selectedCatalogId || !selectedCatalogItemId || !catalogsData?.catalogs) + return undefined; + const catalog = catalogsData.catalogs.find((c) => c.id === selectedCatalogId); + const item = catalog?.catalogItems?.find( + (i) => i.id === selectedCatalogItemId, + ); + return item?.label; + }, [catalogsData?.catalogs, selectedCatalogId, selectedCatalogItemId]); + const { mutate: postCase, isPending: isCreatePending } = usePostCase(); const isInitialLoading = @@ -155,6 +172,27 @@ export default function CreateServiceRequestPage(): JSX.Element { setVariableValues((prev) => ({ ...prev, [variableId]: value })); }, []); + const handleAttachmentClick = () => setIsAttachmentModalOpen(true); + + const handleSelectAttachment = (file: File, attachmentName?: string) => { + setAttachments((prev) => { + const sig = `${file.name}-${file.size}-${file.lastModified}`; + if (prev.some((a) => `${a.file.name}-${a.file.size}-${a.file.lastModified}` === sig)) + return prev; + const id = `att-${++attachmentIdCounterRef.current}-${Date.now()}`; + if (attachmentName?.trim()) attachmentNamesRef.current.set(id, attachmentName.trim()); + return [...prev, { id, file }]; + }); + }; + + const handleAttachmentRemove = (index: number) => { + setAttachments((prev) => { + const item = prev[index]; + if (item) attachmentNamesRef.current.delete(item.id); + return prev.filter((_, i) => i !== index); + }); + }; + const handleBack = () => { if (window.history.length > 1) { navigate(-1); @@ -165,7 +203,19 @@ export default function CreateServiceRequestPage(): JSX.Element { } }; - const handleSubmit = (e: FormEvent) => { + const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const s = typeof reader.result === "string" ? reader.result : ""; + const i = s.indexOf(","); + resolve(i >= 0 ? s.slice(i + 1) : s); + }; + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); + reader.readAsDataURL(file); + }); + + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!projectId) return; @@ -200,19 +250,40 @@ export default function CreateServiceRequestPage(): JSX.Element { return; } + let encodedAttachments: Array<{ name: string; file: string }> = []; + if (attachments.length > 0) { + try { + encodedAttachments = await Promise.all( + attachments.map(async (item) => ({ + name: attachmentNamesRef.current.get(item.id) || item.file.name, + file: await fileToBase64(item.file), + })), + ); + } catch (err) { + showError("Failed to process attachments. Please try again."); + return; + } + } + const payload: CreateServiceRequestPayload = { - caseType: "service_request", + type: "service_request", projectId, deploymentId: deploymentMatch.id, deployedProductId: productId, catalogId: selectedCatalogId, catalogItemId: selectedCatalogItemId, variables: variablePayload, + ...(encodedAttachments.length > 0 && { attachments: encodedAttachments }), }; postCase(payload, { onSuccess: (data) => { - showSuccess("Service request created successfully"); + const srNumber = (data as { number?: string }).number; + showSuccess( + srNumber + ? `Service request ${srNumber} created successfully` + : "Service request created successfully", + ); navigate(`/${projectId}/support/service-requests/${data.id}`); }, onError: (error) => { @@ -370,9 +441,26 @@ export default function CreateServiceRequestPage(): JSX.Element { isLoading={isVariablesLoading} values={variableValues} onChange={handleVariableChange} + selectedRequestTypeLabel={selectedCatalogItemLabel} + contextValues={{ + projectDisplay: projectDisplay, + deploymentDisplay: deployment, + productDisplay: + sortedProductOptions.find((p) => p.id === product)?.label ?? "", + }} + attachments={attachments} + onAttachmentClick={handleAttachmentClick} + onAttachmentRemove={handleAttachmentRemove} + onAttachmentAdd={(file, name) => handleSelectAttachment(file, name)} /> )} + setIsAttachmentModalOpen(false)} + onSelect={handleSelectAttachment} + /> +