Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import type {
CsmCaseRow,
CsmCasesListResponse,
} from "@features/csm-cases/types/csmCases";
import {
DEFAULT_CASES_SORT,
type CasesSortOrder,
} from "@features/csm-cases/utils/casesSort";

/**
* Cross-project CSM cases list.
Expand All @@ -56,15 +60,18 @@ import type {
* `limit` / `offset` / `hasMore`).
*
* `page` is zero-based (matching MUI `TablePagination`); `pageSize` is the row
* limit (≤ {@link BE_MAX_PAGE_LIMIT}). With no filters the backend sorts by
* last-updated descending, so the cases page loads the most recently updated
* cases on arrival. `enabled` is an optional escape hatch to suspend the fetch.
* limit (≤ {@link BE_MAX_PAGE_LIMIT}). Cases are always sorted by `updatedOn`;
* `sortOrder` (default `"desc"`) controls direction, so the cases page loads
* the most recently updated cases on arrival by default but can be flipped
* to oldest-updated-first. `enabled` is an optional escape hatch to suspend
* the fetch.
*/
export function useGetCsmCases(
filters: CasesFilters,
page: number,
pageSize: number,
enabled = true,
sortOrder: CasesSortOrder = DEFAULT_CASES_SORT.order,
): UseQueryResult<CsmCasesListResponse, Error> {
const logger = useLogger();
const api = useBackendApi();
Expand Down Expand Up @@ -100,6 +107,7 @@ export function useGetCsmCases(
currentUserId ?? "",
page,
pageSize,
sortOrder,
],
queryFn: async (): Promise<CsmCasesListResponse> => {
// Resolve the assignee filter (engineer emails + the `@me` sentinel) to
Expand Down Expand Up @@ -159,7 +167,7 @@ export function useGetCsmCases(
BeCaseSearchResponse
>("/cases/search", {
pagination: { offset, limit: pageSize },
sortBy: { field: "updatedOn", order: "desc" },
sortBy: { field: "updatedOn", order: sortOrder },
// Filter fields are nested under `filters` (BE payload restructure).
filters: {
...(search.length > 0 && { searchQuery: search }),
Expand Down Expand Up @@ -231,6 +239,7 @@ export function useGetCsmCases(
hasSla: false,
createdAt: c.createdOn ?? "",
updatedAt: c.updatedOn ?? c.createdOn ?? "",
updatedAtIsCreatedFallback: !c.updatedOn && !!c.createdOn,
};
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,22 @@ function buildSecondaryItems(caseDetail: CsmCaseDetail): SecondaryItem[] {
// attachments, and time tracking (see CsmCaseDetailPage.tsx's isClosed).
const caseClosed = caseDetail.state === "closed";

// Git issues may only be raised while the case is active: Open, Work in
// progress, Waiting on client, Waiting on WSO2, or Reopened. Anything else
// (e.g. Solution proposed, Closed) blocks the action. `caseDetail.state` is
// the normalized label (see `uiStateFromBe`), so this list uses the same
// lowercase/underscore form the data source's raw state label normalizes to.
const GIT_ISSUE_ALLOWED_STATES: readonly string[] = [
"open",
"work_in_progress",
"waiting_on_client",
"waiting_on_wso2",
"reopened",
];
const gitIssueStateBlocked = !GIT_ISSUE_ALLOWED_STATES.includes(
caseDetail.state,
);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Roadmap items with no backend flow yet: kept visible (so the menu still
// advertises what's coming) but disabled with a tooltip explaining why,
// rather than clickable and silently no-op'ing or toasting a mock message.
Expand All @@ -261,8 +277,12 @@ function buildSecondaryItems(caseDetail: CsmCaseDetail): SecondaryItem[] {
label: "Raise internal Git issue…",
icon: <GitBranch size={16} />,
divider: true,
disabled: caseClosed,
tooltip: caseClosed ? "This case is closed — it's read-only." : undefined,
disabled: caseClosed || gitIssueStateBlocked,
tooltip: caseClosed
? "This case is closed — it's read-only."
: gitIssueStateBlocked
? "Git issues can only be raised while the case is Open, Work in progress, Waiting on client, Waiting on WSO2, or Reopened."
: undefined,
},
{
key: "reassign_engineer",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@
// specific language governing permissions and limitations
// under the License.

import { Box, Chip, Skeleton, Typography, useTheme } from "@wso2/oxygen-ui";
import {
Box,
Chip,
Skeleton,
TableSortLabel,
Typography,
useTheme,
} from "@wso2/oxygen-ui";
import type { JSX } from "react";
import { Link as RouterLink } from "react-router";
import { preloadRoute } from "@utils/routePreloaders";
Expand All @@ -23,6 +30,7 @@ import SeverityChip from "@components/SeverityChip";
import StateChip from "@components/StateChip";
import { WORK_STATE_LABEL } from "@features/csm-cases/utils/caseWorkState";
import type { CsmCaseRow } from "@features/csm-cases/types/csmCases";
import type { CasesSortOrder } from "@features/csm-cases/utils/casesSort";

interface CasesListProps {
cases: CsmCaseRow[];
Expand All @@ -31,6 +39,12 @@ interface CasesListProps {
skeletonCount?: number;
/** Base path for detail links. Defaults to "/cases". */
detailBasePath?: string;
/** Current sort order for the "Updated" column, when the caller wants it
* sortable. Omit both `sortOrder` and `onSortOrderChange` for a plain
* (non-interactive) header — the list is always server-sorted by
* `updatedOn`, this just lets the user flip the direction. */
sortOrder?: CasesSortOrder;
onSortOrderChange?: (order: CasesSortOrder) => void;
}

// Every column is left-aligned for a consistent scan line down the table.
Expand All @@ -40,7 +54,6 @@ const HEADER_CELLS: string[] = [
"Product",
"Severity",
"State",
"Updated",
];

// Subject gets the lion's share of the row; the ids sit in their own narrow
Expand All @@ -55,6 +68,8 @@ export default function CasesList({
isLoading,
skeletonCount = 6,
detailBasePath = "/cases",
sortOrder,
onSortOrderChange,
}: CasesListProps): JSX.Element {
const theme = useTheme();

Expand Down Expand Up @@ -97,6 +112,35 @@ export default function CasesList({
{label}
</Typography>
))}
{sortOrder && onSortOrderChange ? (
<TableSortLabel
active
direction={sortOrder}
onClick={() =>
onSortOrderChange(sortOrder === "desc" ? "asc" : "desc")
}
sx={{
justifySelf: "start",
"& .MuiTableSortLabel-icon": { fontSize: "1rem" },
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 600 }}
>
Updated
</Typography>
</TableSortLabel>
) : (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 600, textAlign: "left" }}
>
Updated
</Typography>
)}
</Box>

{/* Rows */}
Expand Down Expand Up @@ -229,6 +273,7 @@ export default function CasesList({
)}
</Box>
<Typography variant="caption" color="text.secondary" noWrap>
{c.updatedAtIsCreatedFallback && "Created "}
<RelativeTime iso={c.updatedAt} />
</Typography>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ export interface CreateGithubIssueDialogProps {
defaultDescription?: string;
/** Show the repository field only for cloud subscription / cloud
* evaluation subscription projects — other project types route by
* product unit on the SN side and have no repo to choose. */
* product unit on the SN side and have no repo to choose. Conversely,
* Update Level and Public Git Issue apply only when this is false. */
showRepoField?: boolean;
onClose: () => void;
/** Body for `POST /cases/{id}/github-issues` (caseId is added by the caller). */
Expand Down Expand Up @@ -151,8 +152,13 @@ export function CreateGithubIssueDialog({
const showSeverity = type === "Type/Incident";
const requireSeverity = type === "Type/Incident";
const showHotFix = type === "Type/Patch";
const requireUpdateLevel = type === "Type/Patch";
const requirePublicIssueUrl = type === "Type/Patch";
// Update Level / Public Git Issue apply to non-cloud projects only — cloud
// projects route via the repo field instead (see showRepoField).
const showUpdateLevelAndIssueUrl = !showRepoField;
const requireUpdateLevel =
type === "Type/Patch" && showUpdateLevelAndIssueUrl;
const requirePublicIssueUrl =
type === "Type/Patch" && showUpdateLevelAndIssueUrl;

const resetAndClose = () => {
setType(UNSET);
Expand Down Expand Up @@ -279,26 +285,30 @@ export function CreateGithubIssueDialog({
requireSeverity,
)}

<TextField
label="Update Level"
value={updateLevel}
onChange={(e) => setUpdateLevel(e.target.value)}
disabled={submitting}
required={requireUpdateLevel}
size="small"
fullWidth
/>
{showUpdateLevelAndIssueUrl && (
<TextField
label="Update Level"
value={updateLevel}
onChange={(e) => setUpdateLevel(e.target.value)}
disabled={submitting}
required={requireUpdateLevel}
size="small"
fullWidth
/>
)}

<TextField
label="Public Git Issue or Security Internal JIRA"
value={publicIssueUrl}
onChange={(e) => setPublicIssueUrl(e.target.value)}
disabled={submitting}
required={requirePublicIssueUrl}
size="small"
fullWidth
placeholder="https://github.com/… or JIRA link"
/>
{showUpdateLevelAndIssueUrl && (
<TextField
label="Public Git Issue or Security Internal JIRA"
value={publicIssueUrl}
onChange={(e) => setPublicIssueUrl(e.target.value)}
disabled={submitting}
required={requirePublicIssueUrl}
size="small"
fullWidth
placeholder="https://github.com/… or JIRA link"
/>
)}

{showHotFix && (
<FormControlLabel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import {
readCasesFiltersFromUrl,
writeCasesFiltersToUrl,
} from "@features/csm-cases/utils/casesFiltersUrl";
import {
DEFAULT_CASES_SORT,
type CasesSortOrder,
} from "@features/csm-cases/utils/casesSort";

const DEFAULT_ROWS_PER_PAGE = 20;
const ROWS_PER_PAGE_OPTIONS = [10, DEFAULT_ROWS_PER_PAGE, BE_MAX_PAGE_LIMIT];
Expand Down Expand Up @@ -103,6 +107,9 @@ export default function CsmIssuesView({
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_ROWS_PER_PAGE);
const [isFiltersOpen, setIsFiltersOpen] = useState(true);
const [sortOrder, setSortOrder] = useState<CasesSortOrder>(
DEFAULT_CASES_SORT.order,
);

const setFilters = useCallback(
(next: CasesFilters) => {
Expand Down Expand Up @@ -143,8 +150,15 @@ export default function CsmIssuesView({
queryFilters,
page,
rowsPerPage,
true,
sortOrder,
);

const handleSortOrderChange = (order: CasesSortOrder): void => {
setSortOrder(order);
setPage(0);
};

const { data: directoryUsers } = useDirectoryUsers();
const { showError } = useErrorBanner();
const hasShownErrorRef = useRef(false);
Expand Down Expand Up @@ -195,9 +209,6 @@ export default function CsmIssuesView({
const breachedCount = cases.filter(
(c) => c.minutesToBreach < 0 && c.state !== "closed",
).length;
const myCount = cases.filter(
(c) => c.assigneeIsMe && c.state !== "closed",
).length;
const rangeStart = total === 0 ? 0 : page * rowsPerPage + 1;
const rangeEnd = page * rowsPerPage + cases.length;

Expand Down Expand Up @@ -231,14 +242,6 @@ export default function CsmIssuesView({
{breachedCount > 0 && (
<Chip size="small" color="error" label={`${breachedCount} breached`} />
)}
{myCount > 0 && (
<Chip
size="small"
color="primary"
variant="outlined"
label={`${myCount} mine`}
/>
)}
{actions}
</Box>
</Box>
Expand All @@ -257,7 +260,14 @@ export default function CsmIssuesView({
showEngagementTypeFilter={showEngagementTypeFilter}
/>

<CasesList cases={cases} isLoading={isLoading || isFetching} skeletonCount={rowsPerPage} detailBasePath={detailBasePath} />
<CasesList
cases={cases}
isLoading={isLoading || isFetching}
skeletonCount={rowsPerPage}
detailBasePath={detailBasePath}
sortOrder={sortOrder}
onSortOrderChange={handleSortOrderChange}
/>

<TablePagination
component="div"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ export interface CsmCaseRow {
hasSla?: boolean;
createdAt: string;
updatedAt: string;
/** True when the backend didn't return `updatedOn` and {@link updatedAt}
* was filled in from {@link createdAt} instead — the list renders that
* fallback labeled "Created", never silently as "Updated". */
updatedAtIsCreatedFallback?: boolean;
}

export interface CsmCasesListResponse {
Expand Down
29 changes: 29 additions & 0 deletions apps/csm-portal/webapp/src/features/csm-cases/utils/casesSort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 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.

/**
* Sort fields the cases list exposes to the user via the "Updated" column
* header. `/cases/search` also accepts `severity` / `state` (see
* `BeCaseSortField`), but those columns already have their own filters and
* don't need a sort toggle.
*/
export type CasesSortField = "createdOn" | "updatedOn";
export type CasesSortOrder = "asc" | "desc";

export const DEFAULT_CASES_SORT: {
field: CasesSortField;
order: CasesSortOrder;
} = { field: "updatedOn", order: "desc" };
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export function useGetMyAssignedOpenCases(
hasSla: false,
createdAt: c.createdOn ?? "",
updatedAt: c.updatedOn ?? c.createdOn ?? "",
updatedAtIsCreatedFallback: !c.updatedOn && !!c.createdOn,
};
});

Expand Down