From 95b30ba14ac665646dad3cb5f847662f34006943 Mon Sep 17 00:00:00 2001 From: Rashmika998 Date: Sun, 19 Jul 2026 19:41:10 +0530 Subject: [PATCH 1/3] [CSM][Web] Align QuickNav search palette with customer-portal's style Restyles the header quick-nav/search palette to match customer-portal's search dropdown while keeping csm-portal's extra command-palette behavior (pinned/recent/pages sections, keyboard nav, Cmd+K): - Cases (live search, plus Pinned/Recent entries snapshotted from the case detail page) render as rich cards with severity/status/case-type chips and updated/assignee metadata, reusing SeverityChip/StateChip. - Swaps the Dialog for Modal + a plain Paper so the theme's translucent "acrylic" background applies, matching the glassy look of customer-portal's dropdown instead of Dialog's more opaque default. - Adds skeleton loading and an illustrated empty state; hides the "Pages" section on the empty-query default view; fixes input autofocus. - Scopes the recent-views localStorage cache to the signed-in user's ID token `sub` claim and wipes it on explicit sign-out (manual + idle timeout), so a previous user's recent/pinned cases can't leak to the next person signing in on a shared machine. Co-Authored-By: Claude Sonnet 5 --- .../empty-state/SearchNoResultsIcon.tsx | 516 ++++++++++++++++++ .../csm-cases/api/useQuickCaseSearch.ts | 28 +- .../csm-cases/pages/CsmCaseDetailPage.tsx | 14 + .../csm-recent/components/QuickNav.tsx | 233 +++++--- .../components/QuickNavCaseCard.tsx | 123 +++++ .../components/QuickNavResultSkeleton.tsx | 50 ++ .../csm-recent/hooks/useRecentViews.test.ts | 4 +- .../csm-recent/hooks/useRecentViews.ts | 89 ++- .../webapp/src/layouts/AppLayout.tsx | 2 + 9 files changed, 988 insertions(+), 71 deletions(-) create mode 100644 apps/csm-portal/webapp/src/components/empty-state/SearchNoResultsIcon.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavResultSkeleton.tsx diff --git a/apps/csm-portal/webapp/src/components/empty-state/SearchNoResultsIcon.tsx b/apps/csm-portal/webapp/src/components/empty-state/SearchNoResultsIcon.tsx new file mode 100644 index 0000000000..4a64ae98bc --- /dev/null +++ b/apps/csm-portal/webapp/src/components/empty-state/SearchNoResultsIcon.tsx @@ -0,0 +1,516 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import type { JSX, SVGProps } from "react"; + +/** + * SearchNoResultsIcon component for displaying no-search-results empty state. + * + * @param {SVGProps} props - SVG props. + * @returns {JSX.Element} The SearchNoResultsIcon component. + */ +const SearchNoResultsIcon = (props: SVGProps): JSX.Element => ( + +); + +export default SearchNoResultsIcon; diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/useQuickCaseSearch.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/useQuickCaseSearch.ts index 89af38f538..a239fb16cb 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/api/useQuickCaseSearch.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/useQuickCaseSearch.ts @@ -17,10 +17,17 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { ApiQueryKeys } from "@constants/apiConstants"; import { useBackendApi } from "@api/backend/client"; +import { severityFromPriority } from "@api/backend/mappers"; import type { BeCaseSearchPayload, BeCaseSearchResponse, + BeCaseType, } from "@api/backend/types"; +import type { + CaseState, + CaseWorkState, + Severity, +} from "@features/csm-dashboard/types/abtDashboard"; /** Don't fire a search until the user has typed something searchable. */ export const QUICK_CASE_MIN_QUERY_LEN = 2; @@ -29,15 +36,23 @@ export const QUICK_CASE_MIN_QUERY_LEN = 2; const QUICK_CASE_LIMIT = 8; /** - * One hit from the global-search case lookup. Carries only what the palette - * needs: the UUID `id` for the `/cases/:id` link and the human-readable - * identity/subject for display. + * One hit from the global-search case lookup. Carries the UUID `id` (for the + * `/cases/:id` link), the human-readable identity/subject, and enough of the + * case's severity/status/ownership for the palette to render a result card + * matching the case list's visual language. */ export interface QuickCaseHit { id: string; caseNumber?: string; wso2CaseId?: string; subject: string; + severity: Severity; + state: CaseState; + workState?: CaseWorkState | null; + caseType?: BeCaseType; + updatedOn?: string; + createdOn?: string; + assigneeName?: string; } /** @@ -70,6 +85,13 @@ export function useQuickCaseSearch( caseNumber: c.number, wso2CaseId: c.internalId, subject: c.subject ?? "(no subject)", + severity: severityFromPriority(c.severity), + state: (c.state ?? "open") as CaseState, + workState: c.workState, + caseType: c.type, + updatedOn: c.updatedOn, + createdOn: c.createdOn, + assigneeName: c.assignedEngineer?.name, })); }, enabled: q.length >= QUICK_CASE_MIN_QUERY_LEN, diff --git a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx index 8683fecdaf..cd49849b69 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx @@ -519,6 +519,20 @@ export default function CsmCaseDetailPage(): JSX.Element { : data.subject, subtitle: `${data.customer} · ${data.projectName}`, href: detailPath, + // Snapshot of the fields QuickNavCaseCard needs, so Pinned/Recent + // entries for this case render the same rich card a live search hit + // does, without the palette re-fetching it. + caseHit: { + caseNumber: data.caseNumber, + wso2CaseId: data.wso2CaseId, + subject: data.subject, + severity: data.severity, + state: data.state, + workState: data.workState, + caseType: data.caseType, + updatedOn: data.updatedAt, + assigneeName: data.assignee !== "Unassigned" ? data.assignee : undefined, + }, }); }, [data, recordView]); diff --git a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNav.tsx b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNav.tsx index 0cf72e7dcb..1bd77a2a44 100644 --- a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNav.tsx +++ b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNav.tsx @@ -17,26 +17,35 @@ import { Box, ButtonBase, - Dialog, + Form, InputBase, + Modal, + Paper, Typography, } from "@wso2/oxygen-ui"; import { Search } from "@wso2/oxygen-ui-icons-react"; -import { useEffect, useMemo, useState, type JSX } from "react"; +import { useEffect, useMemo, useRef, useState, type JSX } from "react"; import { useAsgardeo } from "@asgardeo/react"; import { navigableNavItems } from "@config/csmNavItems"; import { useDebouncedValue } from "@hooks/useDebouncedValue"; -import { useRecentViews } from "@features/csm-recent/hooks/useRecentViews"; +import { + useRecentViews, + type RecentView, +} from "@features/csm-recent/hooks/useRecentViews"; import { kindIcon } from "@features/csm-recent/kindMeta"; +import QuickNavCaseCard from "@features/csm-recent/components/QuickNavCaseCard"; +import QuickNavResultSkeleton from "@features/csm-recent/components/QuickNavResultSkeleton"; +import SearchNoResultsIcon from "@components/empty-state/SearchNoResultsIcon"; import { QUICK_CASE_MIN_QUERY_LEN, useQuickCaseSearch, + type QuickCaseHit, } from "@features/csm-cases/api/useQuickCaseSearch"; import { caseIdLabel } from "@features/csm-cases/utils/caseIdentity"; import { useNavTransition } from "@hooks/useNavTransition"; -type Section = "Cases" | "Pinned" | "Recent" | "Pages"; +type Section = "Cases" | "Pinned" | "Recents" | "Pages"; interface Result { key: string; @@ -45,6 +54,8 @@ interface Result { sublabel?: string; href: string; section: Section; + /** Present only for "Cases" results — renders as a rich card instead of a plain row. */ + caseHit?: QuickCaseHit; } const RECENT_LIMIT = 8; @@ -74,6 +85,14 @@ export default function QuickNav(): JSX.Element | null { // API-backed case lookup: a CS/WSO2 id (or any subject text) resolves to real // cases. Disabled until the query is long enough (see the hook). const caseSearch = useQuickCaseSearch(open ? debouncedQuery : ""); + // True while a case search is in flight (or its result is for a stale + // query) — drives the "Cases" section's skeleton independently of whether + // Pinned/Recent/Pages already have matches to show. + const casesLoading = + trimmedQuery.length >= QUICK_CASE_MIN_QUERY_LEN && + (caseSearch.isFetching || !caseHitsSettled); + + const inputRef = useRef(null); // ⌘K / Ctrl+K toggles the palette — only while signed in, so we don't hijack // the browser shortcut on the sign-in screen (where the palette can't render). @@ -89,6 +108,15 @@ export default function QuickNav(): JSX.Element | null { return () => window.removeEventListener("keydown", onKey); }, [isSignedIn]); + // Focus the input once the palette has mounted. `autoFocus` alone can lose + // a focus-trap race against the Modal claiming focus on open, leaving the + // palette open but requiring a second click before typing works. + useEffect(() => { + if (!open) return; + const id = requestAnimationFrame(() => inputRef.current?.focus()); + return () => cancelAnimationFrame(id); + }, [open]); + const results: Result[] = useMemo(() => { const q = trimmedQuery.toLowerCase(); const match = (...parts: (string | undefined)[]) => @@ -109,10 +137,17 @@ export default function QuickNav(): JSX.Element | null { sublabel: idLabel ? c.subject : undefined, href: `/cases/${c.id}`, section: "Cases" as const, + caseHit: c, }; }) : []; + // A pinned/recent entry for a case carries a severity/status snapshot + // from when it was last visited — render it as the same rich card a live + // case search hit gets, instead of a plain icon+label row. + const toCaseHit = (e: RecentView): QuickCaseHit | undefined => + e.kind === "case" && e.caseHit ? { id: e.id, ...e.caseHit } : undefined; + const pinned: Result[] = recents .filter((e) => e.pinned) .filter((e) => match(e.title, e.subtitle)) @@ -123,6 +158,7 @@ export default function QuickNav(): JSX.Element | null { sublabel: e.subtitle, href: e.href, section: "Pinned", + caseHit: toCaseHit(e), })); const recent: Result[] = recents @@ -135,18 +171,25 @@ export default function QuickNav(): JSX.Element | null { label: e.title, sublabel: e.subtitle, href: e.href, - section: "Recent", + section: "Recents", + caseHit: toCaseHit(e), })); - const pages: Result[] = navigableNavItems() - .filter((i) => match(i.label)) - .map((i) => ({ - key: `page-${i.id}`, - icon: , - label: i.label, - href: i.path, - section: "Pages", - })); + // Pages are worth surfacing when someone types a page name to jump + // straight there, but listing every sidebar page on the empty-query + // default view just duplicates the sidebar itself — so only show this + // section once there's something to match against. + const pages: Result[] = q + ? navigableNavItems() + .filter((i) => match(i.label)) + .map((i) => ({ + key: `page-${i.id}`, + icon: , + label: i.label, + href: i.path, + section: "Pages" as const, + })) + : []; return [...cases, ...pinned, ...recent, ...pages]; }, [recents, trimmedQuery, caseHitsSettled, caseSearch.data]); @@ -196,7 +239,7 @@ export default function QuickNav(): JSX.Element | null { alignItems: "center", gap: 1, height: 36, - width: { xs: 40, sm: 200, md: 260 }, + width: { xs: 40, sm: 340, md: 460, lg: 600 }, px: { xs: 0, sm: 1.25 }, justifyContent: { xs: "center", sm: "flex-start" }, borderRadius: 1, @@ -231,18 +274,41 @@ export default function QuickNav(): JSX.Element | null { - - + - - {results.length === 0 ? ( - - - {trimmedQuery.length >= QUICK_CASE_MIN_QUERY_LEN && - (caseSearch.isFetching || !caseHitsSettled) - ? "Searching cases…" - : "No matches."} + + {casesLoading && ( + + + Cases + + )} + {results.length === 0 ? ( + casesLoading ? null : ( + + + + {trimmedQuery.length === 0 + ? "Nothing pinned or recent yet. Start typing to search cases." + : "No matches."} + + + ) ) : ( results.map((r, i) => { const newSection = i === 0 || results[i - 1].section !== r.section; return ( - + {newSection && ( {r.section} )} - setActive(i)} - onClick={() => choose(r)} - sx={{ - display: "flex", - alignItems: "center", - gap: 1.5, - px: 2, - py: 1, - cursor: "pointer", - bgcolor: i === safeActive ? "action.selected" : undefined, - }} - > - {r.icon} - - - {r.label} - - {r.sublabel && ( - - {r.sublabel} - - )} + {r.caseHit ? ( + + setActive(i)} + onClick={() => choose(r)} + /> - + ) : ( + + setActive(i)} + onClick={() => choose(r)} + sx={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 1.5, + p: 1.25, + width: "100%", + minWidth: 0, + }} + > + {r.icon} + + + {r.label} + + {r.sublabel && ( + + {r.sublabel} + + )} + + + + )} ); }) )} - - + + + ); } diff --git a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx new file mode 100644 index 0000000000..fa3d42c012 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.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 { Chip, Form, Stack, Typography } from "@wso2/oxygen-ui"; +import { Calendar, User } from "@wso2/oxygen-ui-icons-react"; +import type { JSX } from "react"; +import SeverityChip from "@components/SeverityChip"; +import StateChip from "@components/StateChip"; +import RelativeTime from "@components/RelativeTime"; +import { WORK_STATE_LABEL } from "@features/csm-cases/utils/caseWorkState"; +import { CASE_TYPE_LABEL } from "@features/csm-cases/utils/caseType"; +import { caseIdLabel } from "@features/csm-cases/utils/caseIdentity"; +import type { QuickCaseHit } from "@features/csm-cases/api/useQuickCaseSearch"; + +interface QuickNavCaseCardProps { + hit: QuickCaseHit; + active: boolean; + onMouseEnter: () => void; + onClick: () => void; +} + +/** + * Result card for a case hit in the quick-nav palette — mirrors the case + * list's severity/state chips so a search hit reads the same as it would in + * the cases table, just condensed for the palette's row height. + */ +export default function QuickNavCaseCard({ + hit, + active, + onMouseEnter, + onClick, +}: QuickNavCaseCardProps): JSX.Element { + const idLabel = caseIdLabel(hit); + + return ( + + + {idLabel && ( + + {idLabel} + + )} + + + {hit.state === "work_in_progress" && hit.workState && ( + + )} + {hit.caseType && ( + + )} + + + + {hit.subject} + + + + + + + Updated + + + {hit.assigneeName && ( + + + + Assigned to {hit.assigneeName} + + + )} + + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavResultSkeleton.tsx b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavResultSkeleton.tsx new file mode 100644 index 0000000000..687b5fc9a9 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavResultSkeleton.tsx @@ -0,0 +1,50 @@ +// 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"; + +interface QuickNavResultSkeletonProps { + count?: number; +} + +/** + * Placeholder shown while the case search is in flight — matches + * {@link QuickNavCaseCard}'s shape so the palette doesn't jump around once + * real results replace it. + */ +export default function QuickNavResultSkeleton({ + count = 3, +}: QuickNavResultSkeletonProps): JSX.Element { + return ( + + {Array.from({ length: count }, (_, i) => ( + + + + + + + + + + ))} + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-recent/hooks/useRecentViews.test.ts b/apps/csm-portal/webapp/src/features/csm-recent/hooks/useRecentViews.test.ts index 31fd05172a..9941c21f48 100644 --- a/apps/csm-portal/webapp/src/features/csm-recent/hooks/useRecentViews.test.ts +++ b/apps/csm-portal/webapp/src/features/csm-recent/hooks/useRecentViews.test.ts @@ -42,8 +42,10 @@ describe("useRecentViews + useRecordRecentView", () => { }); it("drops persisted entries with an unknown kind", () => { + // No identity has been synced in this test (`useSyncRecentViewsIdentity` + // isn't mounted), so reads/writes fall back to the "pending" bucket. localStorage.setItem( - "csm.recentViews.v1", + "csm.recentViews.v1.pending", JSON.stringify([ { kind: "case", id: "1", title: "Case 1", href: "/cases/1", visitedAt: "t" }, { kind: "bogus", id: "2", title: "X", href: "/x", visitedAt: "t" }, diff --git a/apps/csm-portal/webapp/src/features/csm-recent/hooks/useRecentViews.ts b/apps/csm-portal/webapp/src/features/csm-recent/hooks/useRecentViews.ts index 388d251f20..526d1b8c6a 100644 --- a/apps/csm-portal/webapp/src/features/csm-recent/hooks/useRecentViews.ts +++ b/apps/csm-portal/webapp/src/features/csm-recent/hooks/useRecentViews.ts @@ -15,6 +15,8 @@ // under the License. import { useCallback, useEffect, useState } from "react"; +import { useIdTokenClaims } from "@hooks/useIdTokenClaims"; +import type { QuickCaseHit } from "@features/csm-cases/api/useQuickCaseSearch"; export type RecentViewKind = | "case" @@ -52,13 +54,69 @@ export interface RecentView { * ordinary history entries. */ pinned?: boolean; + /** + * Severity/status/ownership snapshot for `kind: "case"` entries, captured + * at record time — lets the quick-nav palette render Pinned/Recent cases + * as the same rich card a live case search hit gets, without re-fetching. + * Absent for non-case kinds and for entries recorded before this field + * existed. + */ + caseHit?: Omit; } -const STORAGE_KEY = "csm.recentViews.v1"; +/** + * Base for the per-user storage key (see {@link currentStorageKey}). Kept + * distinct from the old flat key of the same literal value only for the + * one-time {@link clearLegacyUnscopedKey} cleanup below — every read/write + * always goes through the suffixed key. + */ +const STORAGE_KEY_BASE = "csm.recentViews.v1"; /** Recency cap for UNPINNED entries only — pinned entries are always kept. */ const MAX_ENTRIES = 12; const STORAGE_EVENT = "csm:recent-views-changed"; +/** + * The signed-in user's stable id (ID token `sub` claim), or `null` before + * it's resolved. Every read/write is scoped under this so a different + * engineer signing in on the same browser never sees a previous user's + * recent/pinned cases — see {@link useSyncRecentViewsIdentity}. + */ +let activeUserKey: string | null = null; + +function currentStorageKey(): string { + return `${STORAGE_KEY_BASE}.${activeUserKey ?? "pending"}`; +} + +let legacyKeyCleared = false; +/** One-time removal of the old, unscoped key from before per-user scoping existed. */ +function clearLegacyUnscopedKey(): void { + if (legacyKeyCleared) return; + legacyKeyCleared = true; + try { + localStorage.removeItem(STORAGE_KEY_BASE); + } catch { + /* ignore */ + } +} + +// Wipe the active user's bucket (including pinned entries) on an explicit +// sign-out. Registered once at module load, not tied to any component, so +// it fires reliably regardless of where in the tree sign-out is triggered. +// "app:signing-out" is dispatched ONLY by the manual "Sign out" action +// (UserProfile.tsx) and the idle-timeout auto sign-out (IdleTimeoutProvider.tsx) +// — never by a silent re-auth/token-refresh — so this never clears data out +// from under a user who is still signed in. +if (typeof window !== "undefined") { + window.addEventListener("app:signing-out", () => { + try { + localStorage.removeItem(currentStorageKey()); + } catch { + /* ignore */ + } + window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); + }); +} + /** * Enforce the recency cap on unpinned entries while keeping every pinned entry, * preserving overall (recency) order. Pinned entries are the working set the @@ -80,7 +138,7 @@ function capUnpinned(entries: RecentView[]): RecentView[] { function readStorage(): RecentView[] { try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(currentStorageKey()); if (!raw) return []; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; @@ -104,7 +162,7 @@ function readStorage(): RecentView[] { function writeStorage(entries: RecentView[]): void { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + localStorage.setItem(currentStorageKey(), JSON.stringify(entries)); // Notify other listeners in the same tab — storage events only fire // across tabs, so we dispatch a CustomEvent for in-tab subscribers. window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); @@ -130,6 +188,31 @@ export function useRecentViews(): RecentView[] { return entries; } +/** + * Resolves the signed-in user's stable id (ID token `sub` claim) and scopes + * all recent-views reads/writes to it, so a different engineer signing in on + * the same browser profile never sees a previous user's recent/pinned cases. + * Mount once near the app root (e.g. `AppLayout`) — every `useRecentViews`/ + * `useRecordRecentView`/`toggleRecentViewPin`/`clearRecentViews` call site + * reads whichever bucket this last resolved to, with no changes needed at + * those call sites. + */ +export function useSyncRecentViewsIdentity(): void { + const sub = useIdTokenClaims()?.sub; + + useEffect(() => { + clearLegacyUnscopedKey(); + }, []); + + useEffect(() => { + activeUserKey = sub ?? null; + // Let already-mounted `useRecentViews()` instances pick up the + // now-current bucket immediately, rather than waiting for the next + // unrelated write to trigger a re-read. + window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); + }, [sub]); +} + /** * Record a visit (call from each detail page's effect). De-dupes by `kind+id`, * bumps the existing entry to the top, caps the list at {@link MAX_ENTRIES}. diff --git a/apps/csm-portal/webapp/src/layouts/AppLayout.tsx b/apps/csm-portal/webapp/src/layouts/AppLayout.tsx index b2787d672c..64366d15a8 100644 --- a/apps/csm-portal/webapp/src/layouts/AppLayout.tsx +++ b/apps/csm-portal/webapp/src/layouts/AppLayout.tsx @@ -35,6 +35,7 @@ import { useLoader } from "@context/linear-loader/LoaderContext"; import { useErrorPageContext } from "@context/error-page/ErrorPageContext"; import { useLocation, Outlet } from "react-router"; import IdleTimeoutProvider from "@providers/IdleTimeoutProvider"; +import { useSyncRecentViewsIdentity } from "@features/csm-recent/hooks/useRecentViews"; import GlobalNotificationBanner from "@components/notification-banner/GlobalNotificationBanner"; import HtmlAnnouncementBanner from "@components/announcement-banner/HtmlAnnouncementBanner"; import TopBanner from "@components/top-banner/TopBanner"; @@ -69,6 +70,7 @@ export default function AppLayout({ children }: AppLayoutProps): JSX.Element { const mainContentRef = useRef(null); const { isLoading: isAuthLoading, isSignedIn } = useAsgardeo(); const { isErrorPageDisplayed } = useErrorPageContext(); + useSyncRecentViewsIdentity(); useEffect(() => { if (mainContentRef.current) { From 0d8f4901a871438377495d64dd3ff4aefb64c9b7 Mon Sep 17 00:00:00 2001 From: Rashmika998 Date: Sun, 19 Jul 2026 20:35:55 +0530 Subject: [PATCH 2/3] [CSM][Web] Address CodeRabbit review comments on QuickNav search PR - Map /cases/search's raw state through uiStateFromBe instead of a direct cast, so quick-search cards normalize status the same way the rest of the app does and don't miss the work_in_progress work-state badge. - Guard the case-type chip on both hit.caseType and a defined CASE_TYPE_LABEL entry, mirroring the existing guard in CsmCaseDetailPage. Co-Authored-By: Claude Sonnet 5 --- .../webapp/src/features/csm-cases/api/useQuickCaseSearch.ts | 4 ++-- .../src/features/csm-recent/components/QuickNavCaseCard.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/useQuickCaseSearch.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/useQuickCaseSearch.ts index a239fb16cb..b3cb1f0338 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/api/useQuickCaseSearch.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/useQuickCaseSearch.ts @@ -17,7 +17,7 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { ApiQueryKeys } from "@constants/apiConstants"; import { useBackendApi } from "@api/backend/client"; -import { severityFromPriority } from "@api/backend/mappers"; +import { severityFromPriority, uiStateFromBe } from "@api/backend/mappers"; import type { BeCaseSearchPayload, BeCaseSearchResponse, @@ -86,7 +86,7 @@ export function useQuickCaseSearch( wso2CaseId: c.internalId, subject: c.subject ?? "(no subject)", severity: severityFromPriority(c.severity), - state: (c.state ?? "open") as CaseState, + state: uiStateFromBe(c.state), workState: c.workState, caseType: c.type, updatedOn: c.updatedOn, diff --git a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx index fa3d42c012..ffa74fbbe3 100644 --- a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx +++ b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx @@ -82,7 +82,7 @@ export default function QuickNavCaseCard({ sx={{ fontWeight: 600 }} /> )} - {hit.caseType && ( + {hit.caseType && CASE_TYPE_LABEL[hit.caseType] && ( Date: Sun, 19 Jul 2026 21:16:41 +0530 Subject: [PATCH 3/3] [CSM][Web] Address AI review comments on QuickNav search PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip the "Cases" skeleton during a background refetch of already-visible results (a stale-but-cached query re-run no longer renders two "Cases" headers at once). - Scope every useRecentViews/useRecordRecentView call site to resolve its own active-user key instead of relying on one component elsewhere in the tree having already done so — activeUserKey is a per-tab JS variable, so a component mounted first (or the only one mounted in a fresh tab) could otherwise get stuck reading the "pending" bucket indefinitely while the real data sat under the correct per-user key. - Migrate any views recorded before identity resolved (the "pending" bucket) into the resolved user's bucket instead of stranding them. - Extract WorkStateChip (mirroring SeverityChip/StateChip) to remove the duplicated inline work-state Chip between CasesList and QuickNavCaseCard. - Re-indent the Modal/Paper wrapper block in QuickNav.tsx. - Add CsmCaseDetail.assigneeName (raw assignee name, no "Unassigned" fallback) so the quick-nav case-hit snapshot no longer infers "no assignee" by string-comparing against a display label. Co-Authored-By: Claude Sonnet 5 --- .../webapp/src/components/WorkStateChip.tsx | 45 ++++ .../csm-cases/api/useGetCsmCaseDetail.ts | 4 +- .../csm-cases/components/CasesList.tsx | 11 +- .../csm-cases/pages/CsmCaseDetailPage.tsx | 2 +- .../src/features/csm-cases/types/csmCases.ts | 7 + .../csm-recent/components/QuickNav.tsx | 240 +++++++++--------- .../components/QuickNavCaseCard.tsx | 10 +- .../csm-recent/hooks/useRecentViews.ts | 114 +++++++-- 8 files changed, 269 insertions(+), 164 deletions(-) create mode 100644 apps/csm-portal/webapp/src/components/WorkStateChip.tsx diff --git a/apps/csm-portal/webapp/src/components/WorkStateChip.tsx b/apps/csm-portal/webapp/src/components/WorkStateChip.tsx new file mode 100644 index 0000000000..d7574ec3d0 --- /dev/null +++ b/apps/csm-portal/webapp/src/components/WorkStateChip.tsx @@ -0,0 +1,45 @@ +// 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 { Chip } from "@wso2/oxygen-ui"; +import type { JSX } from "react"; +import type { CaseWorkState } from "@features/csm-dashboard/types/abtDashboard"; +import { WORK_STATE_LABEL } from "@features/csm-cases/utils/caseWorkState"; + +interface WorkStateChipProps { + workState: CaseWorkState; + size?: "small" | "medium"; +} + +/** + * The single source of truth for the work sub-state badge (Ongoing/Paused) + * shown beside a `work_in_progress` case's state chip — same "single source + * of truth" role `SeverityChip`/`StateChip` play for their own badges. + */ +export default function WorkStateChip({ + workState, + size = "small", +}: WorkStateChipProps): JSX.Element { + return ( + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts index 5767784fcc..2251f7f4ba 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts @@ -38,7 +38,8 @@ function detailFromBeCase( const customer = account?.name ?? "—"; // createdBy.name can be empty for unhydrated users, so fall back to the email. const reporter = c.createdBy?.name?.trim() || c.createdBy?.email; - const assignee = c.assignedEngineer?.name?.trim() || "Unassigned"; + const assigneeName = c.assignedEngineer?.name?.trim() || undefined; + const assignee = assigneeName || "Unassigned"; // "Is me" by comparing the assignee's email (the only stable identity the FE // shares with the JWT) to the signed-in user's, case-insensitively. Falls back // to false when either is absent — e.g. the data source doesn't return the @@ -71,6 +72,7 @@ function detailFromBeCase( ? { id: c.relatedCase.id, caseNumber: c.relatedCase.number } : undefined, assignee, + assigneeName, assigneeIsMe, slaClockType: "ack", minutesToBreach: 0, diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx index f777d5f404..be9e1636ff 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx @@ -16,7 +16,6 @@ import { Box, - Chip, Skeleton, TableSortLabel, Typography, @@ -28,7 +27,7 @@ import { preloadRoute } from "@utils/routePreloaders"; import RelativeTime from "@components/RelativeTime"; import SeverityChip from "@components/SeverityChip"; import StateChip from "@components/StateChip"; -import { WORK_STATE_LABEL } from "@features/csm-cases/utils/caseWorkState"; +import WorkStateChip from "@components/WorkStateChip"; import type { CsmCaseRow } from "@features/csm-cases/types/csmCases"; import type { CasesSortOrder } from "@features/csm-cases/utils/casesSort"; @@ -263,13 +262,7 @@ export default function CasesList({ > {c.state === "work_in_progress" && c.workState && ( - + )} diff --git a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx index cd49849b69..dc432c0ebd 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx @@ -531,7 +531,7 @@ export default function CsmCaseDetailPage(): JSX.Element { workState: data.workState, caseType: data.caseType, updatedOn: data.updatedAt, - assigneeName: data.assignee !== "Unassigned" ? data.assignee : undefined, + assigneeName: data.assigneeName, }, }); }, [data, recordView]); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts b/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts index b3d27d72f2..d2824399d0 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts @@ -395,6 +395,13 @@ export interface CsmCaseDetail extends CsmCaseRow { createdBy?: string; /** Email of the creator — used to tell a WSO2 engineer from a customer. */ createdByEmail?: string; + /** + * Raw assigned-engineer name, with no "Unassigned" display fallback baked + * in (unlike `assignee`) — for callers that need to tell "actually + * unassigned" apart from a display label, without string-comparing + * against that fallback. + */ + assigneeName?: string; customerContext: CaseCustomerContext; productContext: CaseProductContext; watchers: CaseWatcher[]; diff --git a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNav.tsx b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNav.tsx index 1bd77a2a44..fd9f840171 100644 --- a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNav.tsx +++ b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNav.tsx @@ -91,6 +91,12 @@ export default function QuickNav(): JSX.Element | null { const casesLoading = trimmedQuery.length >= QUICK_CASE_MIN_QUERY_LEN && (caseSearch.isFetching || !caseHitsSettled); + // Skeleton only while there's nothing to show yet — `isFetching` also + // covers a background refetch of an already-settled, already-rendered + // query (e.g. re-typing a query after the 15s staleTime), where + // `caseSearch.data` still holds the previous results. Without this, + // the skeleton block and the real "Cases" section would render together. + const showCasesSkeleton = casesLoading && !caseSearch.data; const inputRef = useRef(null); @@ -309,131 +315,131 @@ export default function QuickNav(): JSX.Element | null { onKeyDown={onListKeyDown} sx={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }} > - - - { - setQuery(e.target.value); - setActive(0); + - + > + + { + setQuery(e.target.value); + setActive(0); + }} + inputProps={{ "aria-label": "Quick nav search" }} + /> + - - {casesLoading && ( - - - Cases - - - - )} - {results.length === 0 ? ( - casesLoading ? null : ( - - - - {trimmedQuery.length === 0 - ? "Nothing pinned or recent yet. Start typing to search cases." - : "No matches."} + + {showCasesSkeleton && ( + + + Cases + - ) - ) : ( - results.map((r, i) => { - const newSection = i === 0 || results[i - 1].section !== r.section; - return ( - - {newSection && ( - - {r.section} - - )} - {r.caseHit ? ( - - setActive(i)} - onClick={() => choose(r)} - /> - - ) : ( - - setActive(i)} - onClick={() => choose(r)} + )} + {results.length === 0 ? ( + casesLoading ? null : ( + + + + {trimmedQuery.length === 0 + ? "Nothing pinned or recent yet. Start typing to search cases." + : "No matches."} + + + ) + ) : ( + results.map((r, i) => { + const newSection = i === 0 || results[i - 1].section !== r.section; + return ( + + {newSection && ( + - {r.icon} - - - {r.label} - - {r.sublabel && ( - - {r.sublabel} + {r.section} + + )} + {r.caseHit ? ( + + setActive(i)} + onClick={() => choose(r)} + /> + + ) : ( + + setActive(i)} + onClick={() => choose(r)} + sx={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 1.5, + p: 1.25, + width: "100%", + minWidth: 0, + }} + > + {r.icon} + + + {r.label} - )} - - - - )} - - ); - }) - )} - + {r.sublabel && ( + + {r.sublabel} + + )} + + + + )} + + ); + }) + )} + diff --git a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx index ffa74fbbe3..230cb913cd 100644 --- a/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx +++ b/apps/csm-portal/webapp/src/features/csm-recent/components/QuickNavCaseCard.tsx @@ -19,8 +19,8 @@ import { Calendar, User } from "@wso2/oxygen-ui-icons-react"; import type { JSX } from "react"; import SeverityChip from "@components/SeverityChip"; import StateChip from "@components/StateChip"; +import WorkStateChip from "@components/WorkStateChip"; import RelativeTime from "@components/RelativeTime"; -import { WORK_STATE_LABEL } from "@features/csm-cases/utils/caseWorkState"; import { CASE_TYPE_LABEL } from "@features/csm-cases/utils/caseType"; import { caseIdLabel } from "@features/csm-cases/utils/caseIdentity"; import type { QuickCaseHit } from "@features/csm-cases/api/useQuickCaseSearch"; @@ -74,13 +74,7 @@ export default function QuickNavCaseCard({ {hit.state === "work_in_progress" && hit.workState && ( - + )} {hit.caseType && CASE_TYPE_LABEL[hit.caseType] && ( !target.some((t) => t.kind === p.kind && t.id === p.id), + ), + ]); + localStorage.setItem(targetKey, JSON.stringify(merged)); + localStorage.removeItem(pendingKey); + } catch { + /* ignore */ + } +} + function writeStorage(entries: RecentView[]): void { try { localStorage.setItem(currentStorageKey(), JSON.stringify(entries)); @@ -171,8 +208,53 @@ function writeStorage(entries: RecentView[]): void { } } +/** + * Resolves THIS component instance's view of the signed-in user's stable id + * (ID token `sub` claim) and syncs it into the shared `activeUserKey`, so + * `currentStorageKey()` resolves to that user's bucket. `activeUserKey` is a + * plain module variable — scoped to this tab's JS runtime, not shared across + * browser tabs — so every hook below calls this itself rather than trusting + * that some *other* component elsewhere in the tree already resolved it + * first. Without that, a component mounted before whichever one owned the + * sync (or the very first component in a freshly opened tab) could read the + * still-`null`/"pending" bucket indefinitely, with the real data sitting + * untouched under the correct per-user key. + */ +function useSyncActiveUserKey(): void { + const sub = useIdTokenClaims()?.sub; + + useEffect(() => { + clearLegacyUnscopedKey(); + }, []); + + useEffect(() => { + // Fold in anything recorded during this session's own pre-resolution + // window before switching the active bucket over. + if (sub && activeUserKey === null) { + migratePendingBucket(sub); + } + activeUserKey = sub ?? null; + // Let every `useRecentViews()` instance in this tab — including ones + // that resolved their own `sub` earlier and are just listening — pick up + // the now-current bucket immediately, rather than waiting for the next + // unrelated write to trigger a re-read. + window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); + }, [sub]); +} + +/** + * Kept as a separate export so a component can make the sync-on-mount + * ordering explicit (e.g. near the app root) — but every hook below already + * calls {@link useSyncActiveUserKey} itself, so mounting this isn't required + * for correctness. + */ +export function useSyncRecentViewsIdentity(): void { + useSyncActiveUserKey(); +} + /** Read-only access to the recent-views list. */ export function useRecentViews(): RecentView[] { + useSyncActiveUserKey(); const [entries, setEntries] = useState(() => readStorage()); useEffect(() => { @@ -188,31 +270,6 @@ export function useRecentViews(): RecentView[] { return entries; } -/** - * Resolves the signed-in user's stable id (ID token `sub` claim) and scopes - * all recent-views reads/writes to it, so a different engineer signing in on - * the same browser profile never sees a previous user's recent/pinned cases. - * Mount once near the app root (e.g. `AppLayout`) — every `useRecentViews`/ - * `useRecordRecentView`/`toggleRecentViewPin`/`clearRecentViews` call site - * reads whichever bucket this last resolved to, with no changes needed at - * those call sites. - */ -export function useSyncRecentViewsIdentity(): void { - const sub = useIdTokenClaims()?.sub; - - useEffect(() => { - clearLegacyUnscopedKey(); - }, []); - - useEffect(() => { - activeUserKey = sub ?? null; - // Let already-mounted `useRecentViews()` instances pick up the - // now-current bucket immediately, rather than waiting for the next - // unrelated write to trigger a re-read. - window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); - }, [sub]); -} - /** * Record a visit (call from each detail page's effect). De-dupes by `kind+id`, * bumps the existing entry to the top, caps the list at {@link MAX_ENTRIES}. @@ -220,6 +277,7 @@ export function useSyncRecentViewsIdentity(): void { export function useRecordRecentView(): ( entry: Omit, ) => void { + useSyncActiveUserKey(); return useCallback((entry: Omit) => { const now = new Date().toISOString(); const current = readStorage();