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/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/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..a5e81f61b 100644 --- a/src/apps/review/src/config/routes.config.ts +++ b/src/apps/review/src/config/routes.config.ts @@ -12,6 +12,6 @@ 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 = 'ai-scorecard' 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-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/index.ts b/src/apps/review/src/lib/assets/icons/index.ts index 067315b11..80589e44d 100644 --- a/src/apps/review/src/lib/assets/icons/index.ts +++ b/src/apps/review/src/lib/assets/icons/index.ts @@ -2,6 +2,16 @@ 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 IconRegistration } from './icon-phase-registration.svg' +import { ReactComponent as IconReview } 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' export * from './editor/bold' export * from './editor/code' @@ -19,4 +29,27 @@ 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, + IconReview, + IconAppeal, + IconAppealResponse, + IconPhaseWinners, + IconDeepseekAi, + IconClock, + IconPremium, +} + +export const phasesIcons = { + appeal: IconAppeal, + appealResponse: IconAppealResponse, + 'iterative review': IconReview, + registration: IconRegistration, + review: IconReview, + 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..06fa68e1a --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -0,0 +1,77 @@ +@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; + } + } +} + +.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..06114c48e --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -0,0 +1,162 @@ +import { FC, useMemo } from 'react' +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 + reviewers: { aiWorkflowId: string }[] +} + +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 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', + }, + } 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.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.scorecard ? ( + {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..0534ae0fc --- /dev/null +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx @@ -0,0 +1,66 @@ +import { FC, useMemo } from 'react' + +import { IconOutline } from '~/libs/ui' + +import { aiRunFailed, aiRunInProgress, AiWorkflowRun } from '../../hooks' + +import StatusLabel from './StatusLabel' + +interface AiWorkflowRunStatusProps { + run: Pick + hideLabel?: boolean + showScore?: boolean +} + +export const AiWorkflowRunStatus: FC = props => { + const isInProgress = useMemo(() => aiRunInProgress(props.run), [props.run.status]) + const isFailed = useMemo(() => aiRunFailed(props.run), [props.run.status]) + const isPassing = ( + props.run.status === 'SUCCESS' + && props.run.score >= (props.run.workflow.scorecard?.minimumPassingScore ?? 0) + ) + const status = isInProgress ? 'pending' : isFailed ? 'failed' : ( + isPassing ? 'passed' : 'failed-score' + ) + + const score = props.showScore ? props.run.score : undefined + + return ( + <> + {props.run.status === 'SUCCESS' && isPassing && ( + } + hideLabel={props.hideLabel} + label='Passed' + status={status} + score={score} + /> + )} + {props.run.status === 'SUCCESS' && !isPassing && ( + } + hideLabel={props.hideLabel} + label='Failed' + status={status} + score={score} + /> + )} + {isInProgress && ( + } + hideLabel={props.hideLabel} + label='To be filled' + status={status} + score={score} + /> + )} + {isFailed && ( + } + status={status} + 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 22401683c..f0fce8004 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx @@ -127,6 +127,7 @@ interface SubmissionTabParams { isDownloadingSubmission: useDownloadSubmissionProps['isLoading'] downloadSubmission: useDownloadSubmissionProps['downloadSubmission'] isActiveChallenge: boolean + aiReviewers: { aiWorkflowId: string }[] } const renderSubmissionTab = ({ @@ -138,6 +139,7 @@ const renderSubmissionTab = ({ isDownloadingSubmission, downloadSubmission, isActiveChallenge, + aiReviewers, }: SubmissionTabParams): JSX.Element => { const isSubmissionTab = selectedTabNormalized === 'submission' const isTopgearSubmissionTab = selectedTabNormalized === 'topgearsubmission' @@ -156,6 +158,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: handleSubmissionDownload, 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 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/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..7e786979d --- /dev/null +++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx @@ -0,0 +1,47 @@ +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 { + defaultOpen?: boolean + aiReviewers: { aiWorkflowId: string }[] + submission: BackendSubmission +} + +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/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 685c1bd8a..321461b41 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 @@ -224,35 +244,58 @@ export const SubmissionHistoryModal: FC = (props: S ) + function toggle(): void { + toggleRow(submission.id) + } + return ( - - - - {renderedDownloadButton} - {copyButton} - - - - {submittedDisplay} - - - {isFileSubmission === false ? ( - N/A - ) : normalizedVirusScan === true ? ( - - - - ) : normalizedVirusScan === false ? ( - - + + + + + {renderedDownloadButton} + {copyButton} - ) : ( - - - )} - - + + + {submittedDisplay} + + + {isFileSubmission === false ? ( + N/A + ) : ( + + {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/components/TableActiveReviews/TableActiveReviews.module.scss b/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.module.scss index 2798cf333..6679068ba 100644 --- a/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.module.scss +++ b/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.module.scss @@ -79,3 +79,7 @@ background-color: $red-25; color: $red-140; } + +.mr2 { + margin-right: $sp-2; +} diff --git a/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.tsx b/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.tsx index ef4d1c63f..014430141 100644 --- a/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.tsx +++ b/src/apps/review/src/lib/components/TableActiveReviews/TableActiveReviews.tsx @@ -17,6 +17,7 @@ import { } from '../../models' import { TableWrapper } from '../TableWrapper' import { ProgressBar } from '../ProgressBar' +import { IconAiReview, phasesIcons } from '../../assets/icons' import styles from './TableActiveReviews.module.scss' @@ -253,11 +254,20 @@ export const TableActiveReviews: FC = (props: Props) => { isSortable: true, label: 'Phase', propertyName: 'currentPhase', - renderer: (data: ActiveReviewAssignment) => ( -
- {data.currentPhase} -
- ), + renderer: (data: ActiveReviewAssignment) => { + const Icon = data.hasAIReview ? IconAiReview : ( + phasesIcons[data.currentPhase.toLowerCase() as keyof typeof phasesIcons] + ) + + return ( +
+ {Icon && ( + + )} + {data.currentPhase} +
+ ) + }, type: 'element', }, { 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/useFetchActiveReviews.ts b/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts index b226180f2..e12ee1d3a 100644 --- a/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts +++ b/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts @@ -141,6 +141,7 @@ export const transformAssignments = ( .local() .format(TABLE_DATE_FORMAT) : undefined, + hasAIReview: base.hasAIReview, id: base.challengeId, index: currentIndex, name: base.challengeName, 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..8787d55da --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts @@ -0,0 +1,105 @@ +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' + +import { Scorecard } from '../models' + +import { useRolePermissions, UseRolePermissionsResult } from './useRolePermissions' + +export enum AiWorkflowRunStatusEnum { + INIT = 'INIT', + QUEUED = 'QUEUED', + DISPATCHED = 'DISPATCHED', + IN_PROGRESS = 'IN_PROGRESS', + CANCELLED = 'CANCELLED', + FAILURE = 'FAILURE', + COMPLETED = 'COMPLETED', + SUCCESS = 'SUCCESS', +} + +export interface AiWorkflow { + id: string; + name: string; + description: string; + scorecard?: Scorecard + defUrl: string + llm: { + name: string + description: string + icon: string + url: string + provider: { + name: string + } + } +} + +export interface AiWorkflowRun { + id: string; + startedAt: string; + completedAt: string; + status: AiWorkflowRunStatusEnum; + score: number; + workflow: AiWorkflow +} + +const TC_API_BASE_URL = EnvironmentConfig.API.V6 + +export interface AiWorkflowRunsResponse { + runs: AiWorkflowRun[] + isLoading: boolean +} + +export const aiRunInProgress = (aiRun: Pick): boolean => [ + AiWorkflowRunStatusEnum.INIT, + AiWorkflowRunStatusEnum.QUEUED, + AiWorkflowRunStatusEnum.DISPATCHED, + AiWorkflowRunStatusEnum.IN_PROGRESS, +].includes(aiRun.status) + +export const aiRunFailed = (aiRun: Pick): boolean => [ + AiWorkflowRunStatusEnum.FAILURE, + AiWorkflowRunStatusEnum.CANCELLED, +].includes(aiRun.status) + +export function useFetchAiWorkflowsRuns( + submissionId: string, + workflowIds: string[], +): AiWorkflowRunsResponse { + const { isAdmin }: UseRolePermissionsResult = useRolePermissions() + + // 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: runs.filter(r => isAdmin || !aiRunFailed(r)), + } +} diff --git a/src/apps/review/src/lib/models/ActiveReviewAssignment.model.ts b/src/apps/review/src/lib/models/ActiveReviewAssignment.model.ts index 2ba052480..0fd16bf73 100644 --- a/src/apps/review/src/lib/models/ActiveReviewAssignment.model.ts +++ b/src/apps/review/src/lib/models/ActiveReviewAssignment.model.ts @@ -9,6 +9,7 @@ export interface ActiveReviewAssignment { currentPhaseEndDateString?: string challengeEndDate?: string | Date | null challengeEndDateString?: string + hasAIReview: boolean; timeLeft?: string timeLeftColor?: string timeLeftStatus?: string diff --git a/src/apps/review/src/lib/models/AiScorecardContext.model.ts b/src/apps/review/src/lib/models/AiScorecardContext.model.ts new file mode 100644 index 000000000..46ba4ccb7 --- /dev/null +++ b/src/apps/review/src/lib/models/AiScorecardContext.model.ts @@ -0,0 +1,14 @@ +import { AiWorkflow, AiWorkflowRun } from '../hooks' + +import { Scorecard } from './Scorecard.model' +import { ChallengeDetailContextModel } from './ChallengeDetailContextModel.model' + +export interface AiScorecardContextModel extends ChallengeDetailContextModel { + isLoading: boolean + submissionId: string + workflowId: string + workflow?: AiWorkflow + workflowRun?: AiWorkflowRun + scorecard?: Scorecard + workflowRuns: AiWorkflowRun[] +} diff --git a/src/apps/review/src/lib/models/BackendMyReviewAssignment.model.ts b/src/apps/review/src/lib/models/BackendMyReviewAssignment.model.ts index 4de66927c..7819ce591 100644 --- a/src/apps/review/src/lib/models/BackendMyReviewAssignment.model.ts +++ b/src/apps/review/src/lib/models/BackendMyReviewAssignment.model.ts @@ -20,6 +20,7 @@ export interface BackendMyReviewAssignment { challengeEndDate: string | null currentPhaseName: string currentPhaseEndDate: string | null + hasAIReview: boolean; timeLeftInCurrentPhase: number | null resourceRoleName: string reviewProgress: number | null 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/apps/review/src/lib/models/index.ts b/src/apps/review/src/lib/models/index.ts index 2f62d228e..ff5ead5a7 100644 --- a/src/apps/review/src/lib/models/index.ts +++ b/src/apps/review/src/lib/models/index.ts @@ -1,3 +1,4 @@ +export * from './AiScorecardContext.model' export * from './ChallengeInfo.model' export * from './SubmissionInfo.model' export * from './ReviewInfo.model' diff --git a/src/apps/review/src/pages/active-review-assignements/ActiveReviewAssigments.tsx b/src/apps/review/src/pages/active-review-assignements/ActiveReviewAssigments.tsx deleted file mode 100644 index a38aeded4..000000000 --- a/src/apps/review/src/pages/active-review-assignements/ActiveReviewAssigments.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * The router outlet. - */ - -import { FC, PropsWithChildren, useContext, useEffect, useMemo } from 'react' -import { Outlet, Routes, useLocation } from 'react-router-dom' - -import { routerContext, RouterContextData } from '~/libs/core' - -import { reviewRoutes } from '../../review-app.routes' -import { activeReviewAssignmentsRouteId } from '../../config/routes.config' - -export const ActiveReviewAssigments: FC = () => { - const location = useLocation() - const childRoutes = useChildRoutes() - - useEffect(() => { - window.scrollTo(0, 0) - }, [location.pathname]) - - return ( - <> - - {childRoutes} - - ) -} - -function useChildRoutes(): Array | undefined { - const { getRouteElement }: RouterContextData = useContext(routerContext) - const childRoutes = useMemo( - () => reviewRoutes[0].children - ?.find(r => r.id === activeReviewAssignmentsRouteId) - ?.children?.map(getRouteElement), - [getRouteElement], - ) - return childRoutes -} - -export default ActiveReviewAssigments diff --git a/src/apps/review/src/pages/active-review-assignements/ActiveReviewsPage/ActiveReviewsPage.tsx b/src/apps/review/src/pages/active-review-assignements/ActiveReviewsPage/ActiveReviewsPage.tsx index 6e66e7d82..d8d099bfc 100644 --- a/src/apps/review/src/pages/active-review-assignements/ActiveReviewsPage/ActiveReviewsPage.tsx +++ b/src/apps/review/src/pages/active-review-assignements/ActiveReviewsPage/ActiveReviewsPage.tsx @@ -17,6 +17,7 @@ import classNames from 'classnames' import { Pagination, TableLoading } from '~/apps/admin/src/lib' import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' import { Button, IconOutline, InputText } from '~/libs/ui' +import { NotificationContextType, useNotification } from '~/libs/shared' import { CHALLENGE_TYPE_SELECT_ALL_OPTION } from '../../../config/index.config' import { @@ -37,6 +38,7 @@ import { import { ReviewAppContextModel } from '../../../lib/models' import { SelectOption } from '../../../lib/models/SelectOption.model' import { getAllowedTypeAbbreviationsByTrack } from '../../../lib/utils/challengeTypesByTrack' +import { IconAiReview } from '../../../lib/assets/icons' import styles from './ActiveReviewsPage.module.scss' @@ -50,6 +52,8 @@ const DEFAULT_SORT: Sort = { } export const ActiveReviewsPage: FC = (props: Props) => { + const { showBannerNotification, removeNotification }: NotificationContextType = useNotification() + const { loginUserInfo, }: ReviewAppContextModel = useContext(ReviewAppContext) @@ -193,6 +197,16 @@ export const ActiveReviewsPage: FC = (props: Props) => { }) }, [loadActiveReviews, sort]) + useEffect(() => { + const notification = showBannerNotification({ + icon: , + id: 'ai-review-icon-notification', + message: `Challenges with this icon indicate that + one or more AI reviews will be conducted for each member submission.`, + }) + return () => notification && removeNotification(notification.id) + }, [showBannerNotification, removeNotification]) + return ( = (props: Props) => { - const parentRouteId = props.parentRouteId - ?? activeReviewAssignmentsRouteId - const detailRouteId = props.detailRouteId - ?? challengeDetailRouteId - const childRoutes = useChildRoutes(parentRouteId, detailRouteId) - - return ( - - - {childRoutes} - - ) -} - -/** - * Get child routes of challenge detail page - * @returns child routes - */ -function useChildRoutes( - parentRouteId: string, - detailRouteId: string, -): Array | undefined { - const { getRouteElement }: RouterContextData = useContext(routerContext) - const childRoutes = useMemo( - () => reviewRoutes[0].children - ?.find(r => r.id === parentRouteId) - ?.children?.find(r => r.id === detailRouteId) - ?.children?.map(getRouteElement), - [ - detailRouteId, - getRouteElement, - parentRouteId, - ], - ) - return childRoutes -} - -export default ChallengeDetailContainer diff --git a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx index 82ffe6d8a..ab68d8604 100644 --- a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx +++ b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx @@ -13,6 +13,7 @@ import { TableLoading } from '~/apps/admin/src/lib' import { handleError } from '~/apps/admin/src/lib/utils' import { EnvironmentConfig } from '~/config' import { BaseModal, Button, InputCheckbox, InputDatePicker, InputText } from '~/libs/ui' +import { NotificationContextType, useNotification } from '~/libs/shared' import { useFetchScreeningReview, @@ -227,6 +228,7 @@ const isIterativeReviewPhaseName = (name?: string): boolean => (name || '') // eslint-disable-next-line complexity export const ChallengeDetailsPage: FC = (props: Props) => { + const { showBannerNotification, removeNotification }: NotificationContextType = useNotification() const [searchParams, setSearchParams] = useSearchParams() const location = useLocation() const navigate = useNavigate() @@ -1707,6 +1709,16 @@ export const ChallengeDetailsPage: FC = (props: Props) => { : undefined const shouldShowChallengeMetaRow = Boolean(statusLabel) || trackTypePills.length > 0 + useEffect(() => { + const notification = showBannerNotification({ + id: 'ai-review-scores-warning', + message: `AI Review Scores are advisory only to provide immediate, + educational, and actionable feedback to members. + AI Review Scores do not influence winner selection.`, + }) + return () => notification && removeNotification(notification.id) + }, [showBannerNotification]) + return ( import('./ActiveReviewsPage'), + 'ActiveReviewsPage', +) + +export const activeReviewChildRoutes = [ + { + authRequired: true, + element: , + id: 'active-reviews-page', + route: '', + }, + ...challengeDetailsRoutes, +] + +export const activeReviewRoutes = [ + { + children: [...activeReviewChildRoutes], + element: getRoutesContainer(activeReviewChildRoutes), + id: activeReviewAssignmentsRouteId, + route: activeReviewAssignmentsRouteId, + }, +] diff --git a/src/apps/review/src/pages/active-review-assignements/challenge-details.routes.tsx b/src/apps/review/src/pages/active-review-assignements/challenge-details.routes.tsx new file mode 100644 index 000000000..e6a61eb14 --- /dev/null +++ b/src/apps/review/src/pages/active-review-assignements/challenge-details.routes.tsx @@ -0,0 +1,42 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' + +import { challengeDetailRouteId } from '../../config/routes.config' +import { aiScorecardRoutes } from '../ai-scorecards' + +const ChallengeDetailContextProvider: LazyLoadedComponent = lazyLoad( + () => import('../../lib/contexts/ChallengeDetailContextProvider'), + 'ChallengeDetailContextProvider', +) +const ScorecardDetailsPage: LazyLoadedComponent = lazyLoad( + () => import('./ScorecardDetailsPage'), + 'ScorecardDetailsPage', +) + +const ChallengeDetailsPage: LazyLoadedComponent = lazyLoad( + () => import('./ChallengeDetailsPage'), + 'ChallengeDetailsPage', +) + +export const challengeDetailsChildRoutes = [ + { + element: , + id: 'challenge-details-page', + route: 'challenge-details', + }, + { + element: , + id: 'scorecard-details-page', + route: 'review/:reviewId', + }, + ...aiScorecardRoutes, +] + +export const challengeDetailsRoutes = [ + { + authRequired: true, + children: challengeDetailsChildRoutes, + element: getRoutesContainer(challengeDetailsChildRoutes, ChallengeDetailContextProvider), + id: challengeDetailRouteId, + route: challengeDetailRouteId, + }, +] diff --git a/src/apps/review/src/pages/active-review-assignements/index.ts b/src/apps/review/src/pages/active-review-assignements/index.ts new file mode 100644 index 000000000..416c8fd76 --- /dev/null +++ b/src/apps/review/src/pages/active-review-assignements/index.ts @@ -0,0 +1,2 @@ +export * from './active-review.routes' +export * from './challenge-details.routes' diff --git a/src/apps/review/src/pages/ai-scorecards/AiScorecardContext/AiScorecardContextProvider.tsx b/src/apps/review/src/pages/ai-scorecards/AiScorecardContext/AiScorecardContextProvider.tsx new file mode 100644 index 000000000..5f6e4169f --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/AiScorecardContext/AiScorecardContextProvider.tsx @@ -0,0 +1,76 @@ +/** + * Context provider for challenge detail page + */ +import { Context, createContext, FC, PropsWithChildren, useContext, useMemo } from 'react' +import { useParams } from 'react-router-dom' + +import { ChallengeDetailContext } from '../../../lib' +import { AiScorecardContextModel, ChallengeDetailContextModel } from '../../../lib/models' +import { AiWorkflowRunsResponse, useFetchAiWorkflowsRuns } from '../../../lib/hooks' + +export const AiScorecardContext: Context + = createContext({} as AiScorecardContextModel) + +export const AiScorecardContextProvider: FC = props => { + const { workflowId = '', submissionId = '' }: { + submissionId?: string, + workflowId?: string, + } = useParams<{ + submissionId: string, + workflowId: string, + }>() + + const challengeDetailsCtx = useContext(ChallengeDetailContext) + const { challengeInfo }: ChallengeDetailContextModel = challengeDetailsCtx + const aiReviewers = useMemo(() => ( + (challengeInfo?.reviewers ?? []).filter(r => !!r.aiWorkflowId) + ), [challengeInfo?.reviewers]) + const aiWorkflowIds = useMemo(() => aiReviewers?.map(r => r.aiWorkflowId as string), [aiReviewers]) + + const { runs: workflowRuns, isLoading: aiWorkflowRunsLoading }: AiWorkflowRunsResponse + = useFetchAiWorkflowsRuns(submissionId, aiWorkflowIds) + + const isLoadingCtxData + = challengeDetailsCtx.isLoadingChallengeInfo + && challengeDetailsCtx.isLoadingChallengeResources + && challengeDetailsCtx.isLoadingChallengeSubmissions + && aiWorkflowRunsLoading + + const workflowRun = useMemo( + () => workflowRuns.find(w => w.workflow.id === workflowId), + [workflowRuns, workflowId], + ) + const workflow = useMemo(() => workflowRun?.workflow, [workflowRuns, workflowId]) + const scorecard = useMemo(() => workflow?.scorecard, [workflow]) + + const value = useMemo( + () => ({ + ...challengeDetailsCtx, + isLoading: isLoadingCtxData, + scorecard, + submissionId, + workflow, + workflowId, + workflowRun, + workflowRuns, + }), + [ + challengeDetailsCtx, + isLoadingCtxData, + scorecard, + submissionId, + workflow, + workflowId, + workflowRun, + workflowRuns, + ], + ) + + return ( + + {props.children} + + ) +} + +export const useAiScorecardContext = (): AiScorecardContextModel => useContext(AiScorecardContext) diff --git a/src/apps/review/src/pages/ai-scorecards/AiScorecardContext/index.ts b/src/apps/review/src/pages/ai-scorecards/AiScorecardContext/index.ts new file mode 100644 index 000000000..03eae189c --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/AiScorecardContext/index.ts @@ -0,0 +1 @@ +export * from './AiScorecardContextProvider' diff --git a/src/apps/review/src/pages/ai-scorecards/AiScorecardViewer/AiScorecardViewer.module.scss b/src/apps/review/src/pages/ai-scorecards/AiScorecardViewer/AiScorecardViewer.module.scss new file mode 100644 index 000000000..8ce7e7e01 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/AiScorecardViewer/AiScorecardViewer.module.scss @@ -0,0 +1,28 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} + +.contentWrap { + display: flex; + flex-direction: row; + gap: $sp-10; + + > .sidebar { + flex-grow: 0; + flex-shrink: 0; + flex-basis: 256px; + } + + @include ltelg { + flex-direction: column; + gap: $sp-6; + + > .sidebar { + flex-basis: auto; + width: 100%; + } + } +} diff --git a/src/apps/review/src/pages/ai-scorecards/AiScorecardViewer/AiScorecardViewer.tsx b/src/apps/review/src/pages/ai-scorecards/AiScorecardViewer/AiScorecardViewer.tsx new file mode 100644 index 000000000..7f5ef87da --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/AiScorecardViewer/AiScorecardViewer.tsx @@ -0,0 +1,47 @@ +import { FC, useEffect, useMemo } from 'react' + +import { NotificationContextType, useNotification } from '~/libs/shared' + +import { ScorecardHeader } from '../components/ScorecardHeader' +import { IconAiReview } from '../../../lib/assets/icons' +import { PageWrapper } from '../../../lib' +import { useAiScorecardContext } from '../AiScorecardContext' +import { AiScorecardContextModel } from '../../../lib/models' +import { AiWorkflowsSidebar } from '../components/AiWorkflowsSidebar' + +import styles from './AiScorecardViewer.module.scss' + +const AiScorecardViewer: FC = () => { + const { showBannerNotification, removeNotification }: NotificationContextType = useNotification() + const { challengeInfo }: AiScorecardContextModel = useAiScorecardContext() + + const breadCrumb = useMemo( + () => [{ index: 1, label: 'My Active Challenges' }], + [], + ) + + useEffect(() => { + const notification = showBannerNotification({ + icon: , + id: 'ai-review-icon-notification', + message: `Challenges with this icon indicate that + one or more AI reviews will be conducted for each member submission.`, + }) + return () => notification && removeNotification(notification.id) + }, [showBannerNotification, removeNotification]) + + return ( + +
+ + +
+
+ ) +} + +export default AiScorecardViewer diff --git a/src/apps/review/src/pages/ai-scorecards/AiScorecardViewer/index.ts b/src/apps/review/src/pages/ai-scorecards/AiScorecardViewer/index.ts new file mode 100644 index 000000000..bc2998589 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/AiScorecardViewer/index.ts @@ -0,0 +1 @@ +export { default as AiScorecardViewer } from './AiScorecardViewer' diff --git a/src/apps/review/src/pages/ai-scorecards/ai-scorecard.routes.tsx b/src/apps/review/src/pages/ai-scorecards/ai-scorecard.routes.tsx new file mode 100644 index 000000000..a911150f2 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/ai-scorecard.routes.tsx @@ -0,0 +1,34 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' + +import { aiScorecardRouteId } from '../../config/routes.config' + +const AiScorecardViewer: LazyLoadedComponent = lazyLoad( + () => import('./AiScorecardViewer'), + 'AiScorecardViewer', +) + +const AiScorecardContextProvider: LazyLoadedComponent = lazyLoad( + () => import('./AiScorecardContext'), + 'AiScorecardContextProvider', +) + +export const aiScorecardChildRoutes: ReadonlyArray = [ + { + authRequired: false, + element: , + id: 'view-ai-scorecard-page', + route: '', + }, +] + +export const aiScorecardRoutes: ReadonlyArray = [ + { + children: [...aiScorecardChildRoutes], + element: getRoutesContainer(aiScorecardChildRoutes, AiScorecardContextProvider), + id: aiScorecardRouteId, + rolesRequired: [ + // UserRole.administrator, + ], + route: `${aiScorecardRouteId}/:submissionId/:workflowId`, + }, +] diff --git a/src/apps/review/src/pages/ai-scorecards/components/AiModelIcon.tsx b/src/apps/review/src/pages/ai-scorecards/components/AiModelIcon.tsx new file mode 100644 index 000000000..9d296780d --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/AiModelIcon.tsx @@ -0,0 +1,27 @@ +import { FC, useCallback, useRef } from 'react' + +import iconDeepseekAi from '~/apps/review/src/lib/assets/icons/deepseek.svg' + +import { AiWorkflow } from '../../../lib/hooks' + +interface AiModelIconProps { + model: AiWorkflow['llm'] +} + +const AiModelIcon: FC = props => { + const llmIconImgRef = useRef(null) + + const handleError = useCallback(() => { + if (!llmIconImgRef.current) { + return + } + + llmIconImgRef.current.src = iconDeepseekAi + }, []) + + return ( + {props.model.name} + ) +} + +export default AiModelIcon diff --git a/src/apps/review/src/pages/ai-scorecards/components/AiModelModal/AiModelModal.module.scss b/src/apps/review/src/pages/ai-scorecards/components/AiModelModal/AiModelModal.module.scss new file mode 100644 index 000000000..3f0514beb --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/AiModelModal/AiModelModal.module.scss @@ -0,0 +1,66 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + @include ltemd { + padding-top: $sp-15; + } +} + +.modelNameWrap { + display: flex; + align-items: center; + gap: $sp-4; + @include ltemd { + flex-direction: column; + gap: $sp-4; + } +} + +.modelIcon { + width: 60px; + height: 60px; + border-radius: $sp-1; + border: 1px solid #A8A8A8; + padding: $sp-1; + + align-items: center; + display: flex; + justify-content: center; + + flex: 0 0 auto; +} + +.modelName { + display: flex; + align-items: center; + gap: $sp-3; + + h3 { + font-family: "Figtree", sans-serif; + font-size: 26px; + font-weight: 700; + line-height: 30px; + color: #0A0A0A; + } + + svg { + display: block; + width: 16px; + height: 16px; + } + + @include ltemd { + h3 { + font-size: 22px; + line-height: 26px; + } + } +} + +.modelDescription { + margin-top: $sp-6; + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + line-height: 20px; + color: #0A0A0A; +} diff --git a/src/apps/review/src/pages/ai-scorecards/components/AiModelModal/AiModelModal.tsx b/src/apps/review/src/pages/ai-scorecards/components/AiModelModal/AiModelModal.tsx new file mode 100644 index 000000000..8aa2e02ae --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/AiModelModal/AiModelModal.tsx @@ -0,0 +1,44 @@ +import { FC } from 'react' + +import { BaseModal } from '~/libs/ui' +import { AiWorkflow } from '~/apps/review/src/lib/hooks' +import { IconExternalLink } from '~/apps/review/src/lib/assets/icons' + +import AiModelIcon from '../AiModelIcon' + +import styles from './AiModelModal.module.scss' + +interface AiModelModalProps { + model: AiWorkflow['llm'] + onClose: () => void +} + +const AiModelModal: FC = props => ( + +
+
+
+ +
+
+

{props.model.name}

+ + + +
+
+ +

+ {props.model.description} +

+
+
+) + +export default AiModelModal diff --git a/src/apps/review/src/pages/ai-scorecards/components/AiModelModal/index.ts b/src/apps/review/src/pages/ai-scorecards/components/AiModelModal/index.ts new file mode 100644 index 000000000..948754b83 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/AiModelModal/index.ts @@ -0,0 +1 @@ +export { default as AiModelModal } from './AiModelModal' diff --git a/src/apps/review/src/pages/ai-scorecards/components/AiWorkflowsSidebar/AiWorkflowsSidebar.module.scss b/src/apps/review/src/pages/ai-scorecards/components/AiWorkflowsSidebar/AiWorkflowsSidebar.module.scss new file mode 100644 index 000000000..54197a0e6 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/AiWorkflowsSidebar/AiWorkflowsSidebar.module.scss @@ -0,0 +1,205 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + +} + +.runsWrap { + > ul { + padding: 0; + margin: 0; + list-style: none; + + li { + position: relative; + margin-top: $sp-1; + &:first-child { + margin-top: 0; + } + + > a { + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + } + } + } +} + + +.runEntry { + display: flex; + flex-direction: row; + align-items: center; + gap: $sp-4; + padding: $sp-4; + background-color: #f9fafa; + cursor: pointer; + position: relative; + + font-family: "Nunito Sans", sans-serif; + font-size: 16px; + line-height: 22px; + + &.active { + background-color: #E9ECEF; + .workflowName{ + font-weight: bold; + } + &:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background-color: $teal-160; + } + } + + &:hover { + background-color: lighten(#E9ECEF, 2.5%); + } + + &:active:hover { + background-color: darken(#E9ECEF, 5%); + } + + > span { + display: flex; + flex-direction: row; + align-items: center; + gap: $sp-2; + } +} + +.workflowName { + max-width: 139px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &Wrap { + flex: 1 1 auto; + } + @include ltelg { + max-width: calc(90vw - 140px); + } +} + +.legend { + background-color: #f9fafa; + padding: $sp-4; + margin-top: $sp-4; + + + font-family: "Nunito Sans", sans-serif; + font-size: 16px; + line-height: 22px; + color: #0A0A0A; + + &Label { + font-weight: bold; + } + + ul { + padding: 0; + margin: $sp-2 0 0; + list-style: none; + + li { + margin-top: $sp-1; + &:first-child { + margin-top: 0; + } + } + } +} + +.mobileTrigger { + display: flex; + align-items: center; + gap: $sp-4; + cursor: pointer; + + .runEntry { + flex: 1 1 auto; + } + + .workflowName { + @include ltelg { + max-width: calc(90vw - 205px); + } + } + + @include gtexl { + display: none; + } +} + +.mobileMenuIcon { + display: flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + padding-left: $sp-4; + border-left: 1px solid #D1DAE4; + flex: 0 0 38px; + + svg { + display: block; + } +} + +.contentsWrap { + @include ltelg { + display: none; + position: relative; + + flex-direction: column; + background: #fff; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 1000; + padding: $sp-4; + overflow: auto; + + &.open { + display: flex; + } + + .runsWrap { + margin-bottom: $sp-4; + } + + .legend { + margin-top: auto; + } + } +} + +.mobileCloseicon { + display: flex; + height: 24px; + align-items: center; + justify-content: flex-end; + cursor: pointer; + margin-bottom: $sp-4; + + svg { + display: block; + color: #000; + } + + @include gtexl { + display: none; + } +} diff --git a/src/apps/review/src/pages/ai-scorecards/components/AiWorkflowsSidebar/AiWorkflowsSidebar.tsx b/src/apps/review/src/pages/ai-scorecards/components/AiWorkflowsSidebar/AiWorkflowsSidebar.tsx new file mode 100644 index 000000000..724c6e973 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/AiWorkflowsSidebar/AiWorkflowsSidebar.tsx @@ -0,0 +1,117 @@ +import { FC, useCallback, useState } from 'react' +import { Link } from 'react-router-dom' +import classNames from 'classnames' + +import { AiScorecardContextModel } from '~/apps/review/src/lib/models' +import { AiWorkflowRunStatus } from '~/apps/review/src/lib/components/AiReviewsTable' +import { IconAiReview } from '~/apps/review/src/lib/assets/icons' +import { IconOutline, IconSolid } from '~/libs/ui' +import StatusLabel from '~/apps/review/src/lib/components/AiReviewsTable/StatusLabel' + +import { useAiScorecardContext } from '../../AiScorecardContext' + +import styles from './AiWorkflowsSidebar.module.scss' + +interface AiWorkflowsSidebarProps { + className?: string +} + +const AiWorkflowsSidebar: FC = props => { + const [isMobileOpen, setIsMobileOpen] = useState(false) + const { + workflow, + workflowRun, + workflowRuns, + workflowId, + submissionId, + }: AiScorecardContextModel = useAiScorecardContext() + + const toggleOpen = useCallback(() => { + setIsMobileOpen(wasOpen => !wasOpen) + }, []) + + const close = useCallback(() => { + setIsMobileOpen(false) + }, []) + + return ( +
+ {workflow && workflowRun && ( +
+
+ + + {workflow.name} + + +
+ +
+
+
+ )} + +
+
+ +
+
+
    + {workflowRuns.map(run => ( +
  • + + + + {run.workflow.name} + + +
  • + ))} +
+
+ +
+
+ Legend +
+
    +
  • + } + label='Passed' + status='passed' + /> +
  • +
  • + } + label='Failed' + status='failed' + /> +
  • +
  • + } + label='To be filled' + status='pending' + /> +
  • +
+
+
+
+ ) +} + +export default AiWorkflowsSidebar diff --git a/src/apps/review/src/pages/ai-scorecards/components/AiWorkflowsSidebar/index.ts b/src/apps/review/src/pages/ai-scorecards/components/AiWorkflowsSidebar/index.ts new file mode 100644 index 000000000..1c0991661 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/AiWorkflowsSidebar/index.ts @@ -0,0 +1 @@ +export { default as AiWorkflowsSidebar } from './AiWorkflowsSidebar' diff --git a/src/apps/review/src/pages/ai-scorecards/components/ScorecardHeader/ScorecardHeader.module.scss b/src/apps/review/src/pages/ai-scorecards/components/ScorecardHeader/ScorecardHeader.module.scss new file mode 100644 index 000000000..b84af1272 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/ScorecardHeader/ScorecardHeader.module.scss @@ -0,0 +1,133 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + width: 100%; + color: #0A0A0A; +} + +.headerWrap { + display: flex; + align-items: flex-start; + + @include ltemd { + flex-direction: column; + align-items: stretch; + gap: $sp-6; + } +} + +.workflowInfo { + display: flex; + align-items: flex-start; + gap: $sp-4; +} + +.workflowIcon { + width: 60px; + height: 60px; + border-radius: $sp-1; + border: 1px solid #A8A8A8; + padding: $sp-1; + + align-items: center; + display: flex; + justify-content: center; + + flex: 0 0 auto; + @include ltemd { + width: 56px; + height: 56px; + } +} + +.workflowName { + h3 { + font-family: "Figtree", sans-serif; + font-size: 26px; + font-weight: 700; + line-height: 30px; + color: #0A0A0A; + margin-bottom: $sp-2; + } + + span { + color: $link-blue-dark; + font-family: "Nunito Sans", sans-serif; + font-weight: bold; + font-size: 16px; + line-height: 22px; + } + + .modelName { + cursor: pointer; + } + + @include ltemd { + h3 { + font-size: 22px; + line-height: 26px; + } + } +} + +.workflowRunStats { + margin-left: auto; + display: flex; + flex-direction: column; + gap: $sp-1; + + flex: 0 0 auto; + + > span { + display: flex; + align-items: center; + gap: $sp-2; + + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + line-height: 19px; + color: var(--GrayFontColor); + } + + strong { + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 19px; + color: var(--FontColor); + } + + @include ltemd { + margin-left: 0; + } +} + +.workflowDescription { + margin-top: $sp-6; + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + line-height: 20px; + color: #0A0A0A; +} + +.workflowFileLink { + margin-top: $sp-4; + a { + display: flex; + align-items: center; + gap: $sp-1; + color: $link-blue-dark; + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + line-height: 20px; + + svg { + width: 12px; + height: 12px; + path { + fill: $link-blue-dark; + } + } + } + +} diff --git a/src/apps/review/src/pages/ai-scorecards/components/ScorecardHeader/ScorecardHeader.tsx b/src/apps/review/src/pages/ai-scorecards/components/ScorecardHeader/ScorecardHeader.tsx new file mode 100644 index 000000000..442ee1232 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/ScorecardHeader/ScorecardHeader.tsx @@ -0,0 +1,87 @@ +import { FC, useCallback, useMemo, useState } from 'react' +import moment, { Duration } from 'moment' + +import { AiScorecardContextModel } from '~/apps/review/src/lib/models' + +import { useAiScorecardContext } from '../../AiScorecardContext' +import { IconClock, IconPremium } from '../../../../lib/assets/icons' +import { AiModelModal } from '../AiModelModal' +import AiModelIcon from '../AiModelIcon' + +import styles from './ScorecardHeader.module.scss' + +const formatDuration = (duration: Duration): string => [ + !!duration.hours() && `${duration.hours()}h`, + !!duration.minutes() && `${duration.minutes()}m`, + !!duration.seconds() && `${duration.seconds()}s`, +].filter(Boolean) + .join(' ') + +const ScorecardHeader: FC = () => { + const { workflow, workflowRun }: AiScorecardContextModel = useAiScorecardContext() + const runDuration = useMemo(() => ( + workflowRun && workflowRun.completedAt && workflowRun.startedAt && moment.duration( + +new Date(workflowRun.completedAt) - +new Date(workflowRun.startedAt), + 'milliseconds', + ) + ), [workflowRun]) + const [modelDetailsModalVisible, setModelDetailsModalVisible] = useState(false) + + const toggleModelDetails = useCallback(() => { + setModelDetailsModalVisible(wasVisible => !wasVisible) + }, []) + + if (!workflow || !workflowRun) { + return <> + } + + return ( +
+
+
+
+ +
+
+

{workflow.name}

+ {workflow.llm.name} +
+
+
+ + + + Minimum passing score: + {' '} + {workflow.scorecard?.minimumPassingScore.toFixed(2)} + + + + + + Duration: + {' '} + {!!runDuration && formatDuration(runDuration)} + + +
+
+

+ {workflow.description} +

+ {/* */} + + {modelDetailsModalVisible && ( + + )} +
+ ) +} + +export default ScorecardHeader diff --git a/src/apps/review/src/pages/ai-scorecards/components/ScorecardHeader/index.ts b/src/apps/review/src/pages/ai-scorecards/components/ScorecardHeader/index.ts new file mode 100644 index 000000000..5cafe1a64 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/ScorecardHeader/index.ts @@ -0,0 +1 @@ +export { default as ScorecardHeader } from './ScorecardHeader' diff --git a/src/apps/review/src/pages/ai-scorecards/components/index.ts b/src/apps/review/src/pages/ai-scorecards/components/index.ts new file mode 100644 index 000000000..3bbdff305 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/components/index.ts @@ -0,0 +1 @@ +export * from './AiWorkflowsSidebar' diff --git a/src/apps/review/src/pages/ai-scorecards/index.ts b/src/apps/review/src/pages/ai-scorecards/index.ts new file mode 100644 index 000000000..aec1c4968 --- /dev/null +++ b/src/apps/review/src/pages/ai-scorecards/index.ts @@ -0,0 +1 @@ +export * from './ai-scorecard.routes' diff --git a/src/apps/review/src/pages/past-review-assignments/index.ts b/src/apps/review/src/pages/past-review-assignments/index.ts new file mode 100644 index 000000000..a55eedde5 --- /dev/null +++ b/src/apps/review/src/pages/past-review-assignments/index.ts @@ -0,0 +1 @@ +export * from './past-review.routes' diff --git a/src/apps/review/src/pages/past-review-assignments/past-review.routes.tsx b/src/apps/review/src/pages/past-review-assignments/past-review.routes.tsx new file mode 100644 index 000000000..04402e78f --- /dev/null +++ b/src/apps/review/src/pages/past-review-assignments/past-review.routes.tsx @@ -0,0 +1,28 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' + +import { pastReviewAssignmentsRouteId } from '../../config/routes.config' +import { challengeDetailsRoutes } from '../active-review-assignements' + +const PastReviewsPage: LazyLoadedComponent = lazyLoad( + () => import('./PastReviewsPage'), + 'PastReviewsPage', +) + +export const pastReviewChildRoutes = [ + { + authRequired: true, + element: , + id: 'past-reviews-page', + route: '', + }, + ...challengeDetailsRoutes, +] + +export const pastReviewRoutes = [ + { + children: pastReviewChildRoutes, + element: getRoutesContainer(pastReviewChildRoutes), + id: pastReviewAssignmentsRouteId, + route: pastReviewAssignmentsRouteId, + }, +] diff --git a/src/apps/review/src/pages/scorecards/ScorecardsContainer.tsx b/src/apps/review/src/pages/scorecards/ScorecardsContainer.tsx deleted file mode 100644 index d6a15b92c..000000000 --- a/src/apps/review/src/pages/scorecards/ScorecardsContainer.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * The router outlet. - */ - -import { FC, PropsWithChildren, useContext, useEffect, useMemo } from 'react' -import { Outlet, Routes, useLocation } from 'react-router-dom' - -import { routerContext, RouterContextData } from '~/libs/core' - -import { reviewRoutes } from '../../review-app.routes' -import { scorecardRouteId } from '../../config/routes.config' - -export const ScorecardsContainer: FC = () => { - const location = useLocation() - const childRoutes = useChildRoutes() - - useEffect(() => { - window.scrollTo(0, 0) - }, [location.pathname]) - - return ( - <> - - {childRoutes} - - ) -} - -function useChildRoutes(): Array | undefined { - const { getRouteElement }: RouterContextData = useContext(routerContext) - const childRoutes = useMemo( - () => reviewRoutes[0].children - ?.find(r => r.id === scorecardRouteId) - ?.children?.map(getRouteElement), - [getRouteElement], - ) - return childRoutes -} - -export default ScorecardsContainer diff --git a/src/apps/review/src/pages/scorecards/index.ts b/src/apps/review/src/pages/scorecards/index.ts new file mode 100644 index 000000000..99d2b3f86 --- /dev/null +++ b/src/apps/review/src/pages/scorecards/index.ts @@ -0,0 +1 @@ +export * from './scorecard.routes' diff --git a/src/apps/review/src/pages/scorecards/scorecard.routes.tsx b/src/apps/review/src/pages/scorecards/scorecard.routes.tsx new file mode 100644 index 000000000..3d535d58c --- /dev/null +++ b/src/apps/review/src/pages/scorecards/scorecard.routes.tsx @@ -0,0 +1,66 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent, PlatformRoute, UserRole } from '~/libs/core' + +import { scorecardRouteId } from '../../config/routes.config' + +const ScorecardsListPage: LazyLoadedComponent = lazyLoad( + () => import('./ScorecardsListPage'), + 'ScorecardsListPage', +) + +const ViewScorecardPage: LazyLoadedComponent = lazyLoad( + () => import('./ViewScorecardPage'), + 'ViewScorecardPage', +) +const EditScorecardPage: LazyLoadedComponent = lazyLoad( + () => import('./EditScorecardPage'), + 'EditScorecardPage', +) + +export const scorecardChildRoutes: ReadonlyArray = [ + { + authRequired: true, + element: , + id: 'list-scorecards-page', + rolesRequired: [UserRole.administrator], + route: '', + }, + { + authRequired: true, + element: , + id: 'edit-scorecard-page', + rolesRequired: [ + UserRole.administrator, + ], + route: ':scorecardId/edit', + }, + { + authRequired: true, + element: , + id: 'new-scorecard-page', + rolesRequired: [ + UserRole.administrator, + ], + route: 'new', + }, + { + authRequired: false, + element: , + id: 'view-scorecard-page', + route: ':scorecardId', + }, + +] + +// const ScorecardsContainer = getRoutesContainer(scorecardChildRoutes) + +export const scorecardRoutes: ReadonlyArray = [ + { + children: [...scorecardChildRoutes], + element: getRoutesContainer(scorecardChildRoutes), + id: scorecardRouteId, + rolesRequired: [ + UserRole.administrator, + ], + route: scorecardRouteId, + }, +] diff --git a/src/apps/review/src/review-app.routes.tsx b/src/apps/review/src/review-app.routes.tsx index cd087d5a9..cd95ac79e 100644 --- a/src/apps/review/src/review-app.routes.tsx +++ b/src/apps/review/src/review-app.routes.tsx @@ -7,65 +7,18 @@ import { LazyLoadedComponent, PlatformRoute, Rewrite, - UserRole, } from '~/libs/core' import { activeReviewAssignmentsRouteId, - challengeDetailRouteId, - pastChallengeDetailContainerRouteId, - pastReviewAssignmentsRouteId, rootRoute, - scorecardRouteId, } from './config/routes.config' +import { scorecardRoutes } from './pages/scorecards' +import { activeReviewRoutes } from './pages/active-review-assignements' +import { pastReviewRoutes } from './pages/past-review-assignments' const ReviewApp: LazyLoadedComponent = lazyLoad(() => import('./ReviewApp')) -const ActiveReviewAssigments: LazyLoadedComponent = lazyLoad( - () => import('./pages/active-review-assignements/ActiveReviewAssigments'), -) -const ChallengeDetailContainer: LazyLoadedComponent = lazyLoad( - () => import('./pages/active-review-assignements/ChallengeDetailContainer'), -) -const ActiveReviewsPage: LazyLoadedComponent = lazyLoad( - () => import('./pages/active-review-assignements/ActiveReviewsPage'), - 'ActiveReviewsPage', -) -const PastReviewAssignments: LazyLoadedComponent = lazyLoad( - () => import('./pages/past-review-assignments/PastReviewAssignments'), -) -const PastReviewsPage: LazyLoadedComponent = lazyLoad( - () => import('./pages/past-review-assignments/PastReviewsPage'), - 'PastReviewsPage', -) -const ChallengeDetailsPage: LazyLoadedComponent = lazyLoad( - () => import('./pages/active-review-assignements/ChallengeDetailsPage'), - 'ChallengeDetailsPage', -) -const ScorecardDetailsPage: LazyLoadedComponent = lazyLoad( - () => import('./pages/active-review-assignements/ScorecardDetailsPage'), - 'ScorecardDetailsPage', -) - -const ScorecardsContainer: LazyLoadedComponent = lazyLoad( - () => import('./pages/scorecards/ScorecardsContainer'), - 'ScorecardsContainer', -) - -const ScorecardsListPage: LazyLoadedComponent = lazyLoad( - () => import('./pages/scorecards/ScorecardsListPage'), - 'ScorecardsListPage', -) - -const ViewScorecardPage: LazyLoadedComponent = lazyLoad( - () => import('./pages/scorecards/ViewScorecardPage'), - 'ViewScorecardPage', -) -const EditScorecardPage: LazyLoadedComponent = lazyLoad( - () => import('./pages/scorecards/EditScorecardPage'), - 'EditScorecardPage', -) - const activeChallengeDetailsRewriteTarget: string = `${rootRoute || ''}/${activeReviewAssignmentsRouteId}/:challengeId/challenge-details` @@ -103,116 +56,10 @@ export const reviewRoutes: ReadonlyArray = [ route: ':challengeId', }, // Active Challenges Module - { - children: [ - { - authRequired: true, - element: , - id: 'active-reviews-page', - route: '', - }, - { - authRequired: true, - children: [ - { - element: , - id: 'challenge-details-page', - route: 'challenge-details', - }, - { - element: , - id: 'scorecard-details-page', - route: 'review/:reviewId', - }, - ], - element: , - id: challengeDetailRouteId, - route: challengeDetailRouteId, - }, - ], - element: , - id: activeReviewAssignmentsRouteId, - route: activeReviewAssignmentsRouteId, - }, + ...activeReviewRoutes, // Past Challenges Module - { - children: [ - { - authRequired: true, - element: , - id: 'past-reviews-page', - route: '', - }, - { - authRequired: true, - children: [ - { - element: , - id: 'past-challenge-details-page', - route: 'challenge-details', - }, - { - element: , - id: 'past-scorecard-details-page', - route: 'review/:reviewId', - }, - ], - element: ( - - ), - id: pastChallengeDetailContainerRouteId, - route: challengeDetailRouteId, - }, - ], - element: , - id: pastReviewAssignmentsRouteId, - route: pastReviewAssignmentsRouteId, - }, - { - children: [ - { - authRequired: true, - element: , - id: 'list-scorecards-page', - rolesRequired: [UserRole.administrator], - route: '', - }, - { - authRequired: true, - element: , - id: 'edit-scorecard-page', - rolesRequired: [ - UserRole.administrator, - ], - route: ':scorecardId/edit', - }, - { - authRequired: true, - element: , - id: 'new-scorecard-page', - rolesRequired: [ - UserRole.administrator, - ], - route: 'new', - }, - { - authRequired: false, - element: , - id: 'view-scorecard-page', - route: ':scorecardId', - }, - - ], - element: , - id: scorecardRouteId, - rolesRequired: [ - UserRole.administrator, - ], - route: scorecardRouteId, - }, + ...pastReviewRoutes, + ...scorecardRoutes, ], domain: AppSubdomain.review, element: , diff --git a/src/libs/core/lib/profile/profile-context/index.ts b/src/libs/core/lib/profile/profile-context/index.ts index b80be2bfb..abfdb279c 100644 --- a/src/libs/core/lib/profile/profile-context/index.ts +++ b/src/libs/core/lib/profile/profile-context/index.ts @@ -1,3 +1,3 @@ export * from './profile-context-data.model' -export { default as profileContext, defaultProfileContextData } from './profile.context' +export { default as profileContext, defaultProfileContextData, useProfileContext } from './profile.context' export * from './profile.context-provider' diff --git a/src/libs/core/lib/profile/profile-context/profile.context.tsx b/src/libs/core/lib/profile/profile-context/profile.context.tsx index 3ff734319..d560a86d7 100644 --- a/src/libs/core/lib/profile/profile-context/profile.context.tsx +++ b/src/libs/core/lib/profile/profile-context/profile.context.tsx @@ -1,4 +1,4 @@ -import { Context, createContext } from 'react' +import { Context, createContext, useContext } from 'react' import { ProfileContextData } from './profile-context-data.model' @@ -12,4 +12,6 @@ export const defaultProfileContextData: ProfileContextData = { const profileContext: Context = createContext(defaultProfileContextData) +export const useProfileContext = (): ProfileContextData => useContext(profileContext) + export default profileContext diff --git a/src/libs/core/lib/router/get-routes-container.tsx b/src/libs/core/lib/router/get-routes-container.tsx new file mode 100644 index 000000000..39240b54e --- /dev/null +++ b/src/libs/core/lib/router/get-routes-container.tsx @@ -0,0 +1,34 @@ +/** + * The router outlet. + */ + +import { FC, Fragment, useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes, useLocation } from 'react-router-dom' + +import { PlatformRoute } from './platform-route.model' +import { routerContext, RouterContextData } from './router-context' + +export function getRoutesContainer(childRoutes: ReadonlyArray, contextContainer?: FC): JSX.Element { + const ContextContainer = contextContainer ?? Fragment + const Container = (): JSX.Element => { + const location = useLocation() + const { getRouteElement }: RouterContextData = useContext(routerContext) + const childRoutesWithContext = useMemo( + () => childRoutes.map(getRouteElement), + [getRouteElement], + ) + + useEffect(() => { + window.scrollTo(0, 0) + }, [location.pathname]) + + return ( + + + {childRoutesWithContext} + + ) + } + + return +} diff --git a/src/libs/core/lib/router/index.ts b/src/libs/core/lib/router/index.ts index f5ccd7055..e4fc30157 100644 --- a/src/libs/core/lib/router/index.ts +++ b/src/libs/core/lib/router/index.ts @@ -1,3 +1,4 @@ +export * from './get-routes-container' export * from './router-context' export * from './routes-functions' export * from './platform-route.model' diff --git a/src/libs/shared/lib/components/index.ts b/src/libs/shared/lib/components/index.ts index ac827849a..8b9d29e69 100644 --- a/src/libs/shared/lib/components/index.ts +++ b/src/libs/shared/lib/components/index.ts @@ -3,6 +3,7 @@ export * from './modals' export * from './profile-picture' export * from './input-skill-selector' export * from './member-skill-editor' +export * from './notifications' export * from './skill-pill' export * from './expandable-list' export * from './grouped-skills-ui' diff --git a/src/libs/shared/lib/components/notifications/Notifications.container.tsx b/src/libs/shared/lib/components/notifications/Notifications.container.tsx new file mode 100644 index 000000000..5aa329be7 --- /dev/null +++ b/src/libs/shared/lib/components/notifications/Notifications.container.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react' + +import { Notification } from '~/libs/ui' + +import { NotificationContextType, useNotification } from './Notifications.context' +import styles from './NotificationsContainer.module.scss' + +const NotificationsContainer: FC = () => { + const { notifications, removeNotification }: NotificationContextType = useNotification() + + return ( +
+ {notifications.map(n => ( + + ))} +
+ ) +} + +export default NotificationsContainer diff --git a/src/libs/shared/lib/components/notifications/Notifications.context.tsx b/src/libs/shared/lib/components/notifications/Notifications.context.tsx new file mode 100644 index 000000000..23dbcf558 --- /dev/null +++ b/src/libs/shared/lib/components/notifications/Notifications.context.tsx @@ -0,0 +1,92 @@ +import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react' + +import { useProfileContext } from '~/libs/core' + +import { dismiss, wasDismissed } from './localstorage.utils' + +export type NotificationType = 'success' | 'error' | 'info' | 'warning' | 'banner'; + +export interface Notification { + id: string; + type: NotificationType; + icon?: ReactNode + message: string; + duration?: number; // in ms +} + +type NotifyPayload = string | (Partial & { message: string }) + +export interface NotificationContextType { + notifications: Notification[]; + notify: (message: NotifyPayload, type?: NotificationType, duration?: number) => Notification | void; + showBannerNotification: (message: NotifyPayload) => Notification | void; + removeNotification: (id: string) => void; +} + +const NotificationContext = createContext(undefined) + +export const useNotification = (): NotificationContextType => { + const context = useContext(NotificationContext) + if (!context) throw new Error('useNotification must be used within a NotificationProvider') + return context +} + +export const NotificationProvider: React.FC<{ + children: ReactNode, +}> = props => { + const profileCtx = useProfileContext() + const uuid = profileCtx.profile?.userId ?? 'annon' + const [notifications, setNotifications] = useState([]) + + const removeNotification = useCallback((id: string, persist?: boolean) => { + setNotifications(prev => prev.filter(n => n.id !== id)) + if (persist) { + dismiss(id) + } + }, []) + + const notify = useCallback( + (message: NotifyPayload, type: NotificationType = 'info', duration = 3000) => { + const id = `${uuid}[${typeof message === 'string' ? message : message.id}]` + const newNotification: Notification + = typeof message === 'string' + ? { duration, id, message, type } + : { duration, type, ...message, id } + + if (wasDismissed(id)) { + return undefined + } + + setNotifications(prev => [...prev, newNotification]) + + if (duration > 0) { + setTimeout(() => removeNotification(id), duration) + } + + return newNotification + }, + [uuid], + ) + + const showBannerNotification = useCallback(( + message: NotifyPayload, + ) => notify(message, 'banner', 0), [notify]) + + const ctxValue = useMemo(() => ({ + notifications, + notify, + removeNotification, + showBannerNotification, + }), [ + notifications, + notify, + removeNotification, + showBannerNotification, + ]) + + return ( + + {props.children} + + ) +} diff --git a/src/libs/shared/lib/components/notifications/NotificationsContainer.module.scss b/src/libs/shared/lib/components/notifications/NotificationsContainer.module.scss new file mode 100644 index 000000000..9cbc394ef --- /dev/null +++ b/src/libs/shared/lib/components/notifications/NotificationsContainer.module.scss @@ -0,0 +1,7 @@ +@import "@libs/ui/styles/includes"; + +.wrap { + position: relative; + width: 100%; + z-index: 1000; +} diff --git a/src/libs/shared/lib/components/notifications/index.ts b/src/libs/shared/lib/components/notifications/index.ts new file mode 100644 index 000000000..d2eaff448 --- /dev/null +++ b/src/libs/shared/lib/components/notifications/index.ts @@ -0,0 +1,2 @@ +export { default as NotificationsContainer } from './Notifications.container' +export * from './Notifications.context' diff --git a/src/libs/shared/lib/components/notifications/localstorage.utils.ts b/src/libs/shared/lib/components/notifications/localstorage.utils.ts new file mode 100644 index 000000000..c33d2ff72 --- /dev/null +++ b/src/libs/shared/lib/components/notifications/localstorage.utils.ts @@ -0,0 +1,9 @@ +const lsKeyPrefix = 'notificationDismissed' + +export const wasDismissed = (id: string): boolean => ( + (localStorage.getItem(`${lsKeyPrefix}[${id}]`)) !== null +) + +export const dismiss = (id: string): void => { + localStorage.setItem(`${lsKeyPrefix}[${id}]`, JSON.stringify(true)) +} diff --git a/src/libs/ui/lib/components/index.ts b/src/libs/ui/lib/components/index.ts index 306c469d6..2e0d2f067 100644 --- a/src/libs/ui/lib/components/index.ts +++ b/src/libs/ui/lib/components/index.ts @@ -3,6 +3,7 @@ export * from './content-layout' export * from './default-member-icon' // NOTE: for some reason, modals needs to be imported prior to form export * from './modals' +export * from './notification' export * from './form' export * from './loading-spinner' export * from './page-divider' diff --git a/src/libs/ui/lib/components/notification/Notification.tsx b/src/libs/ui/lib/components/notification/Notification.tsx new file mode 100644 index 000000000..34fe01595 --- /dev/null +++ b/src/libs/ui/lib/components/notification/Notification.tsx @@ -0,0 +1,33 @@ +import { FC, ReactNode, useCallback } from 'react' + +import { NotificationBanner } from './banner' + +interface NotificationProps { + notification: { + icon?: ReactNode; + id: string; + message: string; + type: string; +} + onClose: (id: string, save?: boolean) => void +} + +const Notification: FC = props => { + const handleClose = useCallback((save?: boolean) => { + props.onClose(props.notification.id, save) + }, [props.onClose, props.notification.id]) + + if (props.notification.type === 'banner') { + return ( + + ) + } + + return <> +} + +export default Notification diff --git a/src/libs/ui/lib/components/notification/banner/NotificationBanner.module.scss b/src/libs/ui/lib/components/notification/banner/NotificationBanner.module.scss new file mode 100644 index 000000000..d5fc6bfdd --- /dev/null +++ b/src/libs/ui/lib/components/notification/banner/NotificationBanner.module.scss @@ -0,0 +1,41 @@ +@import '../../../styles/includes'; + +.wrap { + background: #60267D; + color: $tc-white; + + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + line-height: 20px; + + .inner { + max-width: $xxl-min; + padding: $sp-2 0; + @include pagePaddings; + margin: 0 auto; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + } +} + +.close { + cursor: pointer; + color: $tc-white; + flex: 0 0; + margin-left: auto; + border-radius: 50%; + border: 2px solid white; + @include ltemd { + margin-left: $sp-3; + } +} + +.icon { + flex: 0 0; + margin-right: $sp-2; + > svg path { + fill: $tc-white; + } +} diff --git a/src/libs/ui/lib/components/notification/banner/NotificationBanner.stories.tsx b/src/libs/ui/lib/components/notification/banner/NotificationBanner.stories.tsx new file mode 100644 index 000000000..127e09970 --- /dev/null +++ b/src/libs/ui/lib/components/notification/banner/NotificationBanner.stories.tsx @@ -0,0 +1,29 @@ +import { Meta, StoryObj } from '@storybook/react' + +import NotificationBanner from './NotificationBanner' + +const meta: Meta = { + argTypes: { + content: { + description: 'Content displayed inside the notification banner', + }, + persistent: { + defaultValue: false, + description: 'Set to true to hide the close icon button', + }, + }, + component: NotificationBanner, + excludeStories: /.*Decorator$/, + tags: ['autodocs'], + title: 'Components/NotificationBanner', +} + +export default meta + +type Story = StoryObj; + +export const Primary: Story = { + args: { + content: 'Help tooltip', + }, +} diff --git a/src/libs/ui/lib/components/notification/banner/NotificationBanner.tsx b/src/libs/ui/lib/components/notification/banner/NotificationBanner.tsx new file mode 100644 index 000000000..98aa008fd --- /dev/null +++ b/src/libs/ui/lib/components/notification/banner/NotificationBanner.tsx @@ -0,0 +1,42 @@ +import { FC, ReactNode, useCallback } from 'react' + +import { InformationCircleIcon } from '@heroicons/react/outline' + +import { IconOutline } from '../../svgs' + +import styles from './NotificationBanner.module.scss' + +interface NotificationBannerProps { + persistent?: boolean + content: ReactNode + icon?: ReactNode + onClose?: (save?: boolean) => void +} + +const NotificationBanner: FC = props => { + const handleClose = useCallback(() => { + props.onClose?.(true) + }, [props.onClose]) + + return ( +
+
+
+ {props.icon || ( + + )} +
+ + {props.content} + + {!props.persistent && ( +
+ +
+ )} +
+
+ ) +} + +export default NotificationBanner diff --git a/src/libs/ui/lib/components/notification/banner/index.ts b/src/libs/ui/lib/components/notification/banner/index.ts new file mode 100644 index 000000000..51f3cf392 --- /dev/null +++ b/src/libs/ui/lib/components/notification/banner/index.ts @@ -0,0 +1 @@ +export { default as NotificationBanner } from './NotificationBanner' diff --git a/src/libs/ui/lib/components/notification/index.ts b/src/libs/ui/lib/components/notification/index.ts new file mode 100644 index 000000000..ef0ca420e --- /dev/null +++ b/src/libs/ui/lib/components/notification/index.ts @@ -0,0 +1,2 @@ +export * from './banner' +export { default as Notification } from './Notification' 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 } diff --git a/src/libs/ui/lib/components/table/table-row/TableRow.tsx b/src/libs/ui/lib/components/table/table-row/TableRow.tsx index a043b3318..f94748d61 100644 --- a/src/libs/ui/lib/components/table/table-row/TableRow.tsx +++ b/src/libs/ui/lib/components/table/table-row/TableRow.tsx @@ -66,6 +66,7 @@ export const TableRow: ( : undefined } style={colWidth ? { width: `${colWidth}px` } : {}} + allRows={props.allRows} /> ) })