Skip to content
1 change: 1 addition & 0 deletions apps/csm-portal/webapp/src/constants/apiConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_SLAS: "csm-case-slas",
CSM_PROJECTS: "csm-projects",
CSM_PROJECT_DETAIL: "csm-project-detail",
CSM_ACCOUNTS: "csm-accounts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ import type { CsmCaseDetail } from "@features/csm-cases/types/csmCases";
* Build a CsmCaseDetail from the rich `BeCaseView`. The view embeds the
* account / project / deployment / deployed-product / reporter as objects, so
* their names (and the account tier) come straight off the response with no
* extra lookups. Side widgets the backend doesn't return yet (SLA clocks,
* watchers, tags, time logs, attachments, linked items) default to empty /
* placeholder values.
* extra lookups. Side widgets the backend doesn't return yet (watchers, tags,
* time logs, attachments, linked items) default to empty / placeholder
* values.
*/
function detailFromBeCase(
c: BeCaseView,
Expand Down Expand Up @@ -99,7 +99,6 @@ function detailFromBeCase(
deploymentId: c.deployment?.id,
environment: "prod",
},
slaClocks: [],
watchers: [],
linkedItems: [],
tags: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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 } from "@constants/apiConstants";
import { useBackendApi } from "@api/backend/client";
import { toCaseSla } from "@features/csm-cases/utils/caseSlaMapping";
import type {
CaseSlaList,
TaskSlaSearchPayload,
TaskSlaSearchResponse,
} from "@features/csm-cases/types/csmCases";

const TASK_SLA_PAGE_LIMIT = 50; // entity caps the page size at 50
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/**
* Lists the SLA records attached to a case.
*
* Calls the task-SLA search endpoint scoped to this case's id (a case is a
* task) and maps each result onto the {@link CaseSla} row model the SLA table
* renders (see {@link toCaseSla}). The query only runs while `caseId` is set,
* so callers that mount this hook conditionally (e.g. only while the SLA tab
* is active) get lazy fetching for free.
*/
export function useGetCsmCaseSlas(
caseId: string | undefined,
): UseQueryResult<CaseSlaList | null, Error> {
const api = useBackendApi();
const enabled = !!caseId;

return useQuery<CaseSlaList | null, Error>({
queryKey: [ApiQueryKeys.CSM_CASE_SLAS, caseId ?? ""],
queryFn: async (): Promise<CaseSlaList | null> => {
if (!caseId) return null;
const res = await api.post<TaskSlaSearchPayload, TaskSlaSearchResponse>(
"/slas/search",
{
filters: { taskIds: [caseId] },
pagination: { limit: TASK_SLA_PAGE_LIMIT, offset: 0 },
},
);
const slas = (res.slas ?? []).map(toCaseSla);
// Count reflects the rows actually rendered (this table always shows a
// case's full SLA list), not the backend's total-across-pages figure.
return { caseId, count: slas.length, slas };
},
enabled,
staleTime: 30_000,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ const BASE_CASE: CsmCaseDetail = {
deployment: "IAM Production",
environment: "prod",
},
slaClocks: [],
watchers: [],
linkedItems: [],
tags: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,12 @@ import type {
CaseCustomerContext,
CaseLinkedItem,
CaseProductContext,
CaseSlaClock,
CaseTag,
CaseTimeLogEntry,
CaseWatcher,
DeploymentCategory,
} from "@features/csm-cases/types/csmCases";
import { tierColor, tierLabel } from "@features/csm-cases/utils/caseTier";
import {
SLA_CLOCK_LABEL,
formatTimeToBreach,
} from "@features/csm-dashboard/utils/abtDashboard";
import RelativeTime from "@components/RelativeTime";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -271,100 +266,6 @@ export function ProductContextWidget({
);
}

// ---------------------------------------------------------------------------
// 3. SLA timeline
// ---------------------------------------------------------------------------

function clockProgress(c: CaseSlaClock): number {
if (c.state === "met") return 100;
if (c.state === "breached") return 100;
const elapsed = c.targetMinutes - Math.max(0, c.minutesToBreach);
if (c.targetMinutes <= 0) return 0;
return Math.min(100, Math.max(0, (elapsed / c.targetMinutes) * 100));
}

const CLOCK_COLOR: Record<
CaseSlaClock["state"],
"primary" | "warning" | "error" | "success"
> = {
running: "primary",
paused: "warning",
met: "success",
breached: "error",
};

export function SlaTimelineWidget({
clocks,
}: {
clocks: CaseSlaClock[];
}): JSX.Element {
return (
<WidgetCard title="SLA timeline" icon={<Clock size={16} />}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{clocks.map((c) => {
const stateText =
c.state === "met"
? "Met"
: c.state === "breached"
? formatTimeToBreach(c.minutesToBreach)
: c.state === "paused"
? "Paused"
: formatTimeToBreach(c.minutesToBreach);
return (
<Box
key={c.clockType}
sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
}}
>
<Typography variant="body2">
{SLA_CLOCK_LABEL[c.clockType]}
</Typography>
<Typography
variant="caption"
color={
c.state === "breached"
? "error.main"
: c.state === "met"
? "success.main"
: c.state === "paused"
? "warning.main"
: "text.secondary"
}
>
{stateText}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={clockProgress(c)}
color={CLOCK_COLOR[c.state]}
sx={{ height: 6, borderRadius: 1 }}
/>
<Typography variant="caption" color="text.secondary">
Target: {formatMinutes(c.targetMinutes)}
</Typography>
</Box>
);
})}
</Box>
</WidgetCard>
);
}

function formatMinutes(m: number): string {
if (m < 60) return `${m}m`;
const h = m / 60;
if (h < 24) return `${h}h`;
return `${Math.round(h / 24)}d`;
}

// ---------------------------------------------------------------------------
// 4. Watchers
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 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 { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import "@testing-library/jest-dom/vitest";
import { CaseSlaTable } from "@features/csm-cases/components/CaseSlaTable";
import type { CaseSla, CaseSlaList } from "@features/csm-cases/types/csmCases";
import { useGetCsmCaseSlas } from "@features/csm-cases/api/useGetCsmCaseSlas";

vi.mock("@features/csm-cases/api/useGetCsmCaseSlas", () => ({
useGetCsmCaseSlas: vi.fn(),
}));

const mockedUseGetCsmCaseSlas = vi.mocked(useGetCsmCaseSlas);

const SLA_ROW: CaseSla = {
id: "sla-1",
definition: "S1 - Response",
target: "1 Business Hour",
stage: "in_progress",
stageLabel: "In progress",
hasBreached: false,
businessTimeLeftLabel: "30 minutes",
businessElapsedLabel: "30 minutes",
businessElapsedPercent: 50,
startTime: "2026-07-01T10:00:00Z",
stopTime: null,
};

function mockResult(overrides: Partial<ReturnType<typeof useGetCsmCaseSlas>>): void {
mockedUseGetCsmCaseSlas.mockReturnValue({
data: null,
isLoading: false,
isError: false,
isFetching: false,
dataUpdatedAt: 0,
refetch: vi.fn(),
...overrides,
} as unknown as ReturnType<typeof useGetCsmCaseSlas>);
}

describe("CaseSlaTable", () => {
beforeEach(() => {
mockedUseGetCsmCaseSlas.mockReset();
});

it("renders a loading skeleton while the query is in flight", () => {
mockResult({ isLoading: true });
render(<CaseSlaTable caseId="case-1" />);
expect(screen.getByText("SLAs")).toBeInTheDocument();
});

it("renders an empty state when there are no SLAs", () => {
const empty: CaseSlaList = { caseId: "case-1", count: 0, slas: [] };
mockResult({ data: empty });
render(<CaseSlaTable caseId="case-1" />);
expect(screen.getByText("No SLAs on this case.")).toBeInTheDocument();
});

it("renders SLA rows with definition, stage, and time labels", () => {
const list: CaseSlaList = { caseId: "case-1", count: 1, slas: [SLA_ROW] };
mockResult({ data: list });
render(<CaseSlaTable caseId="case-1" />);
expect(screen.getByText("S1 - Response")).toBeInTheDocument();
expect(screen.getByText("In progress")).toBeInTheDocument();
expect(screen.getAllByText("30 minutes").length).toBeGreaterThan(0);
expect(screen.getByText("50%")).toBeInTheDocument();
// Stop time is null → rendered as an em dash.
expect(screen.getByText("—")).toBeInTheDocument();
});

it("renders an error state with a retry action that refetches", () => {
const refetch = vi.fn();
mockResult({ isError: true, refetch });
render(<CaseSlaTable caseId="case-1" />);
expect(screen.getByText("Could not load SLAs for this case.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect(refetch).toHaveBeenCalled();
});
});
Loading