diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardAttachments/ScorecardAttachments.module.scss b/src/apps/review/src/lib/components/Scorecard/ScorecardAttachments/ScorecardAttachments.module.scss index e69de29bb..8eda551a0 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardAttachments/ScorecardAttachments.module.scss +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardAttachments/ScorecardAttachments.module.scss @@ -0,0 +1,87 @@ +@import '@libs/ui/styles/includes'; + +.tableWrapper { + min-height: calc($content-height - 250px); + font-size: 14px; + + &:global(.enhanced-table) { + table { + th, + td { + text-align: left; + background-color: white; + } + + } + } +} + +.tableCell { + vertical-align: middle; +} + +.filenameCell { + display: flex; + align-items: center; + gap: 6px; + color: $link-blue-dark; + cursor: pointer; + opacity: 1; + transition: opacity 0.15s ease; + + &:hover { + text-decoration: underline; + } +} + +.downloading { + cursor: wait; + opacity: 0.6; +} + +.expired { + cursor: not-allowed; + color: #999; + + &:hover { + text-decoration: none; + } +} + +.artifactType { + display: flex; + gap: 5px; + align-items: center; + + .artifactIcon { + stroke: #00797A; + } +} + +.noAttachmentText { + text-align: center; +} + +.mobileRow { + padding: 16px 8px; + border-bottom: 1px solid #A8A8A8; +} + +.mobileHeader { + display: flex; + gap: 12px; +} + +.mobileExpanded { + padding: 16px 20px 0px 32px; +} + +.rowItem { + display: flex; + justify-content: space-between; +} + +.rowItemHeading { + font-weight: 700; + color: #0a0a0a; +} diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardAttachments/ScorecardAttachments.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardAttachments/ScorecardAttachments.tsx index a0091861c..54b78f74e 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardAttachments/ScorecardAttachments.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardAttachments/ScorecardAttachments.tsx @@ -1,15 +1,204 @@ -import { FC } from 'react' +import { FC, useCallback, useMemo, useState } from 'react' +import { noop } from 'lodash' +import classNames from 'classnames' +import moment from 'moment' -// import styles from './ScorecardAttachments.module.scss' +import { IconOutline, Table, TableColumn } from '~/libs/ui' +import { useReviewsContext } from '~/apps/review/src/pages/reviews/ReviewsContext' +import { useWindowSize, WindowSize } from '~/libs/shared' + +import { AiWorkflowRunArtifact, + AiWorkflowRunArtifactDownloadResponse, + AiWorkflowRunAttachmentsResponse, + useDownloadAiWorkflowsRunArtifact, useFetchAiWorkflowsRunAttachments } from '../../../hooks' +import { TableWrapper } from '../../TableWrapper' +import { TABLE_DATE_FORMAT } from '../../../constants' +import { formatFileSize } from '../../common' +import { ReviewsContextModel } from '../../../models' + +import styles from './ScorecardAttachments.module.scss' interface ScorecardAttachmentsProps { className?: string } -const ScorecardAttachments: FC = props => ( -
- attachments -
-) +const ScorecardAttachments: FC = (props: ScorecardAttachmentsProps) => { + const className = props.className + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 1000, [screenWidth]) + const { workflowId, workflowRun }: ReviewsContextModel = useReviewsContext() + const { artifacts }: AiWorkflowRunAttachmentsResponse + = useFetchAiWorkflowsRunAttachments(workflowId, workflowRun?.id) + const { download, isDownloading }: AiWorkflowRunArtifactDownloadResponse = useDownloadAiWorkflowsRunArtifact( + workflowId, + workflowRun?.id, + ) + + const handleDownload = useCallback( + async (artifactId: number): Promise => { + await download(artifactId) + }, + [download], + ) + + const createDownloadHandler = useCallback( + (id: number) => () => handleDownload(id), + [handleDownload], + ) + + const columns = useMemo[]>( + () => [ + { + className: classNames(styles.tableCell), + label: 'Filename', + propertyName: 'name', + renderer: (attachment: AiWorkflowRunArtifact) => { + const isExpired = attachment.expired + + return ( +
+ {attachment.name} + {isExpired && (Link Expired)} +
+ ) + }, + type: 'element', + }, + { + className: classNames(styles.tableCell), + label: 'Type', + renderer: () => ( +
+ + Artifact +
+ ), + type: 'element', + }, + { + className: classNames(styles.tableCell), + label: 'Size', + propertyName: 'sizeInBytes', + renderer: (attachment: AiWorkflowRunArtifact) => ( +
{formatFileSize(attachment.size_in_bytes)}
+ ), + type: 'element', + }, + { + className: styles.tableCell, + label: 'Attached Date', + renderer: (attachment: AiWorkflowRunArtifact) => ( + + {moment(attachment.created_at) + .local() + .format(TABLE_DATE_FORMAT)} + + ), + type: 'element', + }, + ], + [createDownloadHandler, isDownloading], + ) + + const [openRow, setOpenRow] = useState(undefined) + const toggleRow = useCallback( + (id: number) => () => { + setOpenRow(prev => (prev === id ? undefined : id)) + }, + [], + ) + const renderMobileRow = (attachment: AiWorkflowRunArtifact): JSX.Element => { + const isExpired = attachment.expired + const downloading = isDownloading + const isOpen = openRow === attachment.id + + return ( +
+ {/* Top collapsed row */} +
+ +
+ {attachment.name} +
+ +
+ + {/* Expanded content */} + {isOpen && ( +
+
+ Type: +
+ + Artifact +
+
+ +
+ Size: + {formatFileSize(attachment.size_in_bytes)} +
+ +
+ Date: + {moment(attachment.created_at) + .local() + .format(TABLE_DATE_FORMAT)} +
+
+ )} +
+ ) + } + + return ( + + {!artifacts || artifacts.length === 0 ? ( +
No attachments
+ ) : isTablet ? ( +
+ {artifacts.map(renderMobileRow)} +
+ ) : ( + + )} + + + ) + +} export default ScorecardAttachments diff --git a/src/apps/review/src/lib/components/common/columnUtils.ts b/src/apps/review/src/lib/components/common/columnUtils.ts index 0bfceebcc..d50da47f6 100644 --- a/src/apps/review/src/lib/components/common/columnUtils.ts +++ b/src/apps/review/src/lib/components/common/columnUtils.ts @@ -69,3 +69,28 @@ export const getProfileUrl = (handle: string): string => { return `${normalizedBase}/${encodeURIComponent(handle)}` } + +/** + * converts size_in_bytes into KB / MB / GB with correct formatting. + */ +export const formatFileSize = (bytes: number): string => { + if (!bytes || bytes < 0) return '0 B' + + const KB = 1024 + const MB = KB * 1024 + const GB = MB * 1024 + + if (bytes >= GB) { + return `${(bytes / GB).toFixed(2)} GB` + } + + if (bytes >= MB) { + return `${(bytes / MB).toFixed(2)} MB` + } + + if (bytes >= KB) { + return `${(bytes / KB).toFixed(2)} KB` + } + + return `${bytes} B` +} diff --git a/src/apps/review/src/lib/constants.ts b/src/apps/review/src/lib/constants.ts index 89f4cbd37..b03395e68 100644 --- a/src/apps/review/src/lib/constants.ts +++ b/src/apps/review/src/lib/constants.ts @@ -1,2 +1,3 @@ export const SUBMISSION_TYPE_CONTEST = 'CONTEST_SUBMISSION' export const SUBMISSION_TYPE_CHECKPOINT = 'CHECKPOINT_SUBMISSION' +export const TABLE_DATE_FORMAT = 'MMM DD, HH:mm A' diff --git a/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts index 6df3dc16a..c7afa253d 100644 --- a/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts +++ b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts @@ -1,8 +1,8 @@ -import { useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import useSWR, { SWRResponse } from 'swr' import { EnvironmentConfig } from '~/config' -import { xhrGetAsync } from '~/libs/core' +import { xhrGetAsync, xhrGetBlobAsync } from '~/libs/core' import { handleError } from '~/libs/shared/lib/utils/handle-error' import { AiFeedbackItem, Scorecard } from '../models' @@ -46,6 +46,23 @@ export interface AiWorkflowRun { workflow: AiWorkflow } +export interface AiWorkflowRunArtifact { + id: number + name: string + size_in_bytes: number + url: string + archive_download_url: string + expired: boolean + workflow_run: { + id: number + repository_id: number + head_sha: string + } + created_at: string + updated_at: string + expires_at: string +} + export type AiWorkflowRunItem = AiFeedbackItem const TC_API_BASE_URL = EnvironmentConfig.API.V6 @@ -60,6 +77,22 @@ export interface AiWorkflowRunItemsResponse { isLoading: boolean } +export interface AiWorkflowRunAttachmentsApiResponse { + artifacts: AiWorkflowRunArtifact[] + total_count: number +} + +export interface AiWorkflowRunAttachmentsResponse { + artifacts: AiWorkflowRunArtifact[] + totalCount: number + isLoading: boolean +} + +export interface AiWorkflowRunArtifactDownloadResponse { + download: (artifactId: number) => Promise + isDownloading: boolean +} + export const aiRunInProgress = (aiRun: Pick): boolean => [ AiWorkflowRunStatusEnum.INIT, AiWorkflowRunStatusEnum.QUEUED, @@ -137,3 +170,75 @@ export function useFetchAiWorkflowsRunItems( runItems, } } + +export function useFetchAiWorkflowsRunAttachments( + workflowId?: string, + runId?: string | undefined, +): AiWorkflowRunAttachmentsResponse { + const { + data, + error: fetchError, + isValidating: isLoading, + }: SWRResponse = useSWR< + AiWorkflowRunAttachmentsApiResponse, + Error + >( + `${TC_API_BASE_URL}/workflows/${workflowId}/runs/${runId}/attachments`, + { + isPaused: () => !workflowId || !runId, + }, + ) + + useEffect(() => { + if (fetchError) { + handleError(fetchError) + } + }, [fetchError]) + + return { + artifacts: data?.artifacts ?? [], + isLoading, + totalCount: data?.total_count ?? 0, + } +} + +export function useDownloadAiWorkflowsRunArtifact( + workflowId?: string, + runId?: string, +): AiWorkflowRunArtifactDownloadResponse { + const [isDownloading, setIsDownloading] = useState(false) + + const download = useCallback( + async (artifactId: number): Promise => { + if (!workflowId || !runId || !artifactId) return + + setIsDownloading(true) + const url = `${TC_API_BASE_URL}/workflows/${workflowId}/runs/${runId}/attachments/${artifactId}/zip` + + try { + const blob = await xhrGetBlobAsync(url) + + const objectUrl = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = objectUrl + link.download = `artifact-${artifactId}.zip` + + document.body.appendChild(link) + link.click() + link.remove() + + window.URL.revokeObjectURL(objectUrl) + } catch (err) { + handleError(err as Error) + } finally { + setIsDownloading(false) + } + }, + [workflowId, runId], + ) + + return { + download, + isDownloading, + } +} diff --git a/src/apps/review/src/pages/reviews/components/AiReviewViewer/AiReviewViewer.tsx b/src/apps/review/src/pages/reviews/components/AiReviewViewer/AiReviewViewer.tsx index ca1f14802..fe94c883d 100644 --- a/src/apps/review/src/pages/reviews/components/AiReviewViewer/AiReviewViewer.tsx +++ b/src/apps/review/src/pages/reviews/components/AiReviewViewer/AiReviewViewer.tsx @@ -4,8 +4,10 @@ import { Tabs } from '~/apps/review/src/lib' import { ScorecardViewer } from '~/apps/review/src/lib/components/Scorecard' import { ScorecardAttachments } from '~/apps/review/src/lib/components/Scorecard/ScorecardAttachments' import { + AiWorkflowRunAttachmentsResponse, AiWorkflowRunItemsResponse, AiWorkflowRunStatusEnum, + useFetchAiWorkflowsRunAttachments, useFetchAiWorkflowsRunItems, } from '~/apps/review/src/lib/hooks' import { ReviewsContextModel, SelectOption } from '~/apps/review/src/lib/models' @@ -15,15 +17,17 @@ import { useReviewsContext } from '../../ReviewsContext' import styles from './AiReviewViewer.module.scss' -const tabItems: SelectOption[] = [ - { label: 'Scorecard', value: 'scorecard' }, - { label: 'Attachments', value: 'attachments' }, -] - const AiReviewViewer: FC = () => { const { scorecard, workflowId, workflowRun }: ReviewsContextModel = useReviewsContext() const [selectedTab, setSelectedTab] = useState('scorecard') const { runItems }: AiWorkflowRunItemsResponse = useFetchAiWorkflowsRunItems(workflowId, workflowRun?.id) + const { totalCount }: AiWorkflowRunAttachmentsResponse + = useFetchAiWorkflowsRunAttachments(workflowId, workflowRun?.id) + + const tabItems: SelectOption[] = [ + { label: 'Scorecard', value: 'scorecard' }, + { label: `Attachments (${totalCount ?? 0})`, value: 'attachments' }, + ] const isFailedRun = useMemo(() => ( workflowRun && [ AiWorkflowRunStatusEnum.CANCELLED,