Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
526b844
[CSM Portal Microapp] Build out the Case detail page: lifecycle actio…
Hesara2003 Jul 15, 2026
3895715
[CSM Portal Microapp] Fix the single-active-case pause-conflict flow …
Hesara2003 Jul 15, 2026
b65d1eb
[CSM Portal Microapp] Fix case detail dialog input behavior
Hesara2003 Jul 15, 2026
6b249b8
[CSM Portal Microapp] Add case activity DTOs for the activities/searc…
Hesara2003 Jul 15, 2026
4bbbb97
[CSM Portal Microapp] Add CaseAuditEntry model for case activities
Hesara2003 Jul 15, 2026
111ad7e
[CSM Portal Microapp] Export the new activity types
Hesara2003 Jul 15, 2026
9fb58d3
[CSM Portal Microapp] Add the case activities search endpoint
Hesara2003 Jul 15, 2026
642f826
[CSM Portal Microapp] Add a case activities service (field-change aud…
Hesara2003 Jul 15, 2026
3d53154
[CSM Portal Microapp] Add a unified, filterable case activity feed
Hesara2003 Jul 15, 2026
b2d4654
[CSM Portal Microapp] Wire up the activity feed, icon-based details c…
Hesara2003 Jul 15, 2026
0ebba94
[CSM Portal Microapp] Fix the Change state gradient to use the theme'…
Hesara2003 Jul 15, 2026
5bed1ae
[CSM Portal Microapp] Replace the comment type toggle with an Interna…
Hesara2003 Jul 15, 2026
fe22e92
[CSM Portal Microapp] Rename the attachment picker button to Attachments
Hesara2003 Jul 15, 2026
be32f8c
[CSM Portal Microapp] Add an Open button alongside Download for attac…
Hesara2003 Jul 15, 2026
9784236
[CSM Portal Microapp] Split AttachmentsField into a picker button + l…
Hesara2003 Jul 15, 2026
fb51fcd
[CSM Portal Microapp] Put the Attachments button next to Post in the …
Hesara2003 Jul 15, 2026
2a200bf
[CSM Portal Microapp] Make the Change state buttons pill-shaped
Hesara2003 Jul 15, 2026
f4e0d6c
[CSM Portal Microapp] Style Change severity as an orange-outlined pil…
Hesara2003 Jul 15, 2026
c0243e7
[CSM Portal Microapp] Remove the category filter/sort chips from the …
Hesara2003 Jul 15, 2026
c96eb86
[CSM Portal Microapp] Keep only failed attachments after a partial up…
Hesara2003 Jul 15, 2026
f5ea431
[CSM Portal Microapp] Fix HTML detection and truncation on comment bo…
Hesara2003 Jul 15, 2026
0175db9
[CSM Portal Microapp] Gate comment submit on the selected type's own …
Hesara2003 Jul 15, 2026
833513f
[CSM Portal Microapp] Ignore dialog dismissal while the pause mutatio…
Hesara2003 Jul 15, 2026
cad2a82
[CSM Portal Microapp] Reset the file input after an oversized-file re…
Hesara2003 Jul 15, 2026
0f9d9a0
[CSM Portal Microapp] Normalize both sides of the ongoing-case email …
Hesara2003 Jul 15, 2026
c8dc647
[CSM Portal Microapp] Fall back to the default message when unwrappin…
Hesara2003 Jul 15, 2026
71e9915
[CSM Portal Microapp] Revert partial state on ongoing-conflict race; …
Hesara2003 Jul 15, 2026
6209cf1
[CSM Portal Microapp] Page through the full case activity history
Hesara2003 Jul 15, 2026
99f0e84
[CSM Portal Microapp] Drop the Download attachment button that duplic…
Hesara2003 Jul 15, 2026
e2b8e82
[CSM Portal Microapp] Remove redundant Fragment wrappers in CaseActio…
Hesara2003 Jul 15, 2026
b4240f7
[CSM Portal Microapp] Rename attachment picker buttons to "Upload att…
Hesara2003 Jul 15, 2026
6f5b33d
[CSM Portal Microapp] Shrink attachment subtext and space out view/do…
Hesara2003 Jul 15, 2026
251936a
[CSM Portal Microapp] Regroup Details tab fields into a single paired…
Hesara2003 Jul 15, 2026
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
190 changes: 190 additions & 0 deletions apps/csm-portal/microapp/src/components/case-detail/AttachmentsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// 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 { Suspense, useState, type ReactNode } from "react";
import { useQueryClient, useQueryErrorResetBoundary, useSuspenseQuery } from "@tanstack/react-query";
import { Button, IconButton, Skeleton, Stack, Typography } from "@wso2/oxygen-ui";
import { Download, Eye, FileText } from "@wso2/oxygen-ui-icons-react";
import { attachments as attachmentsService } from "@src/services/attachments";
import type { CaseAttachment } from "@src/types";
import { ErrorBoundary } from "@components/common/ErrorBoundary";
import { ErrorState } from "@components/support/ErrorState";
import { AttachmentsField } from "@components/support/AttachmentsField";
import { formatBytes, type PendingAttachment } from "@utils/attachments";
import { formatDate } from "@utils/dateTime";
import { openUrl } from "@components/microapp-bridge";
import { Logger } from "@utils/logger";

/**
* Full attachment list + upload for a case — the standalone flow (10 MB cap), distinct from the
* comment composer's 5 MB inline attach (see CommentComposer.tsx / utils/attachments.ts).
*/
export function AttachmentsTab({ caseId }: { caseId: string }) {
return (
<AttachmentsTabErrorBoundary>
<Suspense fallback={<AttachmentsTabSkeleton />}>
<AttachmentsTabContent caseId={caseId} />
</Suspense>
</AttachmentsTabErrorBoundary>
);
}

function AttachmentsTabContent({ caseId }: { caseId: string }) {
const queryClient = useQueryClient();
const { data: caseAttachments } = useSuspenseQuery(attachmentsService.forCase(caseId));
const [pending, setPending] = useState<PendingAttachment[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleUpload = async () => {
if (pending.length === 0) return;
setIsUploading(true);
setError(null);
const results = await Promise.allSettled(
pending.map((attachment) =>
attachmentsService.create({
referenceId: caseId,
referenceType: "case",
name: attachment.name,
type: attachment.type,
file: attachment.file,
}),
),
);
const failedCount = results.filter((r) => r.status === "rejected").length;
if (failedCount > 0) {
Logger.warn(`${failedCount} attachment(s) failed to upload to case ${caseId}`);
setError(
failedCount === pending.length
? "Could not upload the file(s). Please try again."
: `${failedCount} file(s) failed to upload.`,
);
}
// Keep only the files that actually failed, so a partial (or total) failure leaves them
// selected for retry instead of silently dropping them along with the successful ones.
// Indexes line up with `results` since the field is disabled (via isUploading) for the
// duration of this upload, so `pending` can't have changed underneath us.
setPending(pending.filter((_, i) => results[i].status === "rejected"));
setIsUploading(false);
void queryClient.invalidateQueries({ queryKey: ["case", caseId, "attachments"] });
Comment thread
Hesara2003 marked this conversation as resolved.
Resolved
};

return (
<Stack gap={2}>
<AttachmentsField attachments={pending} onChange={setPending} disabled={isUploading} />

{pending.length > 0 && (
<Button
variant="contained"
size="small"
disabled={isUploading}
onClick={() => void handleUpload()}
sx={{ alignSelf: "end" }}
>
Upload
</Button>
)}

{error && (
<Typography variant="caption" color="error.main">
{error}
</Typography>
)}

{caseAttachments.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No attachments on this case.
</Typography>
) : (
<Stack gap={1}>
{caseAttachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
</Stack>
)}
</Stack>
);
}

function AttachmentRow({ attachment }: { attachment: CaseAttachment }) {
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={1}
sx={{ p: 1, border: "1px solid", borderColor: "divider", borderRadius: 1 }}
>
<Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}>
<FileText size={16} />
<Stack sx={{ minWidth: 0 }}>
<Typography variant="body2" noWrap>
{attachment.name}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{formatBytes(attachment.sizeBytes)} · {attachment.createdBy} · {formatDate(attachment.createdOn)}
</Typography>
</Stack>
</Stack>
{attachment.downloadUrl && (
<Stack direction="row" gap={0.5} sx={{ flexShrink: 0 }}>
<IconButton
size="small"
aria-label={`Open ${attachment.name}`}
onClick={() => openUrl({ url: attachment.downloadUrl as string, presentationStyle: "fullScreen" })}
>
<Eye size={16} />
</IconButton>
<IconButton
size="small"
aria-label={`Download ${attachment.name}`}
onClick={() => openUrl({ url: attachment.downloadUrl as string, presentationStyle: "fullScreen" })}
>
<Download size={16} />
</IconButton>
</Stack>
)}
</Stack>
);
}

function AttachmentsTabSkeleton() {
return (
<Stack gap={1}>
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={56} />
</Stack>
);
}

function AttachmentsTabErrorBoundary({ children }: { children: ReactNode }) {
const { reset } = useQueryErrorResetBoundary();

return (
<ErrorBoundary
fallback={(_error, resetErrorBoundary) => (
<ErrorState
onRetry={() => {
reset();
resetErrorBoundary();
}}
/>
)}
>
{children}
</ErrorBoundary>
);
}
Loading