diff --git a/.circleci/config.yml b/.circleci/config.yml index b23ba76b9..db8617ea2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -232,6 +232,7 @@ workflows: - feat/system-admin - feat/v6 - pm-2074_1 + - feat/ai-workflows - deployQa: context: org-global diff --git a/package.json b/package.json index 1f2c0c9a0..fab70bf67 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "sass": "^1.79.0", "styled-components": "^5.3.6", "swr": "^1.3.0", - "tc-auth-lib": "topcoder-platform/tc-auth-lib#master", + "tc-auth-lib": "topcoder-platform/tc-auth-lib#v2.0", "tinymce": "^7.9.1", "typescript": "^4.8.4", "universal-navigation": "https://github.com/topcoder-platform/universal-navigation#9fc50d938be7182", diff --git a/public/llm-icons/chatgpt-icon.svg b/public/llm-icons/chatgpt-icon.svg new file mode 100644 index 000000000..f6f6925e7 --- /dev/null +++ b/public/llm-icons/chatgpt-icon.svg @@ -0,0 +1 @@ +ChatGPT \ No newline at end of file diff --git a/public/llm-icons/deepseek-icon.svg b/public/llm-icons/deepseek-icon.svg new file mode 100644 index 000000000..e54d70391 --- /dev/null +++ b/public/llm-icons/deepseek-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/llm-icons/google-gemini-icon.svg b/public/llm-icons/google-gemini-icon.svg new file mode 100644 index 000000000..ecc24b6c2 --- /dev/null +++ b/public/llm-icons/google-gemini-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/llm-icons/meta-llama-3-icon.svg b/public/llm-icons/meta-llama-3-icon.svg new file mode 100644 index 000000000..7b9223978 --- /dev/null +++ b/public/llm-icons/meta-llama-3-icon.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + diff --git a/public/llm-icons/qwen-icon.svg b/public/llm-icons/qwen-icon.svg new file mode 100644 index 000000000..78a16baf5 --- /dev/null +++ b/public/llm-icons/qwen-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index 0d5163fb3..b5db82d55 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -17,6 +17,7 @@ import { paymentsRouteId, permissionManagementRouteId, platformRouteId, + reportsRouteId, rootRoute, termsRouteId, userManagementRouteId, @@ -168,6 +169,10 @@ const PaymentsPage: LazyLoadedComponent = lazyLoad( () => import('./payments/PaymentsPage'), 'PaymentsPage', ) +const ReportsPage: LazyLoadedComponent = lazyLoad( + () => import('./reports/ReportsPage'), + 'ReportsPage', +) export const toolTitle: string = ToolTitle.admin @@ -402,6 +407,12 @@ export const adminRoutes: ReadonlyArray = [ id: paymentsRouteId, route: paymentsRouteId, }, + // Reports Module + { + element: , + id: reportsRouteId, + route: reportsRouteId, + }, ], domain: AppSubdomain.admin, element: , diff --git a/src/apps/admin/src/config/busEvent.config.ts b/src/apps/admin/src/config/busEvent.config.ts index e7eb52567..796a97de1 100644 --- a/src/apps/admin/src/config/busEvent.config.ts +++ b/src/apps/admin/src/config/busEvent.config.ts @@ -3,6 +3,8 @@ */ import { v4 as uuidv4 } from 'uuid' +import { EnvironmentConfig } from '~/config' + import { RequestBusAPI, RequestBusAPIAVScan, @@ -43,11 +45,11 @@ export const CREATE_BUS_EVENT_AV_RESCAN = ( payload: RequestBusAPIAVScanPayload, ): RequestBusAPIAVScan => ({ 'mime-type': 'application/json', - originator: 'submission-processor', + originator: 'review-api-v6', payload, timestamp: new Date() .toISOString(), - topic: 'avscan.action.scan', + topic: EnvironmentConfig.ADMIN.AVSCAN_TOPIC, }) export const SUBMISSION_REPROCESS_TOPICS = { diff --git a/src/apps/admin/src/config/routes.config.ts b/src/apps/admin/src/config/routes.config.ts index a2ebf2790..b1b523086 100644 --- a/src/apps/admin/src/config/routes.config.ts +++ b/src/apps/admin/src/config/routes.config.ts @@ -18,3 +18,4 @@ export const termsRouteId = 'terms' export const defaultReviewersRouteId = 'default-reviewers' export const platformRouteId = 'platform' export const paymentsRouteId = 'payments' +export const reportsRouteId = 'reports' diff --git a/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.tsx b/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.tsx index 80d7218bc..ec7504c9b 100644 --- a/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.tsx +++ b/src/apps/admin/src/lib/components/DefaultReviewersAddForm/DefaultReviewersAddForm.tsx @@ -1,17 +1,7 @@ -import { - useCallback, - useEffect, - useMemo, - useState, -} from 'react' import type { FC } from 'react' -import { useNavigate, useParams } from 'react-router-dom' -import type { NavigateFunction } from 'react-router-dom' -import { Controller, useForm } from 'react-hook-form' -import type { - ControllerRenderProps, - UseFormReturn, -} from 'react-hook-form' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { NavigateFunction, useNavigate, useParams } from 'react-router-dom' +import { Controller, ControllerRenderProps, useForm, UseFormReturn } from 'react-hook-form' import _ from 'lodash' import classNames from 'classnames' @@ -25,13 +15,14 @@ import { LinkButton, } from '~/libs/ui' -import { FormAddWrapper } from '../common/FormAddWrapper' -import { FormAddDefaultReviewer } from '../../models' -import { formAddDefaultReviewerSchema } from '../../utils' import { useManageAddDefaultReviewer, useManageAddDefaultReviewerProps, } from '../../hooks' +import { FormAddWrapper } from '../common/FormAddWrapper' +import { FormAddDefaultReviewer } from '../../models' +import { formAddDefaultReviewerSchema } from '../../utils' +import { getAiWorkflows } from '../../services/ai-workflows.service' import styles from './DefaultReviewersAddForm.module.scss' @@ -127,10 +118,10 @@ export const DefaultReviewersAddForm: FC = (props: Props) => { formState: { errors, isDirty }, }: UseFormReturn = useForm({ defaultValues: { + aiWorkflowId: '', baseCoefficient: 0, fixedAmount: 0, incrementalCoefficient: 0, - isAIReviewer: false, isMemberReview: false, memberReviewerCount: 0, opportunityType: '', @@ -148,10 +139,31 @@ export const DefaultReviewersAddForm: FC = (props: Props) => { const onSubmit = useCallback( (data: FormAddDefaultReviewer) => { + const isMemberReview = data.isMemberReview const requestBody = _.omitBy( data, value => value === undefined || value === null || value === '', ) + + if (isMemberReview) { + requestBody.memberReviewerCount = undefined + // Also clear fields when isMemberReview is false + requestBody.scorecardId = undefined + requestBody.fixedAmount = undefined + requestBody.baseCoefficient = undefined + requestBody.incrementalCoefficient = undefined + requestBody.opportunityType = undefined + // The reason for flipping the value is that + // in UI the checkbox is shown as "is AI review" + // but in the database its denoted as member review + // so we are just flipping the boolean value + requestBody.isMemberReview = false + } else { + // eslint-disable-next-line unicorn/no-null + requestBody.aiWorkflowId = null + requestBody.isMemberReview = true + } + if (isEdit) { doUpdateDefaultReviewer(requestBody, () => { navigate('./../..') @@ -165,16 +177,26 @@ export const DefaultReviewersAddForm: FC = (props: Props) => { [doAddDefaultReviewer, doUpdateDefaultReviewer, isEdit, navigate], ) + const [aiWorkflows, setAiWorkflows] = useState<{ label: string; value: string }[]>([]) + + useEffect(() => { + getAiWorkflows() + .then((workflows: { id: string; name: string }[]) => { + const options = workflows.map((wf: { id: string; name: string }) => ({ label: wf.name, value: wf.id })) + setAiWorkflows(options) + }) + }, []) + const isMemberReview = watch('isMemberReview') useEffect(() => { if (defaultReviewerInfo) { reset({ + aiWorkflowId: defaultReviewerInfo.aiWorkflowId ?? '', baseCoefficient: defaultReviewerInfo.baseCoefficient ?? 0, fixedAmount: defaultReviewerInfo.fixedAmount ?? 0, incrementalCoefficient: defaultReviewerInfo.incrementalCoefficient ?? 0, - isAIReviewer: defaultReviewerInfo.isAIReviewer, - isMemberReview: defaultReviewerInfo.isMemberReview, + isMemberReview: !defaultReviewerInfo.isMemberReview, memberReviewerCount: defaultReviewerInfo.memberReviewerCount ?? 0, opportunityType: defaultReviewerInfo.opportunityType ?? '', phaseId: defaultReviewerInfo.phaseId ?? '', @@ -301,30 +323,34 @@ export const DefaultReviewersAddForm: FC = (props: Props) => { ) }} /> - - }) { - return ( - - ) - }} - /> + { + !isMemberReview && ( + + }) { + return ( + + ) + }} + /> + ) + } = (props: Props) => { return ( = (props: Props) => { }} /> - {isMemberReview && ( + {!isMemberReview && ( = (props: Props) => { classNameWrapper={styles.inputField} /> )} - - - - - }) { - return ( - + + { + if (typeof v === 'string') { + const normalized = v.replace(',', '.') + const parsed = parseFloat(normalized) + return Number.isNaN(parsed) ? undefined : parsed + } + + return v + }, + valueAsNumber: false, + })} + dirty + disabled={isLoading} + classNameWrapper={styles.inputField} + /> + { + if (typeof v === 'string') { + const normalized = v.replace(',', '.') + const parsed = parseFloat(normalized) + return Number.isNaN(parsed) ? undefined : parsed + } + + return v + }, + valueAsNumber: false, + })} + dirty + disabled={isLoading} + classNameWrapper={styles.inputField} + /> + + }) { + return ( + + ) + }} + /> + + )} + + { + !isMemberReview && ( +
+ + }) { + return ( + + ) + }} /> - ) - }} - /> -
- - }) { - return ( - - ) - }} - /> -
-
+
+ ) + } + {isMemberReview && ( + field: ControllerRenderProps }) { return ( - ) }} /> -
+ )} { + if (submission.virusScan === true) { + return ( + + + + ) + } + + if (submission.virusScan === false) { + return ( + + + + ) + } + + return -- +} + interface Props { className?: string data: Submission[] @@ -92,6 +126,11 @@ export const SubmissionTable: FC = (props: Props) => { propertyName: 'id', type: 'text', }, + { + label: 'Virus Scan', + renderer: renderVirusScanStatus, + type: 'element', + }, { label: 'Submission date', propertyName: 'submittedDateString', @@ -166,6 +205,10 @@ export const SubmissionTable: FC = (props: Props) => { isRemovingReviewSummations={ props.isRemovingReviewSummations } + isDownloading={props.isDownloading} + downloadSubmission={props.downloadSubmission} + isDoingAvScan={props.isDoingAvScan} + doPostBusEventAvScan={props.doPostBusEventAvScan} setShowConfirmDeleteSubmissionDialog={ setShowConfirmDeleteSubmissionDialog } @@ -192,6 +235,11 @@ export const SubmissionTable: FC = (props: Props) => { propertyName: 'id', type: 'text', }, + { + label: 'Virus Scan', + renderer: renderVirusScanStatus, + type: 'element', + }, { label: 'Time submitted', propertyName: 'submittedDateString', diff --git a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActions.tsx b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActions.tsx index 71fea7aa4..a5a33c19f 100644 --- a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActions.tsx +++ b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActions.tsx @@ -17,6 +17,10 @@ interface Props { isRunningTest: IsRemovingType isRemovingSubmission: IsRemovingType isRemovingReviewSummations: IsRemovingType + isDownloading: IsRemovingType + isDoingAvScan: IsRemovingType + downloadSubmission: (submissionId: string) => void + doPostBusEventAvScan: (submission: Submission) => void doPostBusEvent: DoPostBusEvent setShowConfirmDeleteSubmissionDialog: Dispatch< SetStateAction @@ -82,6 +86,28 @@ export const SubmissionTableActions: FC = (props: Props) => { > Run Provisional Test +
  • + Download +
  • +
  • + AV Rescan +
  • = props => ( { + const response = await xhrGetAsync(`${EnvironmentConfig.API.V6}/workflows`) + return response +} diff --git a/src/apps/admin/src/lib/services/index.ts b/src/apps/admin/src/lib/services/index.ts index b47f23458..bf260a1b9 100644 --- a/src/apps/admin/src/lib/services/index.ts +++ b/src/apps/admin/src/lib/services/index.ts @@ -14,3 +14,4 @@ export * from './default-reviewers.service' export * from './timeline-templates.service' export * from './phases.service' export * from './scorecards.service' +export * from './reports.service' diff --git a/src/apps/admin/src/lib/services/reports.service.ts b/src/apps/admin/src/lib/services/reports.service.ts new file mode 100644 index 000000000..a0dd1ef24 --- /dev/null +++ b/src/apps/admin/src/lib/services/reports.service.ts @@ -0,0 +1,64 @@ +import type { AxiosInstance } from 'axios' + +import { EnvironmentConfig } from '~/config' +import { xhrCreateInstance, xhrGetAsync } from '~/libs/core/lib/xhr' + +export type ReportParameter = { + name: string + type: 'string' | 'string[]' | 'number' | 'number[]' | 'boolean' | 'date' | 'enum' | 'enum[]' + description?: string + required?: boolean + location?: 'query' | 'path' + options?: string[] +} + +export type ReportDefinition = { + name: string + path: string + description?: string + method: string + parameters?: ReportParameter[] +} + +export type ReportGroup = { + label: string + basePath: string + reports: ReportDefinition[] +} + +export type ReportsIndexResponse = Record + +const reportsDownloadClient: AxiosInstance = xhrCreateInstance() + +const buildReportUrl = (path: string): string => { + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${EnvironmentConfig.API.V6}/reports${normalizedPath}` +} + +export const fetchReportsIndex = async (): Promise => ( + xhrGetAsync(`${EnvironmentConfig.API.V6}/reports`) +) + +const downloadReportBlob = async (path: string, accept: string): Promise => { + if (!path) { + throw new Error('Report path is required') + } + + const url = buildReportUrl(path) + const response = await reportsDownloadClient.get(url, { + headers: { + Accept: accept, + }, + responseType: 'blob', + }) + + return response.data +} + +export const downloadReportAsJson = (path: string): Promise => ( + downloadReportBlob(path, 'application/json') +) + +export const downloadReportAsCsv = (path: string): Promise => ( + downloadReportBlob(path, 'text/csv') +) diff --git a/src/apps/admin/src/lib/services/submissions.service.ts b/src/apps/admin/src/lib/services/submissions.service.ts index 390f794e0..47f29c2f1 100644 --- a/src/apps/admin/src/lib/services/submissions.service.ts +++ b/src/apps/admin/src/lib/services/submissions.service.ts @@ -108,9 +108,23 @@ export const createAvScanSubmissionPayload = async ( throw new Error('Submission url is not valid') } - const { isValid, key: fileName }: ValidateS3URIResult = validateS3URI(url) + const { + isValid, + key: fileName, + bucket, + }: ValidateS3URIResult = validateS3URI(url) + const isQuarantineBucket = Boolean( + bucket && bucket.toLowerCase() + .endsWith('-dmz'), + ) + const allowQuarantineRescan = submissionInfo.virusScan === false + && isQuarantineBucket + + if (!isValid && !allowQuarantineRescan) { + throw new Error('Submission url is not valid') + } - if (!isValid) { + if (!fileName) { throw new Error('Submission url is not valid') } diff --git a/src/apps/admin/src/lib/utils/others.ts b/src/apps/admin/src/lib/utils/others.ts index 0cac22b99..8c5d90738 100644 --- a/src/apps/admin/src/lib/utils/others.ts +++ b/src/apps/admin/src/lib/utils/others.ts @@ -35,17 +35,16 @@ export function validateS3URI( ): ValidateS3URIResult { try { const { region, bucket, key }: AmazonS3URI = AmazonS3URI(fileURL) - if ( - region !== EnvironmentConfig.ADMIN.AWS_REGION - || bucket !== EnvironmentConfig.ADMIN.AWS_DMZ_BUCKET - ) { - return { isValid: false } - } + const parsedBucket = bucket ?? undefined + const parsedKey = key ?? undefined + const isValid = Boolean(parsedBucket) + && region === EnvironmentConfig.ADMIN.AWS_REGION + && parsedBucket === EnvironmentConfig.ADMIN.AWS_DMZ_BUCKET return { - bucket: bucket ?? undefined, - isValid: true, - key: key ?? undefined, + bucket: parsedBucket, + isValid, + key: parsedKey, } } catch (error) {} diff --git a/src/apps/admin/src/lib/utils/validation-schemas.ts b/src/apps/admin/src/lib/utils/validation-schemas.ts index d458e795e..3ed8bb800 100644 --- a/src/apps/admin/src/lib/utils/validation-schemas.ts +++ b/src/apps/admin/src/lib/utils/validation-schemas.ts @@ -20,23 +20,33 @@ export const formSearchDefaultReviewersSchema: Yup.ObjectSchema = Yup.object({ + aiWorkflowId: Yup.string() + .optional(), baseCoefficient: Yup.number() .optional() - .min(0, 'Must be non-negative'), + .min(0, 'Must be non-negative') + .transform((value, originalValue) => { + if (typeof originalValue === 'string') { + // Replace comma with dot for decimal separator + const normalized = originalValue.replace(',', '.') + return parseFloat(normalized) + } + + return value + }) + .typeError('Please enter a valid number'), fixedAmount: Yup.number() .optional() .min(0, 'Must be non-negative'), incrementalCoefficient: Yup.number() .optional() .min(0, 'Must be non-negative'), - isAIReviewer: Yup.boolean() - .required(), isMemberReview: Yup.boolean() .required(), memberReviewerCount: Yup.number() .optional() .when('isMemberReview', { - is: true, + is: false, otherwise: schema => schema.optional(), then: schema => schema .required('Member Reviewer Count is required when Is Member Review is checked') @@ -49,7 +59,13 @@ export const formAddDefaultReviewerSchema: Yup.ObjectSchema schema.optional(), + then: schema => schema + .required('Scorecard is required'), + }), shouldOpenOpportunity: Yup.boolean() .required(), timelineTemplateId: Yup.string() diff --git a/src/apps/admin/src/reports/ReportsPage.module.scss b/src/apps/admin/src/reports/ReportsPage.module.scss new file mode 100644 index 000000000..35872b51a --- /dev/null +++ b/src/apps/admin/src/reports/ReportsPage.module.scss @@ -0,0 +1,80 @@ +.page { + display: flex; + flex-direction: column; + gap: 24px; +} + +.instructions { + color: #565a5f; + max-width: 720px; +} + +.selectors { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.select { + min-width: 260px; + max-width: 340px; +} + +.reportDetails { + display: flex; + flex-direction: column; + gap: 4px; +} + +.params { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-top: 12px; +} + +.paramLabel { + font-weight: 600; +} + +.paramMeta { + color: #6b6f75; + font-size: 12px; +} + +.paramHint { + color: #6b6f75; + font-size: 12px; + font-style: italic; +} + +.reportTitle { + font-weight: 600; +} + +.reportDescription { + color: #494f55; +} + +.reportMeta { + font-family: 'Roboto Mono', monospace; + font-size: 13px; + color: #6b6f75; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.spinnerWrapper { + padding: 40px 0; + display: flex; + justify-content: center; +} + +.emptyState { + font-style: italic; + color: #6b6f75; +} diff --git a/src/apps/admin/src/reports/ReportsPage.tsx b/src/apps/admin/src/reports/ReportsPage.tsx new file mode 100644 index 000000000..98053d0dd --- /dev/null +++ b/src/apps/admin/src/reports/ReportsPage.tsx @@ -0,0 +1,393 @@ +import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' + +import { Button, InputSelect, InputSelectOption, InputText, LoadingSpinner, PageTitle } from '~/libs/ui' + +import { PageContent, PageHeader } from '../lib' +import { handleError } from '../lib/utils' +import { + downloadReportAsCsv, + downloadReportAsJson, + fetchReportsIndex, + ReportDefinition, + ReportGroup, + ReportParameter, + ReportsIndexResponse, +} from '../lib/services' + +import styles from './ReportsPage.module.scss' + +const pageTitle = 'Reports' + +const buildDownloadName = (name: string, extension: 'json' | 'csv'): string => { + const normalized = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, '') + + const base = normalized || 'report' + return `${base}.${extension}` +} + +const formatMethod = (method?: string): string => ( + method ? method.toUpperCase() : 'GET' +) + +export const ReportsPage: FC = () => { + const [reportsIndex, setReportsIndex] = useState({}) + const [selectedBasePath, setSelectedBasePath] = useState('') + const [selectedReportPath, setSelectedReportPath] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [downloadingFormat, setDownloadingFormat] = useState<'json' | 'csv' | undefined>(undefined) + const [parameterValues, setParameterValues] = useState>({}) + + useEffect(() => { + let isMounted = true + setIsLoading(true) + + fetchReportsIndex() + .then(data => { + if (!isMounted) return + setReportsIndex(data ?? {}) + }) + .catch(error => { + if (!isMounted) return + handleError(error) + }) + .finally(() => { + if (isMounted) { + setIsLoading(false) + } + }) + + return () => { + isMounted = false + } + }, []) + + const basePathOptions = useMemo(() => { + const groups: ReportGroup[] = Object.values(reportsIndex ?? {}) + const options = groups.map(group => ({ + label: group.label || group.basePath, + value: group.basePath, + })) + + options.sort((a, b) => a.label.localeCompare(b.label)) + return options + }, [reportsIndex]) + + const selectedGroup = useMemo(() => ( + selectedBasePath + ? Object.values(reportsIndex) + .find(group => group.basePath === selectedBasePath) + : undefined + ), [reportsIndex, selectedBasePath]) + + const reportOptions = useMemo(() => { + if (!selectedGroup?.reports?.length) { + return [] + } + + const options = selectedGroup.reports.map(report => ({ + label: report.name, + value: report.path, + })) + + options.sort((a, b) => a.label.localeCompare(b.label)) + return options + }, [selectedGroup]) + + const selectedReport = useMemo(() => ( + selectedGroup?.reports?.find(report => report.path === selectedReportPath) + ), [selectedGroup, selectedReportPath]) + + const handleBasePathChange = useCallback((event: ChangeEvent) => { + setSelectedBasePath(event.target.value) + setSelectedReportPath('') + setParameterValues({}) + }, []) + + const handleReportChange = useCallback((event: ChangeEvent) => { + setSelectedReportPath(event.target.value) + setParameterValues({}) + }, []) + + const handleParameterChange = useCallback((event: ChangeEvent) => { + if (!event.target?.name) return + + setParameterValues(previous => ({ + ...previous, + [event.target.name]: event.target.value, + })) + }, []) + + const createSelectParamChange = useCallback((name: string) => ( + event: ChangeEvent, + ) => { + setParameterValues(previous => ({ + ...previous, + [name]: event.target.value, + })) + }, []) + + const buildReportPathWithParams = useCallback((report: ReportDefinition): string => { + let path = report.path + const query = new URLSearchParams() + const params: ReportParameter[] = report.parameters ?? [] + + params.forEach(param => { + const rawValue = parameterValues[param.name] + if (rawValue === undefined || rawValue.trim() === '') { + return + } + + const isArray = param.type.endsWith('[]') + const values = isArray + ? rawValue.split(',') + .map(v => v.trim()) + .filter(Boolean) + : [rawValue.trim()] + + if (!values.length) return + + if (param.location === 'path') { + path = path.replace(`:${param.name}`, encodeURIComponent(values[0])) + } else { + values.forEach(value => query.append(param.name, value)) + } + }) + + const queryString = query.toString() + return queryString ? `${path}?${queryString}` : path + }, [parameterValues]) + + const handleDownload = useCallback(async (format: 'json' | 'csv') => { + if (!selectedReport) { + return + } + + try { + setDownloadingFormat(format) + + const requestPath = buildReportPathWithParams(selectedReport) + + const blob = format === 'json' + ? await downloadReportAsJson(requestPath) + : await downloadReportAsCsv(requestPath) + + const link = document.createElement('a') + const fileName = buildDownloadName(selectedReport.name, format) + const url = window.URL.createObjectURL(blob) + + link.href = url + link.setAttribute('download', fileName) + document.body.appendChild(link) + link.click() + link.parentNode?.removeChild(link) + window.URL.revokeObjectURL(url) + } catch (error) { + handleError(error) + } finally { + setDownloadingFormat(undefined) + } + }, [buildReportPathWithParams, selectedReport]) + + const isDownloading = downloadingFormat !== undefined + + const requiredParamsMissing = useMemo(() => { + const params = selectedReport?.parameters ?? [] + return params.some(param => param.required && !(parameterValues[param.name]?.trim())) + }, [parameterValues, selectedReport]) + + const hasUnresolvedPathParams = useMemo(() => ( + (selectedReport?.parameters ?? []) + .filter(param => param.location === 'path') + .some(param => !parameterValues[param.name]?.trim()) + ), [parameterValues, selectedReport]) + + const isDownloadDisabled = !selectedReport || isDownloading || requiredParamsMissing || hasUnresolvedPathParams + + const handleJsonDownload = useCallback(() => { + handleDownload('json') + }, [handleDownload]) + + const handleCsvDownload = useCallback(() => { + handleDownload('csv') + }, [handleDownload]) + + const renderParameterInput = useCallback((parameter: ReportParameter) => { + const commonProps = { + label: parameter.name, + name: parameter.name, + placeholder: parameter.type.endsWith('[]') ? 'Comma-separated values' : 'Enter value', + } + + if (parameter.type === 'boolean') { + const options: InputSelectOption[] = [ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' }, + ] + + return ( + + ) + } + + if (parameter.type === 'enum') { + const options: InputSelectOption[] = (parameter.options ?? []).map(option => ({ + label: option, + value: option, + })) + + return ( + + ) + } + + return ( + + ) + }, [createSelectParamChange, handleParameterChange, parameterValues]) + + return ( + <> + + {pageTitle} + + +
    +

    + Select a base path to view the available reports. After choosing a report, provide any + required parameters and download the data as JSON or CSV directly from the reports API. +

    + + {isLoading ? ( +
    + +
    + ) : ( + <> + {basePathOptions.length ? ( +
    + + + {selectedGroup && ( + + )} +
    + ) : ( +
    + No reports are currently available. +
    + )} + + {selectedReport && ( + <> +
    +
    {selectedReport.name}
    + {selectedReport.description && ( +
    + {selectedReport.description} +
    + )} +
    + {formatMethod(selectedReport.method)} + {' '} + {selectedReport.path} +
    +
    + + {(selectedReport.parameters?.length ?? 0) > 0 && ( +
    + {selectedReport.parameters?.map(parameter => ( +
    +
    + {parameter.name} + {parameter.required ? ' *' : ''} +
    + {parameter.description && ( +
    {parameter.description}
    + )} +
    + Location: + {' '} + {parameter.location || 'query'} + {' '} + • Type: + {' '} + {parameter.type} +
    + {parameter.type.endsWith('[]') && ( +
    + Use comma-separated values for lists. +
    + )} + {renderParameterInput(parameter)} +
    + ))} +
    + )} + +
    + + +
    + + )} + + )} +
    +
    + + ) +} + +export default ReportsPage diff --git a/src/apps/platform/src/PlatformApp.tsx b/src/apps/platform/src/PlatformApp.tsx index dd89390fb..e9c34cceb 100644 --- a/src/apps/platform/src/PlatformApp.tsx +++ b/src/apps/platform/src/PlatformApp.tsx @@ -1,7 +1,7 @@ import { FC } from 'react' import { toast, ToastContainer } from 'react-toastify' -import { useViewportUnitsFix } from '~/libs/shared' +import { NotificationsContainer, useViewportUnitsFix } from '~/libs/shared' import { AppFooter } from './components/app-footer' import { AppHeader } from './components/app-header' @@ -14,6 +14,7 @@ const PlatformApp: FC<{}> = () => { return ( +
    diff --git a/src/apps/platform/src/providers/Providers.tsx b/src/apps/platform/src/providers/Providers.tsx index b16709bb2..b7064659d 100644 --- a/src/apps/platform/src/providers/Providers.tsx +++ b/src/apps/platform/src/providers/Providers.tsx @@ -1,7 +1,7 @@ import { FC, ReactNode } from 'react' import { authUrlLogout, ProfileProvider } from '~/libs/core' -import { ConfigContextProvider } from '~/libs/shared' +import { ConfigContextProvider, NotificationProvider } from '~/libs/shared' import { PlatformRouterProvider } from './platform-router.provider' @@ -13,7 +13,9 @@ const Providers: FC = props => ( - {props.children} + + {props.children} + diff --git a/src/apps/review/src/config/routes.config.ts b/src/apps/review/src/config/routes.config.ts index 2eae58a2c..8fd655e70 100644 --- a/src/apps/review/src/config/routes.config.ts +++ b/src/apps/review/src/config/routes.config.ts @@ -12,6 +12,7 @@ export const activeReviewAssignmentsRouteId = 'active-challenges' export const openOpportunitiesRouteId = 'open-opportunities' export const pastReviewAssignmentsRouteId = 'past-challenges' export const challengeDetailRouteId = ':challengeId' -export const pastChallengeDetailContainerRouteId = 'past-challenge-details' export const scorecardRouteId = 'scorecard' +export const aiScorecardRouteId = 'scorecard' +export const reviewsRouteId = 'reviews' export const viewScorecardRouteId = ':scorecardId' diff --git a/src/apps/review/src/lib/assets/icons/deepseek.svg b/src/apps/review/src/lib/assets/icons/deepseek.svg new file mode 100644 index 000000000..e54d70391 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/deepseek.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-ai-review.svg b/src/apps/review/src/lib/assets/icons/icon-ai-review.svg new file mode 100644 index 000000000..0448ec082 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-ai-review.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-clock.svg b/src/apps/review/src/lib/assets/icons/icon-clock.svg new file mode 100644 index 000000000..bc8dd3a99 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-comment.svg b/src/apps/review/src/lib/assets/icons/icon-comment.svg new file mode 100644 index 000000000..13268f759 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-comment.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-edit-reply.svg b/src/apps/review/src/lib/assets/icons/icon-edit-reply.svg new file mode 100644 index 000000000..b9cf53e09 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-edit-reply.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-edit.svg b/src/apps/review/src/lib/assets/icons/icon-edit.svg new file mode 100644 index 000000000..b9cf53e09 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-phase-appeal-response.svg b/src/apps/review/src/lib/assets/icons/icon-phase-appeal-response.svg new file mode 100644 index 000000000..edc7d9459 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-phase-appeal-response.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-phase-appeal.svg b/src/apps/review/src/lib/assets/icons/icon-phase-appeal.svg new file mode 100644 index 000000000..f28dd1bf8 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-phase-appeal.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-phase-registration.svg b/src/apps/review/src/lib/assets/icons/icon-phase-registration.svg new file mode 100644 index 000000000..7305b63dd --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-phase-registration.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-phase-review.svg b/src/apps/review/src/lib/assets/icons/icon-phase-review.svg new file mode 100644 index 000000000..0e0f58507 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-phase-review.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-phase-submission.svg b/src/apps/review/src/lib/assets/icons/icon-phase-submission.svg new file mode 100644 index 000000000..4b96fe2b4 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-phase-submission.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-phase-winners.svg b/src/apps/review/src/lib/assets/icons/icon-phase-winners.svg new file mode 100644 index 000000000..146c041f6 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-phase-winners.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-premium.svg b/src/apps/review/src/lib/assets/icons/icon-premium.svg new file mode 100644 index 000000000..afa0cf4d4 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-premium.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-reply.svg b/src/apps/review/src/lib/assets/icons/icon-reply.svg new file mode 100644 index 000000000..91ed863a2 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-reply.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-thumb-up-filled.svg b/src/apps/review/src/lib/assets/icons/icon-thumb-up-filled.svg new file mode 100644 index 000000000..e862a480c --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-thumb-up-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-thumb-up.svg b/src/apps/review/src/lib/assets/icons/icon-thumb-up.svg new file mode 100644 index 000000000..8f31f2966 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-thumb-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-thumbs-down-filled.svg b/src/apps/review/src/lib/assets/icons/icon-thumbs-down-filled.svg new file mode 100644 index 000000000..5f8c2c03f --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-thumbs-down-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-thumbs-down.svg b/src/apps/review/src/lib/assets/icons/icon-thumbs-down.svg new file mode 100644 index 000000000..abe7616a6 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-thumbs-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/index.ts b/src/apps/review/src/lib/assets/icons/index.ts index 067315b11..03920d463 100644 --- a/src/apps/review/src/lib/assets/icons/index.ts +++ b/src/apps/review/src/lib/assets/icons/index.ts @@ -2,6 +2,25 @@ import { ReactComponent as IconArrowLeft } from './arrow-left.svg' import { ReactComponent as IconExternalLink } from './external-link.svg' import { ReactComponent as IconChevronDown } from './selector.svg' import { ReactComponent as IconError } from './icon-error.svg' +import { ReactComponent as IconAiReview } from './icon-ai-review.svg' +import { ReactComponent as IconSubmission } from './icon-phase-submission.svg' +import { ReactComponent as IconReply } from './icon-reply.svg' +import { ReactComponent as IconRegistration } from './icon-phase-registration.svg' +import { ReactComponent as IconPhaseReview } from './icon-phase-review.svg' +import { ReactComponent as IconAppeal } from './icon-phase-appeal.svg' +import { ReactComponent as IconAppealResponse } from './icon-phase-appeal-response.svg' +import { ReactComponent as IconPhaseWinners } from './icon-phase-winners.svg' +import { ReactComponent as IconDeepseekAi } from './deepseek.svg' +import { ReactComponent as IconClock } from './icon-clock.svg' +import { ReactComponent as IconPremium } from './icon-premium.svg' +import { ReactComponent as IconComment } from './icon-comment.svg' +import { ReactComponent as IconEdit } from './icon-edit.svg' +import { ReactComponent as IconFile } from './icon-file.svg' +import { ReactComponent as IconThumbsUp } from './icon-thumb-up.svg' +import { ReactComponent as IconThumbsDown } from './icon-thumbs-down.svg' +import { ReactComponent as IconThumbsUpFilled } from './icon-thumb-up-filled.svg' +import { ReactComponent as IconThumbsDownFilled } from './icon-thumbs-down-filled.svg' +import { ReactComponent as IconEditReply } from './icon-edit-reply.svg' export * from './editor/bold' export * from './editor/code' @@ -19,4 +38,36 @@ export * from './editor/table' export * from './editor/unordered-list' export * from './editor/upload-file' -export { IconArrowLeft, IconExternalLink, IconChevronDown, IconError } +export { + IconArrowLeft, + IconExternalLink, + IconChevronDown, + IconError, + IconAiReview, + IconSubmission, + IconPhaseReview, + IconAppeal, + IconAppealResponse, + IconPhaseWinners, + IconReply, + IconDeepseekAi, + IconClock, + IconPremium, + IconComment, + IconEdit, + IconFile, + IconThumbsUp, + IconThumbsDown, + IconThumbsUpFilled, + IconThumbsDownFilled, + IconEditReply, +} + +export const phasesIcons = { + appeal: IconAppeal, + appealResponse: IconAppealResponse, + 'iterative review': IconPhaseReview, + registration: IconRegistration, + review: IconPhaseReview, + submission: IconSubmission, +} 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..e587bc4f4 --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -0,0 +1,79 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + font-family: "Nunito Sans", sans-serif; + max-width: 100%; + overflow: hidden; +} + +.reviewsTable { + width: 100%; + border-collapse: collapse; + + + &.reviewsTable thead tr th { + border-top: 1px solid #A8A8A8; + font-weight: bold; + background: #f6f7f8; + } + + 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; + } + } +} + +.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: 0 0 50%; + white-space: normal; + } +} +.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..67ce8d9b8 --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -0,0 +1,176 @@ +import { FC, MouseEvent as ReactMouseEvent, useMemo } from 'react' +import { Link } from 'react-router-dom' +import moment from 'moment' + +import { useWindowSize, WindowSize } from '~/libs/shared' +import { Tooltip } from '~/libs/ui' + +import { + AiWorkflowRun, + AiWorkflowRunsResponse, + AiWorkflowRunStatusEnum, + useFetchAiWorkflowsRuns, +} from '../../hooks' +import { IconAiReview } from '../../assets/icons' +import { TABLE_DATE_FORMAT } from '../../../config/index.config' +import { BackendSubmission } from '../../models' + +import { AiWorkflowRunStatus } from './AiWorkflowRunStatus' +import styles from './AiReviewsTable.module.scss' + +interface AiReviewsTableProps { + submission: Pick +} + +const stopPropagation = (ev: ReactMouseEvent): void => { + ev.stopPropagation() +} + +const AiReviewsTable: FC = props => { + const { runs, isLoading }: AiWorkflowRunsResponse = useFetchAiWorkflowsRuns(props.submission.id) + + const windowSize: WindowSize = useWindowSize() + const isTablet = useMemo( + () => (windowSize.width ?? 0) <= 984, + [windowSize.width], + ) + + const aiRuns = useMemo(() => [ + ...runs, + { + completedAt: (props.submission as BackendSubmission).submittedDate, + id: '-1', + score: props.submission.virusScan === true ? 100 : 0, + status: AiWorkflowRunStatusEnum.SUCCESS, + workflow: { + description: '', + name: 'Virus Scan', + scorecard: { + minimumPassingScore: 1, + }, + }, + } as AiWorkflowRun, + ], [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.workflow.id) ? ( + + {run.score} + + ) : run.score + ) : '-'} +
    +
    + +
    +
    Result
    +
    + +
    +
    +
    + ))} +
    + ) + } + + 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.id ? ( + + {run.score} + + ) : run.score + ) : '-'} + + +
    +
    + ) +} + +export default AiReviewsTable diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.module.scss new file mode 100644 index 000000000..b99473257 --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.module.scss @@ -0,0 +1,39 @@ +@import '@libs/ui/styles/includes'; + +.result { + display: flex; + align-items: center; + gap: $sp-2; +} + +.icon { + display: flex; + width: 20px; + height: 20px; + border-radius: 10px; + border: 1px solid #e9ecef; + background: #fff; + align-items: center; + justify-content: center; + + &.failed, + &.failed-score { + color: #C1294F; + } + + &.passed { + color: $teal-160; + } + + &.pending { + color: $black-20; + border-color: $black-20; + } +} + +.score { + font-size: 14px; + .failed-score { + color: #C1294F; + } +} diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx new file mode 100644 index 000000000..d9928bde6 --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx @@ -0,0 +1,84 @@ +import { FC, useMemo } from 'react' + +import { IconOutline } from '~/libs/ui' + +import { aiRunFailed, aiRunInProgress, AiWorkflowRun } from '../../hooks' + +import StatusLabel from './StatusLabel' + +interface AiWorkflowRunStatusProps { + run?: Pick + status?: 'passed' | 'pending' | 'failed-score' + score?: number + hideLabel?: boolean + showScore?: boolean +} + +const aiRunStatus = (run: Pick): string => { + const isInProgress = aiRunInProgress(run) + const isFailed = aiRunFailed(run) + const isPassing = ( + run.status === 'SUCCESS' + && run.score >= (run.workflow.scorecard?.minimumPassingScore ?? 0) + ) + return isInProgress ? 'pending' : isFailed ? 'failed' : ( + isPassing ? 'passed' : 'failed-score' + ) +} + +export const AiWorkflowRunStatus: FC = props => { + const status = useMemo(() => { + if (props.status) { + return props.status + } + + if (props.run) { + return aiRunStatus(props.run) + } + + return '' + }, [props.status, props.run]) + + const score = props.showScore ? (props.score ?? props.run?.score) : undefined + + return ( + <> + {status === 'passed' && ( + } + hideLabel={props.hideLabel} + label='Passed' + status={status} + score={score} + /> + )} + {status === 'failed-score' && ( + } + hideLabel={props.hideLabel} + label='Failed' + status={status} + score={score} + /> + )} + {status === 'pending' && ( + } + hideLabel={props.hideLabel} + label='To be filled' + status={status} + score={score} + /> + )} + {status === 'failed' && ( + } + hideLabel={props.hideLabel} + status={status} + label='Failure' + score={score} + /> + )} + + ) +} diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss new file mode 100644 index 000000000..1857d285f --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss @@ -0,0 +1,39 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + display: flex; + align-items: center; + gap: $sp-2; +} + +.icon { + display: flex; + width: 20px; + height: 20px; + border-radius: 10px; + border: 1px solid #e9ecef; + background: #fff; + align-items: center; + justify-content: center; + + &.failed, + &.failed-score { + color: #C1294F; + } + + &.passed { + color: $teal-160; + } + + &.pending { + color: #e9ecef; + border-color: #e9ecef; + } +} + +.score { + font-size: 14px; + &.failed-score { + color: #C1294F; + } +} diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx new file mode 100644 index 000000000..011649ebd --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx @@ -0,0 +1,28 @@ +import { FC, ReactNode } from 'react' +import classNames from 'classnames' + +import styles from './StatusLabel.module.scss' + +interface StatusLabelProps { + icon: ReactNode + hideLabel?: boolean + label?: string + score?: number + status: 'pending' | 'failed' | 'passed' | 'failed-score' +} + +const StatusLabel: FC = props => ( +
    + {props.score && ( + {props.score} + )} + {props.icon && ( + + {props.icon} + + )} + {!props.hideLabel && props.label} +
    +) + +export default StatusLabel 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..9e371fd40 --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/index.ts @@ -0,0 +1,2 @@ +export { default as AiReviewsTable } from './AiReviewsTable' +export * from './AiWorkflowRunStatus' diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx index b32bbd2cc..69033a337 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx @@ -128,6 +128,7 @@ interface SubmissionTabParams { isDownloadingSubmission: useDownloadSubmissionProps['isLoading'] downloadSubmission: useDownloadSubmissionProps['downloadSubmission'] isActiveChallenge: boolean + aiReviewers: { aiWorkflowId: string }[] } const renderSubmissionTab = ({ @@ -140,6 +141,7 @@ const renderSubmissionTab = ({ isDownloadingSubmission, downloadSubmission, isActiveChallenge, + aiReviewers, }: SubmissionTabParams): JSX.Element => { const isSubmissionTab = selectedTabNormalized === 'submission' const isTopgearSubmissionTab = selectedTabNormalized === 'topgearsubmission' @@ -158,6 +160,7 @@ const renderSubmissionTab = ({ if (canShowSubmissionList) { return ( ) } @@ -283,6 +287,31 @@ export const ChallengeDetailsContent: FC = (props: Props) => { ), [challengeInfo?.phases], ) + const screeningOutcome = useMemo( + () => { + const passingSubmissionIds = new Set() + const failingSubmissionIds = new Set() + + props.screening.forEach(entry => { + if (!entry?.submissionId) { + return + } + + const normalizedResult = (entry.result || '').toUpperCase() + if (normalizedResult === 'PASS') { + passingSubmissionIds.add(`${entry.submissionId}`) + } else if (normalizedResult === 'NO PASS') { + failingSubmissionIds.add(`${entry.submissionId}`) + } + }) + + return { + failingSubmissionIds, + passingSubmissionIds, + } + }, + [props.screening], + ) const passesReviewTabGuards: (submission: SubmissionInfo) => boolean = useMemo( () => (submission: SubmissionInfo): boolean => shouldIncludeInReviewPhase( submission, @@ -297,36 +326,22 @@ export const ChallengeDetailsContent: FC = (props: Props) => { reviews: SubmissionInfo[] submitterReviews: SubmissionInfo[] } = useMemo(() => { - const shouldFilter = props.isActiveChallenge && hasScreeningPhase - if (!shouldFilter) { - return { - reviews: props.review.filter(passesReviewTabGuards), - submitterReviews: props.submitterReviews.filter(passesReviewTabGuards), - } - } - - const passingSubmissionIds = new Set() - props.screening.forEach(entry => { - if (!entry?.submissionId) { - return - } - - const result = (entry.result || '').toUpperCase() - if (result === 'PASS') { - passingSubmissionIds.add(`${entry.submissionId}`) - } - }) - - if (passingSubmissionIds.size === 0) { - return { - reviews: props.review - .filter(passesReviewTabGuards), - submitterReviews: props.submitterReviews - .filter(passesReviewTabGuards), + const { + failingSubmissionIds, + passingSubmissionIds, + }: { + failingSubmissionIds: Set + passingSubmissionIds: Set + } = screeningOutcome + const shouldFilter = props.isActiveChallenge + && (hasScreeningPhase || props.screening.length > 0) + && (passingSubmissionIds.size > 0 || failingSubmissionIds.size > 0) + + const matchesScreeningOutcome = (submission: SubmissionInfo): boolean => { + if (!shouldFilter) { + return true } - } - const matchesPassingScreening = (submission: SubmissionInfo): boolean => { if (!submission) { return false } @@ -345,15 +360,23 @@ export const ChallengeDetailsContent: FC = (props: Props) => { return true } + if (passingSubmissionIds.size > 0) { + return candidateIds.some(id => passingSubmissionIds.has(id)) + } + + if (failingSubmissionIds.size > 0) { + return !candidateIds.some(id => failingSubmissionIds.has(id)) + } + return candidateIds.some(id => passingSubmissionIds.has(id)) } return { reviews: props.review - .filter(matchesPassingScreening) + .filter(matchesScreeningOutcome) .filter(passesReviewTabGuards), submitterReviews: props.submitterReviews - .filter(matchesPassingScreening) + .filter(matchesScreeningOutcome) .filter(passesReviewTabGuards), } }, [ @@ -362,12 +385,17 @@ export const ChallengeDetailsContent: FC = (props: Props) => { props.review, props.submitterReviews, props.screening, + props.screening.length, passesReviewTabGuards, + screeningOutcome, ]) const renderSelectedTab = (): JSX.Element => { const selectedTabLower = (props.selectedTab || '').toLowerCase() const selectedTabNormalized = normalizeType(props.selectedTab) + const aiReviewers = ( + challengeInfo?.reviewers?.filter(r => !!r.aiWorkflowId) as { aiWorkflowId: string }[] + ) ?? [] if (selectedTabLower === 'registration') { return @@ -381,6 +409,7 @@ export const ChallengeDetailsContent: FC = (props: Props) => { if (SUBMISSION_TAB_KEYS.has(selectedTabNormalized)) { return renderSubmissionTab({ + aiReviewers, allowTopgearSubmissionList, downloadSubmission: handleSubmissionDownload, isActiveChallenge: props.isActiveChallenge, @@ -410,6 +439,7 @@ export const ChallengeDetailsContent: FC = (props: Props) => { isDownloading={isDownloadingSubmission} downloadSubmission={handleSubmissionDownload} mode={checkpointMode} + aiReviewers={aiReviewers} /> ) } @@ -417,6 +447,7 @@ export const ChallengeDetailsContent: FC = (props: Props) => { if (selectedTabLower === 'winners') { return ( = (props: Props) => { downloadSubmission={handleSubmissionDownload} isActiveChallenge={props.isActiveChallenge} columnLabel='Post-Mortem' + aiReviewers={aiReviewers} /> ) } @@ -465,12 +497,14 @@ export const ChallengeDetailsContent: FC = (props: Props) => { downloadSubmission={handleSubmissionDownload} isActiveChallenge={props.isActiveChallenge} phaseIdFilter={props.selectedPhaseId} + aiReviewers={aiReviewers} /> ) } return ( void challengeStatus?: string mode?: 'submission' | 'screening' | 'review' + aiReviewers?: { aiWorkflowId: string }[] } export const TabContentCheckpoint: FC = (props: Props) => { @@ -148,6 +149,7 @@ export const TabContentCheckpoint: FC = (props: Props) => { isDownloading={props.isDownloading} downloadSubmission={props.downloadSubmission} mode={mode} + aiReviewers={props.aiReviewers} /> ) } diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx index f452bcff0..6d9d40fae 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx @@ -28,6 +28,7 @@ interface Props { isActiveChallenge: boolean columnLabel?: string phaseIdFilter?: string + aiReviewers?: { aiWorkflowId: string }[] } const getSubmissionPriority = (submission: SubmissionInfo): number => { @@ -203,6 +204,7 @@ export const TabContentIterativeReview: FC = (props: Props) => { hideSubmissionColumn={shouldHideSubmissionColumn} isChallengeCompleted={isChallengeCompleted} hasPassedThreshold={hasPassedPostMortemThreshold} + aiReviewers={props.aiReviewers} /> ) } diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx index 872e6ea06..8efcb4805 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx @@ -44,6 +44,7 @@ import { import { hasSubmitterPassedThreshold } from '../../utils/reviewScoring' interface Props { + aiReviewers?: { aiWorkflowId: string }[] selectedTab: string reviews: SubmissionInfo[] submitterReviews: SubmissionInfo[] @@ -681,6 +682,7 @@ export const TabContentReview: FC = (props: Props) => { return ( = (props: Props) => { return isSubmitterView ? ( = (props: Props) => { ) : ( = (props: Props) => { return isSubmitterView ? ( ) : ( = (props: Props) => { @@ -136,6 +137,7 @@ export const TabContentScreening: FC = (props: Props) => { downloadSubmission={props.downloadSubmission} hideHandleColumn={hideHandleColumn} showScreeningColumns={showScreeningColumns} + aiReviewers={props.aiReviewers} /> ) } 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 03ebda836..5bec3a5e1 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 @@ -320,31 +322,21 @@ export const TabContentSubmissions: FC = props => { type: 'element', }, { - label: 'Virus Scan', + className: styles.aiReviewerRow, + label: 'Reviewer', + mobileColSpan: 2, propertyName: 'virusScan', - renderer: (submission: BackendSubmission) => { - if (submission.isFileSubmission === false) { - return N/A - } - - if (submission.virusScan === true) { - return ( - - - - ) - } - - if (submission.virusScan === false) { - return ( - - - - ) - } - - return - - }, + renderer: (submission: BackendSubmission, allRows: BackendSubmission[]) => ( + submission.isFileSubmission === false ? ( + N/A + ) : ( + + ) + ), type: 'element', }, ] @@ -412,6 +404,7 @@ export const TabContentSubmissions: FC = props => { }, { ...column, + colSpan: column.mobileColSpan, mobileType: 'last-value', }, ] as MobileTableColumn[], @@ -449,6 +442,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/ChallengeDetailsContent/TabContentWinners.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentWinners.tsx index 39329dc1d..1d1f95a1b 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentWinners.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentWinners.tsx @@ -12,6 +12,7 @@ import { TableWinners } from '../TableWinners' interface Props { projectResults: ProjectResult[] + aiReviewers?: { aiWorkflowId: string }[] isLoading: boolean isDownloading: IsRemovingType downloadSubmission: (submissionId: string) => void @@ -30,6 +31,7 @@ export const TabContentWinners: FC = (props: Props) => { return ( +} + +const CollapsibleAiReviewsRow: FC = props => { + const aiReviewersCount = props.aiReviewers.length + 1 + + const [isOpen, setIsOpen] = useState(props.defaultOpen ?? 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/FieldMarkdownEditor/FieldMarkdownEditor.module.scss b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss index 309361ade..43793e3fc 100644 --- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss @@ -157,6 +157,17 @@ $error-line-height: 14px; } } +.remainingCharacters { + font-family: "Nunito Sans", sans-serif; + color: var(--GrayFontColor); + font-size: 14px; + line-height: 20px; + @include ltemd { + text-align: right; + margin-top: 12px; + } +} + .error { display: flex; align-items: center; diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx index 966b321c2..58897ba0c 100644 --- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx @@ -1,9 +1,9 @@ /** * Field Markdown Editor. */ -import { FC, useCallback, useContext, useEffect, useRef } from 'react' +import { FC, useCallback, useContext, useEffect, useRef, useState } from 'react' import _ from 'lodash' -import CodeMirror from 'codemirror' +import CodeMirror, { EditorChangeCancellable } from 'codemirror' import EasyMDE from 'easymde' import classNames from 'classnames' import 'easymde/dist/easymde.min.css' @@ -44,6 +44,7 @@ interface Props { showBorder?: boolean disabled?: boolean uploadCategory?: string + maxCharactersAllowed?: number } const errorMessages = { fileTooLarge: @@ -149,6 +150,9 @@ type CodeMirrorType = keyof typeof stateStrategy | 'variable-2' export const FieldMarkdownEditor: FC = (props: Props) => { const elementRef = useRef(null) const easyMDE = useRef(null) + const [remainingCharacters, setRemainingCharacters] = useState( + (props.maxCharactersAllowed || 0) - (props.initialValue?.length || 0), + ) const { challengeId }: ChallengeDetailContextModel = useContext(ChallengeDetailContext) const uploadCategory: string = props.uploadCategory ?? 'general' @@ -825,8 +829,30 @@ export const FieldMarkdownEditor: FC = (props: Props) => { uploadImage: true, }) + easyMDE.current.codemirror.on('beforeChange', (cm: CodeMirror.Editor, change: EditorChangeCancellable) => { + if (change.update) { + const current = cm.getValue().length + const incoming = change.text.join('\n').length + const replaced = cm.indexFromPos(change.to) - cm.indexFromPos(change.from) + + const newLength = current + incoming - replaced + + if (props.maxCharactersAllowed) { + if (newLength > props.maxCharactersAllowed) { + change.cancel() + } + } + } + }) + easyMDE.current.codemirror.on('change', (cm: CodeMirror.Editor) => { - props.onChange?.(cm.getValue()) + if (props.maxCharactersAllowed) { + const remaining = (props.maxCharactersAllowed || 0) - cm.getValue().length + setRemainingCharacters(remaining) + props.onChange?.(cm.getValue()) + } else { + props.onChange?.(cm.getValue()) + } }) easyMDE.current.codemirror.on('blur', () => { @@ -856,7 +882,13 @@ export const FieldMarkdownEditor: FC = (props: Props) => { })} >