diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss new file mode 100644 index 000000000..db1611d2d --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -0,0 +1,100 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + font-family: "Nunito Sans", sans-serif; + max-width: 100%; + overflow: hidden; +} + +.reviewsTable { + width: 100%; + border-collapse: collapse; + + th { + border-top: 1px solid #A8A8A8; + font-weight: bold; + background: #E0E4E8; + } + + th, td { + text-align: left; + font-size: 14px; + padding: $sp-2 $sp-4; + border-bottom: 1px solid #A8A8A8; + } + + .scoreCol { + text-align: right; + } +} + +.aiReviewer { + display: flex; + align-items: center; + gap: $sp-2; + + .icon { + display: flex; + align-items: center; + flex: 0 0; + } + + .workflowName { + > div:first-child { + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +.result { + display: flex; + align-items: center; + gap: $sp-2; + + :global(.icon) { + color: #C1294F; + &:global(.passed) { + color: $teal-160; + } + &:global(.pending) { + color: $black-20; + display: flex; + width: 16px; + height: 16px; + border-radius: 15px; + border: 1px solid; + align-items: center; + justify-content: center; + } + } +} + +.mobileCard { + border-top: 1px solid #A8A8A8; + margin-top: $sp-2; +} + +.mobileRow { + display: flex; + padding-top: $sp-2; + padding-left: $sp-4; + padding-right: $sp-4; + > * { + flex: 1 1 50%; + } +} +.label { + font-weight: bold; +} +.value { + + svg { + display: inline; + vertical-align: middle; + margin-right: $sp-1; + } + +} diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx new file mode 100644 index 000000000..ed9c21b3e --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -0,0 +1,243 @@ +import { FC, useMemo } from 'react' +import moment from 'moment' + +import { CheckIcon, MinusCircleIcon } from '@heroicons/react/outline' +import { useWindowSize, WindowSize } from '~/libs/shared' +import { IconOutline, Tooltip } from '~/libs/ui' + +import { + AiWorkflowRun, + AiWorkflowRunsResponse, + AiWorkflowRunStatus, + useFetchAiWorkflowsRuns, + useRolePermissions, + UseRolePermissionsResult, +} from '../../hooks' +import { IconAiReview } from '../../assets/icons' +import { TABLE_DATE_FORMAT } from '../../../config/index.config' +import { BackendSubmission } from '../../models' + +import styles from './AiReviewsTable.module.scss' + +interface AiReviewsTableProps { + submission: Pick + reviewers: { aiWorkflowId: string }[] +} + +const aiRunInProgress = (aiRun: Pick): boolean => [ + AiWorkflowRunStatus.INIT, + AiWorkflowRunStatus.QUEUED, + AiWorkflowRunStatus.DISPATCHED, + AiWorkflowRunStatus.IN_PROGRESS, +].includes(aiRun.status) + +const aiRunFailed = (aiRun: Pick): boolean => [ + AiWorkflowRunStatus.FAILURE, + AiWorkflowRunStatus.CANCELLED, +].includes(aiRun.status) + +const AiReviewsTable: FC = props => { + const aiWorkflowIds = useMemo(() => props.reviewers.map(r => r.aiWorkflowId), [props.reviewers]) + const { runs, isLoading }: AiWorkflowRunsResponse = useFetchAiWorkflowsRuns(props.submission.id, aiWorkflowIds) + + const windowSize: WindowSize = useWindowSize() + const isTablet = useMemo( + () => (windowSize.width ?? 0) <= 984, + [windowSize.width], + ) + const { isAdmin }: UseRolePermissionsResult = useRolePermissions() + + const aiRuns = useMemo(() => [ + ...runs, + { + completedAt: (props.submission as BackendSubmission).submittedDate, + id: '-1', + score: props.submission.virusScan === true ? 100 : 0, + status: AiWorkflowRunStatus.SUCCESS, + workflow: { + description: '', + name: 'Virus Scan', + }, + } as AiWorkflowRun, + ].filter(r => isAdmin || !aiRunFailed(r)), [runs, props.submission]) + + if (isTablet) { + return ( +
+ {!runs.length && isLoading && ( +
Loading...
+ )} + + {aiRuns.map(run => ( +
+
+
Reviewer
+
+ + + + + {run.workflow.name} + +
+
+ +
+
Review Date
+
+ {run.status === 'SUCCESS' + ? moment(run.completedAt) + .local() + .format(TABLE_DATE_FORMAT) + : '-'} +
+
+ +
+
Score
+
+ {run.status === 'SUCCESS' ? ( + run.workflow.scorecard ? ( + {run.score} + ) : run.score + ) : '-'} +
+
+ +
+
Result
+
+ {run.status === 'SUCCESS' && ( +
+ {run.score >= (run.workflow.scorecard?.minimumPassingScore ?? 0) ? ( + <> + + {' '} + Passed + + ) : ( + <> + + {' '} + Failed + + )} +
+ )} + {aiRunInProgress(run) && ( +
+ + + + {' '} + To be filled +
+ )} + {aiRunFailed(run) && ( +
+ + + +
+ )} +
+
+
+ ))} +
+ ) + } + + return ( +
+ + + + + + + + + + + + {!runs.length && isLoading && ( + + + + )} + + {aiRuns.map(run => ( + + + + + + + ))} + +
AI ReviewerReview DateScoreResult
Loading...
+
+ + + + + + {run.workflow.name} + + +
+
+ {run.status === 'SUCCESS' && ( + moment(run.completedAt) + .local() + .format(TABLE_DATE_FORMAT) + )} + + {run.status === 'SUCCESS' ? ( + run.workflow.scorecard ? ( + {run.score} + ) : run.score + ) : '-'} + + {run.status === 'SUCCESS' && ( +
+ {run.score >= (run.workflow.scorecard?.minimumPassingScore ?? 0) + ? ( + <> + + {' '} + Passed + + ) + : ( + <> + + {' '} + Failed + + )} +
+ )} + {aiRunInProgress(run) && ( +
+ + + + {' '} + To be filled +
+ )} + {aiRunFailed(run) && ( +
+ + + +
+ )} +
+
+ ) +} + +export default AiReviewsTable diff --git a/src/apps/review/src/lib/components/AiReviewsTable/index.ts b/src/apps/review/src/lib/components/AiReviewsTable/index.ts new file mode 100644 index 000000000..2a13d8a91 --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/index.ts @@ -0,0 +1 @@ +export { default as AiReviewsTable } from './AiReviewsTable' diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx index 3643e2994..d72fe3f0e 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx @@ -126,6 +126,7 @@ interface SubmissionTabParams { isDownloadingSubmission: useDownloadSubmissionProps['isLoading'] downloadSubmission: useDownloadSubmissionProps['downloadSubmission'] isActiveChallenge: boolean + aiReviewers: { aiWorkflowId: string }[] } const renderSubmissionTab = ({ @@ -137,6 +138,7 @@ const renderSubmissionTab = ({ isDownloadingSubmission, downloadSubmission, isActiveChallenge, + aiReviewers, }: SubmissionTabParams): JSX.Element => { const isSubmissionTab = selectedTabNormalized === 'submission' const isTopgearSubmissionTab = selectedTabNormalized === 'topgearsubmission' @@ -155,6 +157,7 @@ const renderSubmissionTab = ({ if (canShowSubmissionList) { return ( = (props: Props) => { if (SUBMISSION_TAB_KEYS.has(selectedTabNormalized)) { return renderSubmissionTab({ + aiReviewers: ( + challengeInfo?.reviewers?.filter(r => !!r.aiWorkflowId) as { aiWorkflowId: string }[] + ) ?? [], downloadSubmission, isActiveChallenge: props.isActiveChallenge, isDownloadingSubmission, diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.module.scss b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.module.scss index 15a635655..87cdbb723 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.module.scss +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.module.scss @@ -126,3 +126,13 @@ pointer-events: none; } } + +.aiReviewerRow { + @include ltelg { + tr:has(&) { + td:first-child { + display: none; + } + } + } +} diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx index 81816c9fa..c2f2acf3f 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx @@ -41,10 +41,12 @@ import { } from '../../utils' import type { SubmissionHistoryPartition } from '../../utils' import { TABLE_DATE_FORMAT } from '../../../config/index.config' +import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' import styles from './TabContentSubmissions.module.scss' interface Props { + aiReviewers?: { aiWorkflowId: string }[] submissions: BackendSubmission[] isLoading: boolean isDownloading: IsRemovingType @@ -317,27 +319,16 @@ export const TabContentSubmissions: FC = props => { type: 'element', }, { - label: 'Virus Scan', + className: styles.aiReviewerRow, + label: 'Reviewer', + mobileColSpan: 2, propertyName: 'virusScan', - renderer: (submission: BackendSubmission) => { - if (submission.virusScan === true) { - return ( - - - - ) - } - - if (submission.virusScan === false) { - return ( - - - - ) - } - - return - - }, + renderer: (submission: BackendSubmission) => ( + + ), type: 'element', }, ] @@ -405,6 +396,7 @@ export const TabContentSubmissions: FC = props => { }, { ...column, + colSpan: column.mobileColSpan, mobileType: 'last-value', }, ] as MobileTableColumn[], @@ -442,6 +434,7 @@ export const TabContentSubmissions: FC = props => { isDownloading={props.isDownloading} getRestriction={getHistoryRestriction} getSubmissionMeta={resolveSubmissionMeta} + aiReviewers={props.aiReviewers} /> ) diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss new file mode 100644 index 000000000..95a40e950 --- /dev/null +++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss @@ -0,0 +1,36 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + width: 100%; + text-align: left; +} + +.reviewersDropown { + display: flex; + align-items: center; + gap: $sp-2; + cursor: pointer; + + @include ltelg { + justify-content: space-between; + font-weight: 600; + } + + svg { + color: #767676; + } +} + +.table { + margin-top: $sp-2; + margin-left: -1 * $sp-4; + @include ltelg { + margin-top: 0; + margin-left: -1 * $sp-4; + margin-right: -1 * $sp-4; + } +} + +.rotated { + transform: rotate(180deg); +} diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx new file mode 100644 index 000000000..5d00a31c0 --- /dev/null +++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx @@ -0,0 +1,46 @@ +import { FC, useCallback, useState } from 'react' +import classNames from 'classnames' + +import { IconOutline } from '~/libs/ui' + +import { AiReviewsTable } from '../AiReviewsTable' +import { BackendSubmission } from '../../models' + +import styles from './CollapsibleAiReviewsRow.module.scss' + +interface CollapsibleAiReviewsRowProps { + aiReviewers: { aiWorkflowId: string }[] + submission: BackendSubmission +} + +const CollapsibleAiReviewsRow: FC = props => { + const aiReviewersCount = props.aiReviewers.length + 1 + + const [isOpen, setIsOpen] = useState(false) + + const toggleOpen = useCallback(() => { + setIsOpen(wasOpen => !wasOpen) + }, []) + + return ( +
+ + {aiReviewersCount} + {' '} + AI Reviewer + {aiReviewersCount === 1 ? '' : 's'} + + + {isOpen && ( +
+ +
+ )} +
+ ) +} + +export default CollapsibleAiReviewsRow diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/index.ts b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/index.ts new file mode 100644 index 000000000..757542122 --- /dev/null +++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/index.ts @@ -0,0 +1 @@ +export { default as CollapsibleAiReviewsRow } from './CollapsibleAiReviewsRow' diff --git a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.module.scss b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.module.scss index 59fc9266f..6fc566bec 100644 --- a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.module.scss +++ b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.module.scss @@ -146,3 +146,21 @@ font-size: 14px; text-align: center; } + +.reviewersDropown { + display: flex; + align-items: center; + gap: $sp-2; + + svg { + color: #767676; + } +} + +.table .aiReviewersTableRow.aiReviewersTableRow { + padding: 0; +} + +.aiReviewersTable { + margin-top: -1px; +} diff --git a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx index 71a53ae8b..eede31bf7 100644 --- a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx +++ b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx @@ -1,4 +1,4 @@ -import { FC, MouseEvent, useCallback, useMemo } from 'react' +import { FC, Fragment, MouseEvent, useCallback, useMemo, useState } from 'react' import { toast } from 'react-toastify' import classNames from 'classnames' import moment from 'moment' @@ -9,6 +9,7 @@ import { BaseModal, IconOutline, Tooltip } from '~/libs/ui' import { SubmissionInfo } from '../../models' import { TABLE_DATE_FORMAT } from '../../../config/index.config' +import { AiReviewsTable } from '../AiReviewsTable' import styles from './SubmissionHistoryModal.module.scss' @@ -29,6 +30,7 @@ export interface SubmissionHistoryModalProps { * when the provided submission entry is missing those fields. */ getSubmissionMeta?: (submissionId: string) => SubmissionInfo | undefined + aiReviewers?: { aiWorkflowId: string }[] } function getTimestamp(submission: SubmissionInfo): number { @@ -97,6 +99,11 @@ export const SubmissionHistoryModal: FC = (props: S .sort((a, b) => getTimestamp(b) - getTimestamp(a)), [props.submissions], ) + + const aiReviewersCount = useMemo(() => (props.aiReviewers?.length ?? 0) + 1, [props.aiReviewers]) + + const [toggledRows, setToggledRows] = useState(new Set()) + const resolvedMemberInfo = useMemo(() => { for (const submission of sortedSubmissions) { if (submission.userInfo?.memberHandle) { @@ -171,6 +178,19 @@ export const SubmissionHistoryModal: FC = (props: S .catch(() => undefined) }, [handleCopy]) + const toggleRow = useCallback((rowId: string) => { + setToggledRows(previous => { + const next = new Set(previous) + if (next.has(rowId)) { + next.delete(rowId) + } else { + next.add(rowId) + } + + return next + }) + }, []) + const renderHistoryRow = useCallback((submission: SubmissionInfo): JSX.Element => { const fallbackMeta = props.getSubmissionMeta?.(submission.id) ?? undefined const resolvedVirusScan = submission.virusScan ?? fallbackMeta?.virusScan @@ -222,33 +242,54 @@ export const SubmissionHistoryModal: FC = (props: S ) + function toggle(): void { + toggleRow(submission.id) + } + return ( - - - - {renderedDownloadButton} - {copyButton} - - - - {submittedDisplay} - - - {resolvedVirusScan === true ? ( - - + + + + + {renderedDownloadButton} + {copyButton} - ) : resolvedVirusScan === false ? ( - - + + + {submittedDisplay} + + + + {aiReviewersCount} + {' '} + AI Reviewer + {aiReviewersCount === 1 ? '' : 's'} + - ) : ( - - - )} - - + + + {toggledRows.has(submission.id) && ( + + +
+ +
+ + + )} +
) - }, [handleCopy, props.downloadSubmission, props.getRestriction, props.getSubmissionMeta, props.isDownloading]) + }, [ + handleCopy, + props.downloadSubmission, + props.getRestriction, + props.getSubmissionMeta, + props.isDownloading, + toggledRows, + ]) return ( = (props: S Submission ID Submitted - Virus Scan + Reviewer diff --git a/src/apps/review/src/lib/hooks/index.ts b/src/apps/review/src/lib/hooks/index.ts index 38b71fef1..5d2b5455f 100644 --- a/src/apps/review/src/lib/hooks/index.ts +++ b/src/apps/review/src/lib/hooks/index.ts @@ -18,3 +18,4 @@ export * from './useScoreVisibility' export * from './useSubmissionDownloadAccess' export * from './useSubmissionHistory' export * from './useScorecardPassingScores' +export * from './useFetchAiWorkflowRuns' diff --git a/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts new file mode 100644 index 000000000..14d810b47 --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts @@ -0,0 +1,106 @@ +import { useEffect } from 'react' +import useSWR, { SWRResponse } from 'swr' + +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' +import { handleError } from '~/libs/shared/lib/utils/handle-error' + +export enum AiWorkflowRunStatus { + INIT = 'INIT', + QUEUED = 'QUEUED', + DISPATCHED = 'DISPATCHED', + IN_PROGRESS = 'IN_PROGRESS', + CANCELLED = 'CANCELLED', + FAILURE = 'FAILURE', + COMPLETED = 'COMPLETED', + SUCCESS = 'SUCCESS', +} + +export interface AiWorkflowRun { + id: string; + completedAt: string; + status: AiWorkflowRunStatus; + score: number; + workflow: { + name: string; + description: string; + scorecard?: { + id: string; + name: string; + minimumPassingScore: number; + } + } +} + +const TC_API_BASE_URL = EnvironmentConfig.API.V6 + +export interface AiWorkflowRunsResponse { + runs: AiWorkflowRun[] + isLoading: boolean +} + +export function useFetchAiWorkflowRuns( + workflowId: string, + submissionId: string, +): AiWorkflowRunsResponse { + // Use swr hooks for challenge info fetching + const { + data: runs = [], + error: fetchError, + isValidating: isLoading, + }: SWRResponse = useSWR( + `${TC_API_BASE_URL}/workflows/${workflowId}/runs?submissionId=${submissionId}`, + { + isPaused: () => !workflowId || !submissionId, + }, + ) + + // Show backend error when fetching challenge info + useEffect(() => { + if (fetchError) { + handleError(fetchError) + } + }, [fetchError]) + + return { + isLoading, + runs, + } +} + +export function useFetchAiWorkflowsRuns( + submissionId: string, + workflowIds: string[], +): AiWorkflowRunsResponse { + // Use swr hooks for challenge info fetching + const { + data: runs = [], + error: fetchError, + isValidating: isLoading, + }: SWRResponse = useSWR( + `${TC_API_BASE_URL}/workflows/${workflowIds.join(',')}/runs?submissionId=${submissionId}`, + { + fetcher: () => Promise.all( + workflowIds.map(workflowId => ( + xhrGetAsync( + `${TC_API_BASE_URL}/workflows/${workflowId}/runs?submissionId=${submissionId}`, + ) + )), + ) + .then(results => results.flat()), + isPaused: () => !workflowIds?.length || !submissionId, + }, + ) + + // Show backend error when fetching challenge info + useEffect(() => { + if (fetchError) { + handleError(fetchError) + } + }, [fetchError]) + + return { + isLoading, + runs, + } +} diff --git a/src/apps/review/src/lib/models/ChallengeInfo.model.ts b/src/apps/review/src/lib/models/ChallengeInfo.model.ts index 10f4a078e..b2b417230 100644 --- a/src/apps/review/src/lib/models/ChallengeInfo.model.ts +++ b/src/apps/review/src/lib/models/ChallengeInfo.model.ts @@ -72,7 +72,7 @@ export interface ChallengeInfo { basePayment: number incrementalPayment: number type: string - isAIReviewer: boolean + aiWorkflowId?: string; }[] currentPhaseObject?: BackendPhase metadata?: BackendMetadata[] diff --git a/src/libs/ui/lib/components/table/table-column.model.ts b/src/libs/ui/lib/components/table/table-column.model.ts index d0fb0d15c..a230cb27e 100644 --- a/src/libs/ui/lib/components/table/table-column.model.ts +++ b/src/libs/ui/lib/components/table/table-column.model.ts @@ -13,4 +13,5 @@ export interface TableColumn { readonly type: TableCellType readonly isSortable?: boolean readonly columnId?: string + readonly mobileColSpan?: number }