From 501a13eb094051bf9d3a95d90a9e06a7f382ea0b Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Mon, 6 Jul 2026 18:10:52 +0530 Subject: [PATCH 1/4] [CSM Portal] show state transitions and field updates in case activity stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the case activity feed's lifecycle lane to the new field-change activity endpoint. Previously the lane was always empty because the audit list was hardcoded to an empty array on the client, so state, severity, and assignee changes never appeared in the timeline even though the backend now records them. Comments, work notes, chat, and attachments keep reading from their existing endpoints/hooks — this only adds the field-change lane alongside them. --- .../webapp/src/api/backend/types.ts | 48 +++++++ .../webapp/src/constants/apiConstants.ts | 1 + .../api/useCsmCaseActivities.test.ts | 108 +++++++++++++++ .../csm-cases/api/useCsmCaseActivities.ts | 96 +++++++++++++ .../features/csm-cases/api/usePatchCsmCase.ts | 9 ++ .../components/CaseActivitiesFeed.test.tsx | 126 ++++++++++++++++++ .../components/CaseActivitiesFeed.tsx | 52 +++++++- .../components/CaseDetailWidgets.tsx | 1 + .../csm-cases/pages/CsmCaseDetailPage.tsx | 9 +- .../src/features/csm-cases/types/csmCases.ts | 19 ++- 10 files changed, 462 insertions(+), 7 deletions(-) create mode 100644 apps/csm-portal/webapp/src/features/csm-cases/api/useCsmCaseActivities.test.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-cases/api/useCsmCaseActivities.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-cases/components/CaseActivitiesFeed.test.tsx diff --git a/apps/csm-portal/webapp/src/api/backend/types.ts b/apps/csm-portal/webapp/src/api/backend/types.ts index 04a3a302a8..adbc1cbbe0 100644 --- a/apps/csm-portal/webapp/src/api/backend/types.ts +++ b/apps/csm-portal/webapp/src/api/backend/types.ts @@ -556,6 +556,54 @@ export interface BeCommentSearchResponse extends BeSearchResponseBase { comments?: BeComment[]; } +// --------------------------------------------------------------------------- +// Case activities (unified comment / attachment / field-change stream) +// --------------------------------------------------------------------------- + +/** One field changed within a single audited save-transaction. */ +export interface BeFieldChange { + /** Wire field name (e.g. `state`, `priority`, `assignedEngineer`). */ + field: string; + /** Human-readable label for the field (e.g. "State", "Severity"). */ + fieldLabel: string; + /** Absent/empty when the field was previously unset. */ + previousValue?: string; + /** Absent/empty when the field was cleared. */ + newValue?: string; +} + +export type BeCaseActivityType = "comment" | "attachment" | "field_change"; + +/** + * One entry from `POST /cases/{id}/activities/search`. Shared fields are + * present on every entry regardless of `type`; `changes` is populated only + * for `type === "field_change"`. This endpoint intentionally excludes work + * notes — the comments/work-notes feed continues to read from + * `/cases/{id}/comments/search` (see {@link BeComment}). + */ +export interface BeCaseActivityEntry { + id: string; + type: BeCaseActivityType; + content?: string; + createdOn: string; + createdBy?: string; + createdByFirstName?: string; + createdByLastName?: string; + createdByFullName?: string; + /** Only present on `type === "field_change"` entries. */ + changes?: BeFieldChange[]; +} + +export interface BeCaseActivitiesSearchPayload { + pagination?: BePagination; + /** Whether the response should include `field_change` entries. */ + includeFieldChanges?: boolean; +} + +export interface BeCaseActivitiesSearchResponse extends BeSearchResponseBase { + activity?: BeCaseActivityEntry[]; +} + // --------------------------------------------------------------------------- // Attachments // --------------------------------------------------------------------------- diff --git a/apps/csm-portal/webapp/src/constants/apiConstants.ts b/apps/csm-portal/webapp/src/constants/apiConstants.ts index c4dc92b0ab..4d898684ee 100644 --- a/apps/csm-portal/webapp/src/constants/apiConstants.ts +++ b/apps/csm-portal/webapp/src/constants/apiConstants.ts @@ -90,6 +90,7 @@ export const ApiQueryKeys = { CSM_CASE_DETAIL: "csm-case-detail", CSM_CASE_COMMENTS: "csm-case-comments", CSM_CASE_ATTACHMENTS: "csm-case-attachments", + CSM_CASE_ACTIVITIES: "csm-case-activities", CSM_CASE_SLAS: "csm-case-slas", CSM_PROJECTS: "csm-projects", CSM_PROJECT_DETAIL: "csm-project-detail", diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/useCsmCaseActivities.test.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/useCsmCaseActivities.test.ts new file mode 100644 index 0000000000..83e76ca3de --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/useCsmCaseActivities.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { describe, expect, it, vi } from "vitest"; +import type { BeCaseActivityEntry } from "@api/backend/types"; + +// The module also exports `useGetCsmCaseActivities`, which imports the +// backend client; that client throws at import time if the runtime config +// (`CSM_PORTAL_BACKEND_BASE_URL`) isn't present, which it isn't under +// vitest. Stub it out — this test only exercises the pure mapper. +vi.mock("@api/backend/client", () => ({ + useBackendApi: vi.fn(), +})); + +const { auditEntryFromBeActivity } = await import("./useCsmCaseActivities"); + +describe("auditEntryFromBeActivity", () => { + it("maps a field_change entry with a preferred display-name order", () => { + const entry: BeCaseActivityEntry = { + id: "fc-1", + type: "field_change", + createdOn: "2026-07-01T00:00:00Z", + createdBy: "jane.doe@example.com", + createdByFirstName: "Jane", + createdByLastName: "Doe", + createdByFullName: "Jane Doe", + changes: [ + { + field: "state", + fieldLabel: "State", + previousValue: "In Progress", + newValue: "Resolved", + }, + ], + }; + + const mapped = auditEntryFromBeActivity(entry); + + expect(mapped).toEqual({ + id: "fc-1", + kind: "field_change", + actor: "Jane Doe", + createdAt: "2026-07-01T00:00:00Z", + changes: [ + { + field: "state", + fieldLabel: "State", + previousValue: "In Progress", + newValue: "Resolved", + }, + ], + }); + }); + + it("falls back to first+last name, then the bare email, when fullName is absent", () => { + const noFullName: BeCaseActivityEntry = { + id: "fc-2", + type: "field_change", + createdOn: "2026-07-01T00:00:00Z", + createdByFirstName: "Jane", + createdByLastName: "Doe", + changes: [], + }; + expect(auditEntryFromBeActivity(noFullName).actor).toBe("Jane Doe"); + + const emailOnly: BeCaseActivityEntry = { + id: "fc-3", + type: "field_change", + createdOn: "2026-07-01T00:00:00Z", + createdBy: "jane.doe@example.com", + changes: [], + }; + expect(auditEntryFromBeActivity(emailOnly).actor).toBe( + "jane.doe@example.com", + ); + + const nothing: BeCaseActivityEntry = { + id: "fc-4", + type: "field_change", + createdOn: "2026-07-01T00:00:00Z", + changes: [], + }; + expect(auditEntryFromBeActivity(nothing).actor).toBe("Unknown"); + }); + + it("defaults changes to an empty array when absent", () => { + const entry: BeCaseActivityEntry = { + id: "fc-5", + type: "field_change", + createdOn: "2026-07-01T00:00:00Z", + createdByFullName: "Jane Doe", + }; + expect(auditEntryFromBeActivity(entry).changes).toEqual([]); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/useCsmCaseActivities.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/useCsmCaseActivities.ts new file mode 100644 index 0000000000..51ecdb0ed2 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/useCsmCaseActivities.ts @@ -0,0 +1,96 @@ +// 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 { ApiQueryKeys, BE_MAX_PAGE_LIMIT } from "@constants/apiConstants"; +import { useBackendApi } from "@api/backend/client"; +import type { + BeCaseActivitiesSearchPayload, + BeCaseActivitiesSearchResponse, + BeCaseActivityEntry, +} from "@api/backend/types"; +import type { CaseAuditEntry } from "@features/csm-cases/types/csmCases"; + +/** Page size used when loading the field-change lane. Capped by the BE; see BE_MAX_PAGE_LIMIT. */ +const ACTIVITIES_PAGE_LIMIT = BE_MAX_PAGE_LIMIT; + +/** Best display name off an activity entry's flat author fields. */ +function activityAuthorName(entry: BeCaseActivityEntry): string { + const full = entry.createdByFullName?.trim(); + if (full) return full; + const composed = [entry.createdByFirstName, entry.createdByLastName] + .filter((p) => p && p.trim()) + .join(" ") + .trim(); + if (composed) return composed; + return entry.createdBy?.trim() || "Unknown"; +} + +/** Map one backend `field_change` activity entry onto a {@link CaseAuditEntry}. */ +export function auditEntryFromBeActivity( + entry: BeCaseActivityEntry, +): CaseAuditEntry { + return { + id: entry.id, + kind: "field_change", + actor: activityAuthorName(entry), + createdAt: entry.createdOn, + changes: (entry.changes ?? []).map((c) => ({ + field: c.field, + fieldLabel: c.fieldLabel, + previousValue: c.previousValue, + newValue: c.newValue, + })), + }; +} + +/** + * Load the audited field/state-change lane for a case. In LIVE mode calls + * `POST /cases/{id}/activities/search` with a single wide page (limit capped + * at BE_MAX_PAGE_LIMIT) and `includeFieldChanges: true`, then filters the + * response down to `type === "field_change"` entries — this endpoint also + * returns `comment`/`attachment` entries, but those lanes keep reading from + * their existing hooks (`useGetCsmCaseComments` / `useGetCsmCaseAttachments`), + * so they are ignored here to avoid a second, divergent read path. Notably + * this endpoint excludes work notes, so it must never replace the comments + * hook. + */ +export function useGetCsmCaseActivities( + caseId: string | undefined, +): UseQueryResult { + const api = useBackendApi(); + + return useQuery({ + queryKey: [ApiQueryKeys.CSM_CASE_ACTIVITIES, caseId ?? ""], + queryFn: async (): Promise => { + if (!caseId) return []; + + const payload: BeCaseActivitiesSearchPayload = { + pagination: { offset: 0, limit: ACTIVITIES_PAGE_LIMIT }, + includeFieldChanges: true, + }; + const response = await api.post< + BeCaseActivitiesSearchPayload, + BeCaseActivitiesSearchResponse + >(`/cases/${encodeURIComponent(caseId)}/activities/search`, payload); + return (response.activity ?? []) + .filter((a) => a.type === "field_change") + .map(auditEntryFromBeActivity); + }, + enabled: !!caseId, + staleTime: 10_000, + }); +} diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/usePatchCsmCase.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/usePatchCsmCase.ts index a7188ceacd..c379f27e9d 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/api/usePatchCsmCase.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/usePatchCsmCase.ts @@ -57,6 +57,12 @@ export function usePatchCsmCase( queryKey: [ApiQueryKeys.CSM_CASE_DETAIL, caseId ?? ""], }); queryClient.invalidateQueries({ queryKey: [ApiQueryKeys.CSM_CASES] }); + // A state/severity/assignee/watcher patch is audited server-side, so + // refresh the activity/field-change lane too — otherwise the new + // lifecycle entry wouldn't show until the next unrelated refetch. + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.CSM_CASE_ACTIVITIES, caseId ?? ""], + }); }, }); } @@ -86,6 +92,9 @@ export function usePatchCsmCaseById(): ( queryKey: [ApiQueryKeys.CSM_CASE_DETAIL, caseId], }); queryClient.invalidateQueries({ queryKey: [ApiQueryKeys.CSM_CASES] }); + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.CSM_CASE_ACTIVITIES, caseId], + }); }, [api, queryClient], ); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActivitiesFeed.test.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActivitiesFeed.test.tsx new file mode 100644 index 0000000000..0843c808bd --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActivitiesFeed.test.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import CaseActivitiesFeed from "@features/csm-cases/components/CaseActivitiesFeed"; +import type { CaseAuditEntry } from "@features/csm-cases/types/csmCases"; + +describe("CaseActivitiesFeed", () => { + it("renders a field_change entry with old/new values", () => { + const entry: CaseAuditEntry = { + id: "fc-1", + kind: "field_change", + actor: "Jane Doe", + createdAt: "2026-07-01T00:00:00Z", + changes: [ + { + field: "state", + fieldLabel: "State", + previousValue: "In Progress", + newValue: "Resolved", + }, + ], + }; + + const { container } = render( + , + ); + + expect(screen.getByText("State:")).toBeInTheDocument(); + // The line reads "State: Resolved was In Progress" across sibling text + // nodes (new value, then the struck-through old value in its own span) — + // assert on the row's combined text rather than a single text node. + expect(container.textContent).toContain("Resolved"); + expect(container.textContent).toContain("was"); + expect(container.textContent).toContain("In Progress"); + expect(screen.getByText(/Jane Doe/)).toBeInTheDocument(); + }); + + it("renders multiple field changes from one field_change entry", () => { + const entry: CaseAuditEntry = { + id: "fc-2", + kind: "field_change", + actor: "Jane Doe", + createdAt: "2026-07-01T00:00:00Z", + changes: [ + { + field: "state", + fieldLabel: "State", + previousValue: "New", + newValue: "In Progress", + }, + { + field: "assignedEngineer", + fieldLabel: "Assignee", + previousValue: undefined, + newValue: "John Smith", + }, + ], + }; + + render( + , + ); + + expect(screen.getByText("State:")).toBeInTheDocument(); + expect(screen.getByText("Assignee:")).toBeInTheDocument(); + expect(screen.getByText("John Smith")).toBeInTheDocument(); + // No previous value on the "set" change — no strike-through text for it. + expect(screen.queryByText("cleared")).not.toBeInTheDocument(); + }); + + it("shows a cleared marker when newValue is empty", () => { + const entry: CaseAuditEntry = { + id: "fc-3", + kind: "field_change", + actor: "Jane Doe", + createdAt: "2026-07-01T00:00:00Z", + changes: [ + { + field: "assignedEngineer", + fieldLabel: "Assignee", + previousValue: "John Smith", + newValue: undefined, + }, + ], + }; + + render( + , + ); + + expect(screen.getByText("cleared")).toBeInTheDocument(); + expect(screen.getByText("John Smith")).toBeInTheDocument(); + }); + + it("falls back to description when changes is absent", () => { + const entry: CaseAuditEntry = { + id: "fc-4", + kind: "state_change", + actor: "System", + description: "Case moved to In Progress", + createdAt: "2026-07-01T00:00:00Z", + }; + + render( + , + ); + + expect(screen.getByText("Case moved to In Progress")).toBeInTheDocument(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActivitiesFeed.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActivitiesFeed.tsx index b592a713be..cb0fd8992b 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActivitiesFeed.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActivitiesFeed.tsx @@ -74,8 +74,40 @@ const AUDIT_ICON: Record = { attachment_added: , sla_breached: , created: , + field_change: , }; +/** One "