diff --git a/src/apps/campus/index.ts b/src/apps/campus/index.ts new file mode 100644 index 000000000..3c2923024 --- /dev/null +++ b/src/apps/campus/index.ts @@ -0,0 +1 @@ +export { campusRoutes } from './src' diff --git a/src/apps/campus/src/CampusApp.tsx b/src/apps/campus/src/CampusApp.tsx new file mode 100644 index 000000000..a5c01921d --- /dev/null +++ b/src/apps/campus/src/CampusApp.tsx @@ -0,0 +1,21 @@ +import { FC, useContext, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { toolTitle } from './campus.routes' +import './lib/styles/index.scss' + +const CampusApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + return ( + <> + + {childRoutes} + + ) +} + +export default CampusApp diff --git a/src/apps/campus/src/campus.routes.spec.tsx b/src/apps/campus/src/campus.routes.spec.tsx new file mode 100644 index 000000000..b42d5d4f3 --- /dev/null +++ b/src/apps/campus/src/campus.routes.spec.tsx @@ -0,0 +1,56 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' + +import { campusRoutes, rootRoute } from './campus.routes' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + SUBDOMAIN: 'campus', + }, +}), { + virtual: true, +}) + +jest.mock('~/config/constants', () => ({ + AppSubdomain: { + campus: 'campus', + }, + ToolTitle: { + campus: 'Campus', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + lazyLoad: () => (): undefined => undefined, +}), { + virtual: true, +}) + +const LocationViewer = (): JSX.Element => { + const location = useLocation() + + return
{location.pathname}
+} + +describe('campus routes', () => { + it('redirects the campus root to /mecw when groupName is missing', async () => { + const campusAppRoute = campusRoutes[0] + const campusChildRoutes = campusAppRoute.children || [] + const fallbackRoute = campusChildRoutes.find(route => route.route === '') + + render( + + + } path={`${rootRoute}/mecw`} /> + + + , + ) + + expect((await screen.findByTestId('location-pathname')).textContent) + .toBe('/mecw') + }) +}) diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx new file mode 100644 index 000000000..6426a9b61 --- /dev/null +++ b/src/apps/campus/src/campus.routes.tsx @@ -0,0 +1,40 @@ +import { Navigate } from 'react-router-dom' + +import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' +import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' + +const CampusApp: LazyLoadedComponent = lazyLoad(() => import('./CampusApp')) +const CampusLeaderboardPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/leaderboard'), + 'CampusLeaderboardPage', +) + +export const rootRoute: string = ( + EnvironmentConfig.SUBDOMAIN === AppSubdomain.campus ? '' : `/${AppSubdomain.campus}` +) + +export const toolTitle: string = ToolTitle.campus + +export const campusRoutes: ReadonlyArray = [ + { + authRequired: true, + children: [ + { + element: , + route: '', + }, + { + // Campus program leaderboard, eg. https://campus.topcoder-dev.com/mecw + children: [], + element: , + id: 'Campus Leaderboard', + route: ':groupName', + }, + ], + domain: AppSubdomain.campus, + element: , + id: toolTitle, + route: rootRoute, + title: toolTitle, + }, +] diff --git a/src/apps/campus/src/index.ts b/src/apps/campus/src/index.ts new file mode 100644 index 000000000..903dee652 --- /dev/null +++ b/src/apps/campus/src/index.ts @@ -0,0 +1 @@ +export { campusRoutes } from './campus.routes' diff --git a/src/apps/campus/src/lib/assets/avatar-placeholder.png b/src/apps/campus/src/lib/assets/avatar-placeholder.png new file mode 100644 index 000000000..d73649e2b Binary files /dev/null and b/src/apps/campus/src/lib/assets/avatar-placeholder.png differ diff --git a/src/apps/campus/src/lib/assets/ic-user-placeholder.svg b/src/apps/campus/src/lib/assets/ic-user-placeholder.svg new file mode 100755 index 000000000..35c860d03 --- /dev/null +++ b/src/apps/campus/src/lib/assets/ic-user-placeholder.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-help.svg b/src/apps/campus/src/lib/assets/icons/icon-help.svg new file mode 100644 index 000000000..e2e8d906c --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-help.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-info.svg b/src/apps/campus/src/lib/assets/icons/icon-info.svg new file mode 100644 index 000000000..28595a183 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-info.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg b/src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg new file mode 100644 index 000000000..997f82201 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg b/src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg new file mode 100644 index 000000000..b92980117 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg b/src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg new file mode 100644 index 000000000..118a81b70 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-result-failed.svg b/src/apps/campus/src/lib/assets/icons/icon-result-failed.svg new file mode 100644 index 000000000..a46b3769f --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-result-failed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-result-passed.svg b/src/apps/campus/src/lib/assets/icons/icon-result-passed.svg new file mode 100644 index 000000000..9112d0cb3 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-result-passed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-members.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-members.svg new file mode 100644 index 000000000..268aaf41c --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-members.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg new file mode 100644 index 000000000..406071c9c --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg new file mode 100644 index 000000000..c140d46dd --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg new file mode 100644 index 000000000..3e461a0e1 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg new file mode 100644 index 000000000..a427a31fa --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/index.ts b/src/apps/campus/src/lib/assets/icons/index.ts new file mode 100644 index 000000000..9b710220b --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/index.ts @@ -0,0 +1,45 @@ +import { ReactComponent as IconHelp } from './icon-help.svg' +import { ReactComponent as IconInfo } from './icon-info.svg' +import { ReactComponent as IconMedal1st } from './icon-medal-1st.svg' +import { ReactComponent as IconMedal2nd } from './icon-medal-2nd.svg' +import { ReactComponent as IconMedal3rd } from './icon-medal-3rd.svg' +import { ReactComponent as IconResultFailed } from './icon-result-failed.svg' +import { ReactComponent as IconResultPassed } from './icon-result-passed.svg' +import { ReactComponent as IconStatMembers } from './icon-stat-members.svg' +import { ReactComponent as IconStatPassed } from './icon-stat-passed.svg' +import { ReactComponent as IconStatRegistered } from './icon-stat-registered.svg' +import { ReactComponent as IconStatSubmitted } from './icon-stat-submitted.svg' +import { ReactComponent as IconStatWins } from './icon-stat-wins.svg' + +export { + IconHelp, + IconInfo, + IconMedal1st, + IconMedal2nd, + IconMedal3rd, + IconResultFailed, + IconResultPassed, + IconStatMembers, + IconStatPassed, + IconStatRegistered, + IconStatSubmitted, + IconStatWins, +} + +/** + * Medal badges shown for the top three placements. + */ +export const placementIcons: { [placement: number]: typeof IconMedal1st } = { + 1: IconMedal1st, + 2: IconMedal2nd, + 3: IconMedal3rd, +} + +/** + * Ordinal labels for the top three placements. + */ +export const placementLabels: { [placement: number]: string } = { + 1: '1st place', + 2: '2nd place', + 3: '3rd place', +} diff --git a/src/apps/campus/src/lib/components/index.ts b/src/apps/campus/src/lib/components/index.ts new file mode 100644 index 000000000..683964206 --- /dev/null +++ b/src/apps/campus/src/lib/components/index.ts @@ -0,0 +1,2 @@ +export * from './member-avatar' +export * from './stat-card' diff --git a/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.module.scss b/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.module.scss new file mode 100644 index 000000000..8bf7d5190 --- /dev/null +++ b/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.module.scss @@ -0,0 +1,7 @@ +@import '@libs/ui/styles/includes'; + +.avatar { + border-radius: 50%; + flex: 0 0 auto; + object-fit: cover; +} diff --git a/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.tsx b/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.tsx new file mode 100644 index 000000000..bf9b62fac --- /dev/null +++ b/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.tsx @@ -0,0 +1,33 @@ +/** + * Member avatar, falling back to a placeholder when there is no usable photo. + */ +import { FC, useEffect, useState } from 'react' +import classNames from 'classnames' + +import avatarPlaceholder from '../../assets/ic-user-placeholder.svg' + +import styles from './MemberAvatar.module.scss' + +interface MemberAvatarProps { + readonly className?: string + readonly photoURL?: string | null +} + +export const MemberAvatar: FC = (props: MemberAvatarProps) => { + const [failed, setFailed] = useState(false) + const photoURL: string = props.photoURL?.trim() ?? '' + + // rows are reused as the leaderboard is filtered or paged + useEffect(() => { setFailed(false) }, [photoURL]) + + return ( + + ) +} + +export default MemberAvatar diff --git a/src/apps/campus/src/lib/components/member-avatar/index.ts b/src/apps/campus/src/lib/components/member-avatar/index.ts new file mode 100644 index 000000000..b10775e3d --- /dev/null +++ b/src/apps/campus/src/lib/components/member-avatar/index.ts @@ -0,0 +1 @@ +export { MemberAvatar } from './MemberAvatar' diff --git a/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss b/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss new file mode 100644 index 000000000..179101251 --- /dev/null +++ b/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss @@ -0,0 +1,53 @@ +@import '@libs/ui/styles/includes'; + +.statCard { + align-items: center; + border: 1px solid var(--TableBorderColor); + border-radius: 8px; + display: flex; + flex: 1 0 0; + gap: $sp-2; + min-width: 0; + padding: $sp-4 $sp-6; + + @include ltemd { + padding: $sp-3; + } +} + +.icon { + flex: 0 0 auto; + height: 50px; + width: 50px; + + @include ltemd { + height: 40px; + width: 40px; + } +} + +.stat { + color: var(--FontColor); + display: flex; + flex-direction: column; + min-width: 0; +} + +.value { + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + line-height: 30px; +} + +.label { + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 20px; + + // single line at the design width; wraps on narrower viewports + @media (min-width: 1280px) { + white-space: nowrap; + } +} diff --git a/src/apps/campus/src/lib/components/stat-card/StatCard.tsx b/src/apps/campus/src/lib/components/stat-card/StatCard.tsx new file mode 100644 index 000000000..0ea689c3e --- /dev/null +++ b/src/apps/campus/src/lib/components/stat-card/StatCard.tsx @@ -0,0 +1,30 @@ +/** + * Bordered card showing one participation statistic next to its icon. + */ +import { FC, FunctionComponent, SVGProps } from 'react' + +import styles from './StatCard.module.scss' + +interface StatCardProps { + readonly icon: FunctionComponent> + readonly label: string + readonly value?: number +} + +export const StatCard: FC = (props: StatCardProps) => { + const Icon: FunctionComponent> = props.icon + + return ( +
+ +
+
+ {props.value?.toLocaleString() ?? '-'} +
+
{props.label}
+
+
+ ) +} + +export default StatCard diff --git a/src/apps/campus/src/lib/components/stat-card/index.ts b/src/apps/campus/src/lib/components/stat-card/index.ts new file mode 100644 index 000000000..4626bce2f --- /dev/null +++ b/src/apps/campus/src/lib/components/stat-card/index.ts @@ -0,0 +1 @@ +export { StatCard } from './StatCard' diff --git a/src/apps/campus/src/lib/hooks/index.ts b/src/apps/campus/src/lib/hooks/index.ts new file mode 100644 index 000000000..55f29ec92 --- /dev/null +++ b/src/apps/campus/src/lib/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-campus-leaderboard' diff --git a/src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts b/src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts new file mode 100644 index 000000000..7a4b1a954 --- /dev/null +++ b/src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts @@ -0,0 +1,38 @@ +import useSWR, { SWRResponse } from 'swr' + +import { CampusChallengeFilter, CampusLeaderboard } from '../models' +import { campusLeaderboardUrl, fetchCampusLeaderboard } from '../services' + +export interface CampusLeaderboardResource { + data?: CampusLeaderboard + error?: Error & { response?: { status?: number } } + isLoading: boolean +} + +/** + * Loads the campus leaderboard for a group, re-fetching when the filter changes. + * + * @param groupName group name from the route, when available. + * @param challengeFilter selected challenge visibility filter. + * @returns leaderboard resource state. + */ +export function useCampusLeaderboard( + groupName: string | undefined, + challengeFilter: CampusChallengeFilter, +): CampusLeaderboardResource { + const url: string | undefined = groupName + ? campusLeaderboardUrl(groupName, challengeFilter) + : undefined + + const { data, error }: SWRResponse = useSWR( + url, + fetchCampusLeaderboard, + { revalidateOnFocus: false }, + ) + + return { + data, + error, + isLoading: !!url && !data && !error, + } +} diff --git a/src/apps/campus/src/lib/models/campus-leaderboard.model.ts b/src/apps/campus/src/lib/models/campus-leaderboard.model.ts new file mode 100644 index 000000000..ff95e0b74 --- /dev/null +++ b/src/apps/campus/src/lib/models/campus-leaderboard.model.ts @@ -0,0 +1,64 @@ +/** + * Shapes returned by the campus leaderboard report endpoint. + */ + +export type CampusChallengeFilter = 'all' | 'public' | 'campus' + +export interface CampusParticipation { + challengeEndDate: string | null + challengeId: string + challengeName: string | null + challengeStatus: string | null + challengeTrack: string | null + challengeType: string | null + isCampusChallenge: boolean + isPublicChallenge: boolean + passedReview: boolean + placement: number | null + registered: boolean + registeredAt: string | null + reviewed: boolean + score: number | null + submitted: boolean + submittedDate: string | null + won: boolean +} + +export interface CampusLeaderboardMember { + challenges: CampusParticipation[] + firstName: string | null + handle: string | null + hasActivity: boolean + lastName: string | null + memberSince: string | null + passingSubmissions: number + photoURL: string | null + rank: number + rating: number | null + ratingColor: string | null + registrations: number + signupDate: string | null + submissions: number + userId: string + wins: number +} + +export interface CampusLeaderboardSummary { + membersRegistered: number + membersSubmitted: number + totalMembers: number +} + +export interface CampusLeaderboardGroup { + id: string + name: string + oldId: string | null + privateGroup: boolean +} + +export interface CampusLeaderboard { + challengeFilter: CampusChallengeFilter + group: CampusLeaderboardGroup + members: CampusLeaderboardMember[] + summary: CampusLeaderboardSummary +} diff --git a/src/apps/campus/src/lib/models/index.ts b/src/apps/campus/src/lib/models/index.ts new file mode 100644 index 000000000..d4bcf47dd --- /dev/null +++ b/src/apps/campus/src/lib/models/index.ts @@ -0,0 +1 @@ +export * from './campus-leaderboard.model' diff --git a/src/apps/campus/src/lib/services/campus-leaderboard.service.ts b/src/apps/campus/src/lib/services/campus-leaderboard.service.ts new file mode 100644 index 000000000..50e3e59b5 --- /dev/null +++ b/src/apps/campus/src/lib/services/campus-leaderboard.service.ts @@ -0,0 +1,32 @@ +/** + * Read-only client for the campus leaderboard report. + */ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import { CampusChallengeFilter, CampusLeaderboard } from '../models' + +/** + * Builds the campus leaderboard report url for a group and challenge filter. + * + * @param groupName group name taken from the route. + * @param challengeFilter challenge visibility filter. + * @returns absolute reports api url. + */ +export function campusLeaderboardUrl( + groupName: string, + challengeFilter: CampusChallengeFilter, +): string { + const params: URLSearchParams = new URLSearchParams({ challengeFilter, groupName }) + return `${EnvironmentConfig.REPORTS_API}/topcoder/leaderboard/campus?${params.toString()}` +} + +/** + * Fetches the campus leaderboard for a group. + * + * @param url campus leaderboard report url. + * @returns leaderboard payload. + */ +export async function fetchCampusLeaderboard(url: string): Promise { + return xhrGetAsync(url) +} diff --git a/src/apps/campus/src/lib/services/index.ts b/src/apps/campus/src/lib/services/index.ts new file mode 100644 index 000000000..cb7a5a248 --- /dev/null +++ b/src/apps/campus/src/lib/services/index.ts @@ -0,0 +1 @@ +export * from './campus-leaderboard.service' diff --git a/src/apps/campus/src/lib/styles/index.scss b/src/apps/campus/src/lib/styles/index.scss new file mode 100644 index 000000000..3d99e7f04 --- /dev/null +++ b/src/apps/campus/src/lib/styles/index.scss @@ -0,0 +1,67 @@ +@import '@libs/ui/styles/includes'; +@import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300..900;1,300..900&display=swap'); + +:root { + --Link: #0d61bf; + --FontColor: #0a0a0a; + --SubtitleColor: #202020; + --GrayFontColor: #767676; + --TableBorderColor: #a8a8a8; + --TableRowBorderColor: #e0e0e0; + --TableTextColor: #161616; + --TooltipColor: #0f172a; +} + +// Reskin of the shared ~/libs/ui Table to the campus design: +// Nunito Sans header and cells, 52px rows, square cells, gray rules. +.campus-table { + table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + + thead th { + border-bottom: 1px solid var(--TableBorderColor); + height: 52px; + padding: 0 $sp-4 !important; + vertical-align: middle; + + > div { + align-items: center; + color: var(--TableTextColor) !important; + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 700; + gap: $sp-1; + letter-spacing: normal; + line-height: 20px; + text-transform: none; + } + } + + td { + border-bottom: 1px solid var(--TableRowBorderColor); + color: var(--TableTextColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + height: 52px; + letter-spacing: normal; + line-height: 20px; + max-width: none; + padding: 0 $sp-4; + vertical-align: middle; + + &:first-child, + &:last-child { + border-radius: 0; + } + + // the shared table centers the second to last column + &:nth-last-child(2) { + text-align: left; + } + } + } +} diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss new file mode 100644 index 000000000..3e13de360 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss @@ -0,0 +1,226 @@ +@import '@libs/ui/styles/includes'; + +$section-gap: 40px; +$card-gap: 35px; + +.header { + margin-top: $section-gap; + margin-bottom: $section-gap; +} + +.title { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 33px; + font-weight: 700; + letter-spacing: normal; + line-height: 38px; + margin-bottom: $sp-2; + text-transform: none; +} + +.subtitle { + color: var(--SubtitleColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 18px; + font-weight: 400; + line-height: 25px; +} + +.stats { + display: flex; + align-items: center; + gap: $card-gap; + margin-bottom: $section-gap; + + @include ltemd { + flex-direction: column; + align-items: stretch; + gap: $sp-4; + } +} + +.toolbar { + align-items: center; + border-bottom: 1px solid var(--TableBorderColor); + display: flex; + gap: $sp-4; + justify-content: space-between; + padding-bottom: $sp-4; +} + +.filter { + max-width: 100%; + width: 234px; + + :global(.input-el) { + border-color: var(--TableBorderColor); + border-radius: 4px; + height: 40px; + justify-content: center; + margin-bottom: 0; + padding: $sp-2 $sp-4; + } + + :global(.input-el) span { + color: var(--FontColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 400; + line-height: 22px; + } + + :global(.input-el) svg { + color: var(--GrayFontColor); + height: 22px; + width: 22px; + } +} + +.rulesLink { + align-items: center; + background: none; + border: none; + color: var(--Link); + cursor: pointer; + display: flex; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 700; + gap: $sp-1; + line-height: 22px; + padding: 0; + white-space: nowrap; + + svg { + flex: 0 0 auto; + height: 20px; + width: 20px; + } +} + +.tableWrapper { + position: relative; +} + +.lbTable { + table { + // doubled to win over the reskin's second-to-last column alignment + td.numberCell.numberCell { + text-align: right; + + :global(.TableCell_blockCell) { + justify-content: flex-end; + } + } + + th:global(.column-id-open-) { + width: 55px; + } + + th:global(.column-id-rank-) { + width: 52px; + } + + th:global(.column-id-member-) { + width: 210px; + } + + th:global(.column-id-wins-), + th:global(.column-id-passingSubmissions-), + th:global(.column-id-submissions-), + th:global(.column-id-registrations-) { + width: 220px; + + > div { + justify-content: flex-end; + } + } + } +} + +.infoIcon { + align-items: center; + color: var(--GrayFontColor); + display: inline-flex; + height: 24px; + justify-content: center; + width: 24px; + + svg { + height: 14px; + width: 14px; + } +} + +.tooltip { + // doubled to win over the shared tooltip + react-tooltip variant styles + &.tooltip { + background-color: var(--TooltipColor); + border-radius: 8px; + color: $tc-white; + font-family: 'Nunito Sans', sans-serif; + font-size: 12px; + font-weight: 400; + line-height: 20px; + max-width: 244px; + padding: $sp-3; + text-align: left; + } +} + +.medal { + display: block; + height: 20px; + width: 20px; +} + +.rank { + display: inline-block; + text-align: center; + width: 20px; +} + +.handleCell { + align-items: center; + display: flex; + gap: $sp-2; +} + +.avatar { + height: 32px; + width: 32px; +} + +.handle { + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 20px; +} + +.chevronButton { + align-items: center; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + display: inline-flex; + justify-content: center; + padding: 0; + margin-left: auto; +} + +.chevron { + color: var(--FontColor); + height: 24px; + width: 24px; +} + +.empty, +.error { + color: var(--GrayFontColor); + font-family: 'Nunito Sans', sans-serif; + padding: $sp-6 0; + text-align: center; +} diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx new file mode 100644 index 000000000..ecf5efd66 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx @@ -0,0 +1,401 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind, + react/no-unused-prop-types, react/no-array-index-key, unicorn/no-null */ +import '@testing-library/jest-dom' +import type { ChangeEvent, PropsWithChildren, ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { CampusLeaderboard, CampusLeaderboardMember, CampusParticipation } from '../../lib/models' + +import { CampusLeaderboardPage } from './CampusLeaderboardPage' + +interface StubColumn { + columnId?: string + label?: string + propertyName?: string + renderer?: (data: T) => ReactNode +} + +interface StubTableProps { + columns: ReadonlyArray> + data: ReadonlyArray + moreToLoad?: boolean + onLoadMoreClick?: () => void + onRowClick?: (data: T) => void +} + +interface StubSelectProps { + onChange: (event: ChangeEvent) => void + options: ReadonlyArray<{ label?: ReactNode, value: string }> + value?: string +} + +jest.mock('~/config', () => ({ + AppSubdomain: { campus: 'campus' }, + EnvironmentConfig: { + REPORTS_API: 'https://api.example.com/v6/reports', + REVIEW: { CHALLENGE_PAGE_URL: 'https://review.example.test' }, + SUBDOMAIN: 'campus', + URLS: { USER_PROFILE: 'https://profiles.example.test' }, + }, +}), { virtual: true }) + +let mockWindowWidth = 1280 + +jest.mock('~/libs/shared', () => ({ + textFormatDateLocaleShortString: (date?: Date): string | undefined => date?.toISOString(), + useWindowSize: () => ({ height: 800, width: mockWindowWidth }), +}), { virtual: true }) + +jest.mock('~/apps/admin/src/lib/components/common/TableMobile', () => ({ + TableMobile: (props: { + columns: ReadonlyArray[]>, + data: ReadonlyArray, + }): JSX.Element => ( + + + {props.data.map((row, rowIndex) => props.columns.map((group, groupIndex) => ( + + {group.map((column, cellIndex) => ( + + ))} + + )))} + +
+ {column.renderer + ? column.renderer(row) + : String((row as Record)[column.propertyName ?? ''])} +
+ ), +}), { virtual: true }) + +jest.mock('~/libs/ui', () => { + const Icon = (): JSX.Element => + + return { + BaseModal: (props: PropsWithChildren<{ open?: boolean, title?: ReactNode }>): JSX.Element => ( + props.open ? ( +
+

{props.title}

+ {props.children} +
+ ) : <> + ), + ContentLayout: (props: PropsWithChildren<{}>): JSX.Element =>
{props.children}
, + IconOutline: new Proxy({}, { get: () => Icon }), + InputSelect: (props: StubSelectProps): JSX.Element => ( + + ), + LoadingSpinner: (props: { hide?: boolean }): JSX.Element => ( + props.hide ? <> :
Loading
+ ), + PageTitle: (): JSX.Element => <>, + Table: (props: StubTableProps): JSX.Element => ( + + + {props.data.map((row, rowIndex) => ( + props.onRowClick?.(row)}> + {props.columns.map(column => ( + + ))} + + ))} + +
+ {column.renderer + ? column.renderer(row) + : String((row as Record)[column.propertyName ?? ''])} +
+ ), + Tooltip: (props: PropsWithChildren<{ content?: ReactNode }>): JSX.Element => ( + <>{props.children} + ), + } +}, { virtual: true }) + +const mockUseCampusLeaderboard = jest.fn() + +jest.mock('../../lib/hooks', () => ({ + useCampusLeaderboard: (...args: unknown[]) => mockUseCampusLeaderboard(...args), +})) + +const participation = (overrides: Partial = {}): CampusParticipation => ({ + challengeEndDate: '2026-02-01T00:00:00.000Z', + challengeId: 'c1', + challengeName: 'Campus Sprint', + challengeStatus: 'COMPLETED', + challengeTrack: 'Development', + challengeType: 'Challenge', + isCampusChallenge: true, + isPublicChallenge: false, + passedReview: true, + placement: 1, + registered: true, + registeredAt: '2026-01-05T00:00:00.000Z', + reviewed: true, + score: 95, + submitted: true, + submittedDate: '2026-01-20T00:00:00.000Z', + won: true, + ...overrides, +}) + +const member = (overrides: Partial = {}): CampusLeaderboardMember => ({ + challenges: [participation()], + firstName: 'Ada', + handle: 'testaws1', + hasActivity: true, + lastName: 'Lovelace', + memberSince: '2025-01-01T00:00:00.000Z', + passingSubmissions: 1, + photoURL: null, + rank: 1, + rating: 1500, + ratingColor: '#3f3', + registrations: 1, + signupDate: '2026-01-01T00:00:00.000Z', + submissions: 1, + userId: '1', + wins: 1, + ...overrides, +}) + +const leaderboard = (): CampusLeaderboard => ({ + challengeFilter: 'all', + group: { id: 'group-1', name: 'MECW', oldId: null, privateGroup: false }, + members: [ + member(), + member({ + challenges: [], + handle: 'quiet_member', + hasActivity: false, + passingSubmissions: 0, + rank: 2, + registrations: 0, + submissions: 0, + userId: '2', + wins: 0, + }), + ], + summary: { membersRegistered: 842, membersSubmitted: 623, totalMembers: 1248 }, +}) + +function renderPage(): void { + render( + + + } path='/:groupName' /> + + , + ) +} + +describe('CampusLeaderboardPage', () => { + beforeEach(() => { + mockWindowWidth = 1280 + mockUseCampusLeaderboard.mockReturnValue({ data: leaderboard(), isLoading: false }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('requests the leaderboard for the group in the route', () => { + renderPage() + + expect(mockUseCampusLeaderboard) + .toHaveBeenCalledWith('mecw', 'all') + }) + + it('renders the participation summary and every group member', () => { + renderPage() + + expect(screen.getByText('1,248')) + .toBeInTheDocument() + expect(screen.getByText('842')) + .toBeInTheDocument() + expect(screen.getByText('623')) + .toBeInTheDocument() + expect(screen.getByText('testaws1')) + .toBeInTheDocument() + expect(screen.getByText('quiet_member')) + .toBeInTheDocument() + }) + + it('opens the participation history only when the chevron is clicked for active members', () => { + renderPage() + + expect(screen.queryByRole('button', { + name: /View participation history for quiet_member/i, + })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { + name: /View participation history for testaws1/i, + })) + expect(screen.getByText('testaws1 Participation History')) + .toBeInTheDocument() + expect(screen.getByText('Campus Sprint')) + .toBeInTheDocument() + expect(screen.getByLabelText('1st place')) + .toBeInTheDocument() + }) + + it('falls back to the placeholder avatar when a member has no photo', () => { + mockUseCampusLeaderboard.mockReturnValue({ + data: { + ...leaderboard(), + members: [ + member({ handle: 'with_photo', photoURL: 'https://images.example.test/a.png' }), + member({ handle: 'without_photo', photoURL: null, userId: '2' }), + ], + }, + isLoading: false, + }) + renderPage() + + const avatars = Array.from(document.querySelectorAll('img')) + + expect(avatars) + .toHaveLength(2) + expect(avatars[0].getAttribute('src')) + .toBe('https://images.example.test/a.png') + expect(avatars[1].getAttribute('src')) + .toContain('avatar-placeholder') + }) + + it('swaps in the placeholder avatar when a member photo fails to load', () => { + mockUseCampusLeaderboard.mockReturnValue({ + data: { + ...leaderboard(), + members: [member({ photoURL: 'https://images.example.test/broken.png' })], + }, + isLoading: false, + }) + renderPage() + + const avatar = document.querySelector('img') as HTMLImageElement + + expect(avatar.getAttribute('src')) + .toBe('https://images.example.test/broken.png') + + fireEvent.error(avatar) + + expect(avatar.getAttribute('src')) + .toContain('avatar-placeholder') + }) + + it('keeps a still running review out of the failed review state', () => { + mockUseCampusLeaderboard.mockReturnValue({ + data: { + ...leaderboard(), + members: [member({ + challenges: [participation({ + passedReview: false, + placement: null, + reviewed: false, + won: false, + })], + })], + }, + isLoading: false, + }) + renderPage() + + fireEvent.click(screen.getByRole('button', { + name: /View participation history for testaws1/i, + })) + + expect(screen.getByText('In Review')) + .toBeInTheDocument() + expect(screen.queryByText('Failed Review')) + .not.toBeInTheDocument() + }) + + it('orders the participation history by submission date, then registration date', () => { + mockUseCampusLeaderboard.mockReturnValue({ + data: { + ...leaderboard(), + members: [member({ + challenges: [ + participation({ + challengeId: 'c1', + challengeName: 'Submitted first', + submittedDate: '2026-01-10T00:00:00.000Z', + }), + participation({ + challengeId: 'c2', + challengeName: 'Registered first, never submitted', + registeredAt: '2026-01-05T00:00:00.000Z', + submittedDate: null, + }), + participation({ + challengeId: 'c3', + challengeName: 'Submitted last', + submittedDate: '2026-02-20T00:00:00.000Z', + }), + participation({ + challengeId: 'c4', + challengeName: 'Registered last, never submitted', + registeredAt: '2026-01-20T00:00:00.000Z', + submittedDate: null, + }), + ], + })], + }, + isLoading: false, + }) + renderPage() + + fireEvent.click(screen.getByRole('button', { + name: /View participation history for testaws1/i, + })) + + const isChallengeLink = (link: HTMLElement): boolean => Boolean( + link.getAttribute('href') + ?.startsWith('https://review.example.test'), + ) + const challengeNames = screen.getAllByRole('link') + .filter(isChallengeLink) + .map(link => link.textContent) + + expect(challengeNames) + .toEqual([ + 'Submitted last', + 'Submitted first', + 'Registered last, never submitted', + 'Registered first, never submitted', + ]) + }) + + it('stacks the participation history into labelled rows on small screens', () => { + mockWindowWidth = 375 + renderPage() + + fireEvent.click(screen.getByRole('button', { + name: /View participation history for testaws1/i, + })) + + expect(screen.getByText('Registration Date:')) + .toBeInTheDocument() + expect(screen.getByText('Campus Sprint')) + .toBeInTheDocument() + }) + + it('re-requests the leaderboard when the challenge filter changes', () => { + renderPage() + + fireEvent.change(screen.getByTestId('challenge-filter'), { target: { value: 'campus' } }) + + expect(mockUseCampusLeaderboard) + .toHaveBeenLastCalledWith('mecw', 'campus') + }) +}) diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx new file mode 100644 index 000000000..606c73431 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx @@ -0,0 +1,334 @@ +/** + * Campus program leaderboard for a single group (`/:groupName`). + */ +import { ChangeEvent, FC, useCallback, useMemo, useState } from 'react' +import { useParams, useSearchParams } from 'react-router-dom' +import classNames from 'classnames' + +import { + ContentLayout, + IconOutline, + InputSelect, + InputSelectOption, + LoadingSpinner, + PageTitle, + Table, + TableColumn, + Tooltip, +} from '~/libs/ui' +import { EnvironmentConfig } from '~/config' + +import { + CampusChallengeFilter, + CampusLeaderboardMember, +} from '../../lib/models' +import { CampusLeaderboardResource, useCampusLeaderboard } from '../../lib/hooks' +import { + IconHelp, + IconInfo, + IconStatMembers, + IconStatRegistered, + IconStatSubmitted, + placementIcons, +} from '../../lib/assets/icons' +import { MemberAvatar, StatCard } from '../../lib/components' + +import { ParticipationHistoryModal } from './ParticipationHistoryModal' +import { RankingRulesModal } from './RankingRulesModal' +import styles from './CampusLeaderboardPage.module.scss' + +const PAGE_SIZE: number = 50 + +const CHALLENGE_FILTER_OPTIONS: ReadonlyArray = [ + { label: 'All Challenges', value: 'all' }, + { label: 'Public Challenges', value: 'public' }, + { label: 'Campus Challenges', value: 'campus' }, +] + +/** + * Renders a column header with an info tooltip, as designed. + * + * @param label column header text. + * @param tooltip tooltip copy. + * @returns header renderer. + */ +function headerWithTooltip(label: string, tooltip: string): () => JSX.Element { + return function renderHeader(): JSX.Element { + return ( + <> + {label} + + + + + + + ) + } +} + +/** + * Renders the placement: a medal for the top three ranks, the number otherwise. + * + * @param member leaderboard row. + * @returns rank cell. + */ +function renderRank(member: CampusLeaderboardMember): JSX.Element { + const Medal = placementIcons[member.rank] + + return Medal + ? + : {member.rank} +} + +/** + * Renders the member avatar and rating-colored handle. + * + * @param member leaderboard row. + * @returns handle cell. + */ +function renderHandle(member: CampusLeaderboardMember): JSX.Element { + const profileUrl: string | undefined = member.handle + ? `${EnvironmentConfig.URLS.USER_PROFILE}/${encodeURIComponent(member.handle)}` + : undefined + + return ( +
+ + {profileUrl ? ( + + {member.handle} + + ) : ( + + {member.userId} + + )} +
+ ) +} + +export const CampusLeaderboardPage: FC = () => { + const groupName: string | undefined = useParams<{ groupName: string }>().groupName + const [searchParams, setSearchParams] = useSearchParams() + const searchChallengeFilter: string | null = searchParams.get('type') + const challengeFilter: CampusChallengeFilter = ( + searchChallengeFilter === 'public' || searchChallengeFilter === 'campus' + ) ? searchChallengeFilter : 'all' + + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) + const [selectedMember, setSelectedMember] = useState() + const [rulesVisible, setRulesVisible] = useState(false) + + const { data, error, isLoading }: CampusLeaderboardResource + = useCampusLeaderboard(groupName, challengeFilter) + + const displayGroupName: string = data?.group.name ?? groupName ?? '' + + const onFilterChange = useCallback((event: ChangeEvent): void => { + const value = event.target.value as CampusChallengeFilter + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + type: value, + }, { replace: true }) + + setVisibleCount(PAGE_SIZE) + }, [searchParams, setSearchParams]) + + const openParticipationHistory = useCallback((member: CampusLeaderboardMember): void => { + if (!member.hasActivity) { + return + } + + setSelectedMember(member) + }, []) + + const columns = useMemo>>(() => [ + { + columnId: 'rank', + label: 'Rank', + renderer: renderRank, + type: 'element', + }, + { + columnId: 'member', + label: 'Member', + renderer: renderHandle, + type: 'element', + }, + { + className: styles.numberCell, + columnId: 'wins', + label: '# of Wins', + propertyName: 'wins', + type: 'number', + }, + { + className: styles.numberCell, + columnId: 'passingSubmissions', + label: headerWithTooltip( + '# of Passing Submissions', + 'Challenges where a submission passed review. ' + + 'At most one passing submission is counted per challenge.', + ), + propertyName: 'passingSubmissions', + type: 'number', + }, + { + className: styles.numberCell, + columnId: 'submissions', + label: headerWithTooltip( + '# of Submissions', + 'Challenges the member submitted to. At most one submission is counted per challenge.', + ), + propertyName: 'submissions', + type: 'number', + }, + { + className: styles.numberCell, + columnId: 'registrations', + label: headerWithTooltip( + '# of Registrations', + 'Challenges the member registered for.', + ), + propertyName: 'registrations', + type: 'number', + }, + { + className: styles.actionCell, + columnId: 'open', + label: '', + renderer: (member: CampusLeaderboardMember) => (member.hasActivity ? ( + + ) : ), + type: 'element', + }, + ], [openParticipationHistory]) + + const members: ReadonlyArray = data?.members ?? [] + const visibleMembers = useMemo( + () => members.slice(0, visibleCount), + [members, visibleCount], + ) + + const onLoadMoreClick = useCallback((): void => { + setVisibleCount(count => count + PAGE_SIZE) + }, []) + + return ( + + Campus Program Leaderboard + +
+

Campus Program Leaderboard

+

+ {`Track participation and performance of members in the ${displayGroupName} `} + group across challenges. +

+
+ + {!!error && ( +
+ {error.response?.status === 403 + ? 'You do not have access to this leaderboard.' + : `The leaderboard for "${displayGroupName}" could not be loaded.`} +
+ )} + + {(!!data || isLoading) && ( + <> +
+ + + +
+ +
+
+ +
+ +
+ +
+ + + + + {!isLoading && !members.length && ( +
+ {`No members were found in the ${displayGroupName} group.`} +
+ )} + + )} + + + + + + ) +} + +export default CampusLeaderboardPage diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss new file mode 100644 index 000000000..3cd010765 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss @@ -0,0 +1,178 @@ +@import '@libs/ui/styles/includes'; + +$box-padding: 40px; +$box-width: 1080px; + +.modal { + border-radius: 8px !important; + max-width: calc(100vw - #{$sp-8}) !important; + padding: $box-padding !important; + width: $box-width !important; + + // full screen on mobile, like the rest of the platform's modals + @include ltemd { + border-radius: 0 !important; + max-width: 100vw !important; + padding: $sp-6 $sp-4 !important; + width: 100vw !important; + } + + :global(.react-responsive-modal-closeButton) { + right: $sp-2; + top: $sp-2; + + svg { + height: 22px; + width: 22px; + } + } + + // the shared modal header pads its top by 5px, the design does not + div:has(> h3) { + padding-top: 0; + } + + h3 { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + letter-spacing: normal; + line-height: 30px; + text-transform: none; + word-break: break-word; + + @include ltemd { + font-size: 20px; + line-height: 26px; + padding-right: $sp-6; + } + } + + :global(.modal-body) { + margin: 0; + padding: 0; + } +} + +.body { + display: flex; + flex-direction: column; + gap: $box-padding; + margin-top: $box-padding; + + @include ltemd { + gap: $sp-6; + margin-top: $sp-6; + } + + // nested to win over the shared modal body link styling + .workLink { + color: var(--Link); + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 700; + line-height: 22px; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } + } +} + +.stats { + align-items: center; + display: flex; + gap: $sp-6; + + // two by two rather than a four card column, which would push the + // participation history off screen + @include ltemd { + display: grid; + gap: $sp-3; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.historyTable { + table { + th:global(.column-id-track-) { + width: 140px; + } + + th:global(.column-id-registrationDate-), + th:global(.column-id-submissionDate-) { + width: 150px; + } + + th:global(.column-id-result-) { + width: 160px; + } + } +} + +// stacked "Label: value" rows, one block per challenge +.stackedTable { + width: 100%; + + tbody { + td { + border-bottom: 0; + color: var(--TableTextColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + letter-spacing: normal; + line-height: 20px; + padding: $sp-1 0 !important; + text-transform: none; + vertical-align: top; + + &:first-child { + color: var(--GrayFontColor); + padding-right: $sp-3 !important; + text-align: left; + white-space: nowrap; + } + + &:last-child { + text-align: right; + } + } + + // one rule per challenge, and a little more air between blocks. + // 5n == one row per column of `stackedColumns`, keep them in sync. + tr:nth-child(5n) td { + border-bottom: 1px solid var(--TableRowBorderColor); + padding-bottom: $sp-4 !important; + } + + tr:nth-child(5n + 1) td { + padding-top: $sp-4 !important; + } + + tr:last-child td { + border-bottom: 0; + } + } + + .workLink { + font-size: 14px; + line-height: 20px; + } +} + +.result { + align-items: center; + display: inline-flex; + gap: $sp-2; +} + +.resultIcon, +.medal { + flex: 0 0 auto; + height: 20px; + width: 20px; +} diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx new file mode 100644 index 000000000..d1db452c4 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx @@ -0,0 +1,262 @@ +/** + * Participation history for one leaderboard member. + */ +import { FC, useMemo } from 'react' +import classNames from 'classnames' + +import { BaseModal, Table, TableColumn } from '~/libs/ui' +import { textFormatDateLocaleShortString, useWindowSize, WindowSize } from '~/libs/shared' +import { TableMobile } from '~/apps/admin/src/lib/components/common/TableMobile' +import { MobileTableColumn } from '~/apps/admin/src/lib/models/MobileTableColumn.model' +import { EnvironmentConfig } from '~/config' + +import { CampusLeaderboardMember, CampusParticipation } from '../../lib/models' +import { + IconResultFailed, + IconResultPassed, + IconStatPassed, + IconStatRegistered, + IconStatSubmitted, + IconStatWins, + placementIcons, + placementLabels, +} from '../../lib/assets/icons' +import { StatCard } from '../../lib/components' + +import styles from './ParticipationHistoryModal.module.scss' + +interface ParticipationHistoryModalProps { + member?: CampusLeaderboardMember + onClose: () => void +} + +/** + * Formats an api date as a short local date. + * + * @param value iso date string. + * @returns formatted date or an em dash. + */ +function formatDate(value: string | null): string { + return (value ? textFormatDateLocaleShortString(new Date(value)) : undefined) ?? '—' +} + +/** + * Reads an api date as a sortable timestamp. Missing dates sort last. + * + * @param value iso date string. + * @returns timestamp, or -Infinity when there is no usable date. + */ +function toTime(value: string | null): number { + const time: number = value ? Date.parse(value) : NaN + + return Number.isFinite(time) ? time : -Infinity +} + +/** + * Orders two api dates, most recent first. + * + * @param left first date. + * @param right second date. + * @returns comparator result. + */ +function compareDatesDesc(left: string | null, right: string | null): number { + const leftTime: number = toTime(left) + const rightTime: number = toTime(right) + + return leftTime === rightTime ? 0 : rightTime - leftTime +} + +/** + * Renders the outcome of a member's participation: a medal for a top three + * placement, a pass or fail tag once the submission was reviewed, and the + * pending state while the review is still running. + * + * @param entry participation entry. + * @returns result cell. + */ +function renderResult(entry: CampusParticipation): JSX.Element { + const placement: number | null = entry.placement + const Medal = placement ? placementIcons[placement] : undefined + + if (Medal) { + return ( + + + + ) + } + + if (entry.passedReview) { + return ( + + + Passed Review + + ) + } + + // a review that has not finished yet is not a failed review + if (entry.submitted && !entry.reviewed) { + return In Review + } + + if (entry.submitted) { + return ( + + + Failed Review + + ) + } + + return ( + + {entry.challengeStatus === 'ACTIVE' ? 'Challenge is in progress' : 'No submission'} + + ) +} + +export const ParticipationHistoryModal: FC = props => { + const member: CampusLeaderboardMember | undefined = props.member + const { width: screenWidth }: WindowSize = useWindowSize() + // five columns need more room than a tablet viewport offers, so anything + // narrower falls back to the stacked label/value layout + const isStacked: boolean = useMemo(() => screenWidth <= 984, [screenWidth]) + + const columns = useMemo>>(() => [ + { + columnId: 'work', + label: 'Work', + renderer: (entry: CampusParticipation) => { + const challengePath + = `${EnvironmentConfig.REVIEW.CHALLENGE_PAGE_URL}/${encodeURIComponent(entry.challengeId)}` + + return ( + + {entry.challengeName ?? entry.challengeId} + + ) + }, + type: 'element', + }, + { + columnId: 'track', + label: 'Track', + propertyName: 'challengeTrack', + type: 'text', + }, + { + columnId: 'registrationDate', + label: 'Registration Date', + renderer: (entry: CampusParticipation) => {formatDate(entry.registeredAt)}, + type: 'element', + }, + { + columnId: 'submissionDate', + label: 'Submission Date', + renderer: (entry: CampusParticipation) => {formatDate(entry.submittedDate)}, + type: 'element', + }, + { + columnId: 'result', + label: 'Result', + renderer: renderResult, + type: 'element', + }, + ], []) + + // most recently submitted first, then most recently registered + const challenges = useMemo>( + () => [...member?.challenges ?? []].sort((left, right) => ( + compareDatesDesc(left.submittedDate, right.submittedDate) + || compareDatesDesc(left.registeredAt, right.registeredAt) + )), + [member?.challenges], + ) + + // one "Label: value" row per column, stacked into a block per challenge + const stackedColumns = useMemo[][]>( + () => columns.map(column => [ + { + ...column, + className: '', + mobileType: 'label', + renderer: () =>
{`${column.label as string}:`}
, + type: 'element', + }, + { + ...column, + mobileType: 'last-value', + }, + ] as MobileTableColumn[]), + [columns], + ) + + if (!member) { + return <> + } + + return ( + +
+
+ + + + +
+ + {isStacked ? ( + + ) : ( +
+ )} + + + ) +} + +export default ParticipationHistoryModal diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss new file mode 100644 index 000000000..691e6b8f7 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss @@ -0,0 +1,76 @@ +@import '@libs/ui/styles/includes'; + +$box-padding: 40px; +$box-width: 476px; +$text-gap: 20px; + +.modal { + border-radius: 8px !important; + max-width: calc(100vw - #{$sp-8}) !important; + min-width: 0 !important; + padding: $box-padding !important; + width: $box-width !important; + + @include ltemd { + padding: $sp-4 !important; + width: calc(100vw - #{$sp-4}) !important; + } + + :global(.react-responsive-modal-closeButton) { + right: $sp-2; + top: $sp-2; + + svg { + height: 22px; + width: 22px; + } + } + + // the shared modal header pads its top by 5px, the design does not + div:has(> h3) { + padding-top: 0; + } + + h3 { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + letter-spacing: normal; + line-height: 30px; + text-transform: none; + } + + :global(.modal-body) { + margin: 0; + padding: 0; + } + + .body { + color: var(--FontColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + letter-spacing: normal; + line-height: 20px; + margin-top: $sp-6; + + p { + margin: 0 0 $text-gap; + + &:last-child { + margin-bottom: 0; + } + } + + strong { + font-weight: 700; + } + } +} + +.rules { + list-style: decimal outside; + margin: 0 0 $text-gap; + padding-left: $sp-6; +} diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx new file mode 100644 index 000000000..903f0a346 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx @@ -0,0 +1,48 @@ +/** + * Explains the leaderboard ranking criteria. + */ +import { FC } from 'react' + +import { BaseModal } from '~/libs/ui' + +import styles from './RankingRulesModal.module.scss' + +interface RankingRulesModalProps { + onClose: () => void + open: boolean +} + +export const RankingRulesModal: FC = props => { + if (!props.open) { + return <> + } + + return ( + +
+

Members are ranked by the following criteria, in order:

+
    +
  1. Number of wins, highest first
  2. +
  3. Number of passing submissions, highest first
  4. +
  5. Number of registrations, highest first
  6. +
  7. Signup time, earliest first
  8. +
+

+ Note: + At most one submission and one passing submission are counted per member per + challenge. Every member of the group is listed, including members with no + challenge activity. +

+
+
+ ) +} + +export default RankingRulesModal diff --git a/src/apps/campus/src/pages/leaderboard/index.ts b/src/apps/campus/src/pages/leaderboard/index.ts new file mode 100644 index 000000000..e6be4c579 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/index.ts @@ -0,0 +1,3 @@ +export { default as CampusLeaderboardPage } from './CampusLeaderboardPage' +export { default as ParticipationHistoryModal } from './ParticipationHistoryModal' +export { default as RankingRulesModal } from './RankingRulesModal' diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index c45b3e81e..0e5a652a0 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -12,3 +12,4 @@ export const talentSearchRouteId = 'talent-search' export const showcaseSearchRouteId = 'showcase' export const flexiTalentRouteId = 'flexi-talent' export const statisticsRouteId = 'statistics' +export const skillStatisticsRouteId = 'skill-statistics' diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx index 3e9cb2249..d0033c8be 100644 --- a/src/apps/customer-portal/src/customer-portal.routes.tsx +++ b/src/apps/customer-portal/src/customer-portal.routes.tsx @@ -17,6 +17,7 @@ import { import { customerPortalFlexiTalentRoutes } from './pages/flexi-talent/flexi-talent.routes' import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes' import { customerPortalProjectShowcaseRoutes } from './pages/project-showcase/project-showcase.routes' +import { customerPortalSkillStatisticsRoutes } from './pages/skill-statistics/skill-statistics.routes' import { customerPortalStatisticsRoutes } from './pages/statistics/statistics.routes' const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp')) @@ -34,6 +35,7 @@ export const customerPortalRoutes: ReadonlyArray = [ route: '', }, ...customerPortalStatisticsRoutes, + ...customerPortalSkillStatisticsRoutes, ...customerPortalTalentSearchRoutes, ...customerPortalProjectShowcaseRoutes, ...customerPortalFlexiTalentRoutes, diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts index c01e6ff0e..75c25f041 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts @@ -4,6 +4,7 @@ import { TabsNavItem } from '~/libs/ui' import { flexiTalentRouteId, showcaseSearchRouteId, + skillStatisticsRouteId, statisticsRouteId, talentSearchRouteId, } from '~/apps/customer-portal/src/config/routes.config' @@ -14,6 +15,9 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri ...(!isUnprivilegedUser ? [{ id: statisticsRouteId, title: 'General Statistics', + }, { + id: skillStatisticsRouteId, + title: 'Skill Statistics', }, { id: talentSearchRouteId, title: 'Talent Search', diff --git a/src/apps/customer-portal/src/lib/services/statistics.service.spec.ts b/src/apps/customer-portal/src/lib/services/statistics.service.spec.ts new file mode 100644 index 000000000..d48d22b27 --- /dev/null +++ b/src/apps/customer-portal/src/lib/services/statistics.service.spec.ts @@ -0,0 +1,73 @@ +import { xhrGetAsync } from '~/libs/core' + +import { + fetchExpertSkillCategories, + fetchExpertSkillCategoryMembers, +} from './statistics.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { V6: 'https://api.example.com/v6' }, + REPORTS_API: 'https://reports.example.com', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + xhrGetAsync: jest.fn(), +}), { + virtual: true, +}) + +const mockedXhrGetAsync = xhrGetAsync as jest.MockedFunction + +describe('statistics.service expert-skills', () => { + beforeEach(() => { + mockedXhrGetAsync.mockReset() + }) + + it('loads skill categories from statistics/expert-skills', async () => { + const categories = [{ + color: '#1B4F72', + icon: 'TerminalIcon', + id: '481b5ebc-2fe6-45ed-a90c-736936d458d7', + name: 'Programming and Development', + officialName: 'Programming and Development', + size: 10, + skillsBreakdown: [{ name: 'JavaScript', percentage: 40 }], + totalMembers: 101, + totalSkills: 50, + }] + mockedXhrGetAsync.mockResolvedValueOnce(categories) + + await expect(fetchExpertSkillCategories()) + .resolves + .toEqual(categories) + expect(mockedXhrGetAsync) + .toHaveBeenCalledWith( + 'https://reports.example.com/statistics/expert-skills/categories', + ) + }) + + it('loads category members from statistics/expert-skills', async () => { + const members = [{ + countryCode: 'IN', + countryName: 'India', + handle: 'billzedison', + name: 'Honghan W', + rating: 2000, + wins: 376, + }] + mockedXhrGetAsync.mockResolvedValueOnce(members) + + await expect(fetchExpertSkillCategoryMembers('Programming and Development')) + .resolves + .toEqual(members) + expect(mockedXhrGetAsync) + .toHaveBeenCalledWith( + 'https://reports.example.com/statistics/expert-skills/category-members' + + '?selectedcategory=Programming+and+Development', + ) + }) +}) diff --git a/src/apps/customer-portal/src/lib/services/statistics.service.ts b/src/apps/customer-portal/src/lib/services/statistics.service.ts index e15a59c15..c00df244e 100644 --- a/src/apps/customer-portal/src/lib/services/statistics.service.ts +++ b/src/apps/customer-portal/src/lib/services/statistics.service.ts @@ -92,6 +92,7 @@ type CountryLookupResponse = { } const GENERAL_STATISTICS_URL = `${EnvironmentConfig.REPORTS_API}/statistics/general` +const EXPERT_SKILLS_STATISTICS_URL = `${EnvironmentConfig.REPORTS_API}/statistics/expert-skills` const COUNTRY_LOOKUP_URL = `${EnvironmentConfig.API.V6}/lookups/countries?page=1&perPage=9999` const COUNTRY_NAME_ALIASES: Record = { @@ -263,3 +264,57 @@ export async function fetchGeneralStatistics(): Promise { totalPrizes: Number(totalPrizesResponse.total || 0), } } + +export type ExpertSkillBreakdown = { + name: string + percentage: number +} + +export type ExpertSkillCategory = { + color: string + icon: string + id: string + name: string + officialName: string + size: number + skillsBreakdown: ExpertSkillBreakdown[] + totalMembers: number + totalSkills: number +} + +export type ExpertSkillCategoryMember = { + countryCode: string + countryName: string + handle: string + name: string + photoURL?: string | null + rating: number + wins: number +} + +export const EXPERT_SKILL_CATEGORIES_CACHE_KEY = 'customer-portal-expert-skill-categories' + +export function expertSkillCategoryMembersCacheKey(selectedCategory: string): string { + return `customer-portal-expert-skill-category-members:${selectedCategory}` +} + +export async function fetchExpertSkillCategories(): Promise { + const response = await xhrGetAsync( + `${EXPERT_SKILLS_STATISTICS_URL}/categories`, + ) + + return Array.isArray(response) ? response : [] +} + +export async function fetchExpertSkillCategoryMembers( + selectedCategory: string, +): Promise { + const query = new URLSearchParams({ + selectedcategory: selectedCategory, + }) + const response = await xhrGetAsync( + `${EXPERT_SKILLS_STATISTICS_URL}/category-members?${query.toString()}`, + ) + + return Array.isArray(response) ? response : [] +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss new file mode 100644 index 000000000..221cac070 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss @@ -0,0 +1,313 @@ +@import '@libs/ui/styles/includes'; + +.chart { + height: 560px; + min-height: 560px; + overflow: visible; + position: relative; + width: 100%; + + @include ltemd { + height: 420px; + min-height: 420px; + } +} + +.bubble { + align-items: center; + border: 0; + border-radius: 50%; + box-sizing: border-box; + color: #fff; + cursor: pointer; + display: flex; + justify-content: center; + overflow: hidden; + padding: 0; + position: absolute; + text-align: center; + transform: translate(-50%, -50%); + transition: box-shadow 160ms ease, transform 160ms ease; + z-index: 1; + + &:hover, + &:focus-visible, + &.hovered { + box-shadow: 0 10px 28px rgba(10, 10, 10, 0.35); + z-index: 3; + } + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 3px; + } +} + +.selected { + box-shadow: 0 12px 32px rgba(10, 10, 10, 0.4); + z-index: 4; +} + +.bubbleInner { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + width: 80%; + + svg { + color: #fff; + flex: 0 0 auto; + margin-bottom: 4px; + } +} + +.label { + display: -webkit-box; + font-family: 'Nunito Sans', sans-serif; + font-weight: 700; + line-height: 1.15; + max-width: 100%; + overflow: hidden; + overflow-wrap: anywhere; + width: 100%; + word-break: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.popover { + background: #0f172a; + border-radius: 8px; + box-sizing: border-box; + color: #fff; + display: flex; + flex-direction: column; + font-family: 'Figtree', sans-serif; + gap: 16px; + padding: 24px; + pointer-events: none; + position: fixed; + transform: translate(-50%, calc(-100% - 8px)); + width: 320px; + z-index: 2147483647; + + &::after { + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-top: 8px solid #0f172a; + content: ''; + height: 0; + left: 50%; + position: absolute; + top: 100%; + transform: translateX(-50%); + width: 0; + } + + &.below { + transform: translate(-50%, 8px); + + &::after { + border-bottom: 8px solid #0f172a; + border-top: 0; + bottom: 100%; + top: auto; + } + } + + &.left { + transform: translate(calc(-100% - 8px), 0); + + &::after { + border-bottom: 9px solid transparent; + border-left: 8px solid #0f172a; + border-right: 0; + border-top: 9px solid transparent; + left: 100%; + top: var(--arrow-offset, 50%); + transform: translateY(-50%); + } + } + + &.right { + transform: translate(8px, 0); + + &::after { + border-bottom: 9px solid transparent; + border-left: 0; + border-right: 8px solid #0f172a; + border-top: 9px solid transparent; + left: auto; + right: 100%; + top: var(--arrow-offset, 50%); + transform: translateY(-50%); + } + } +} + +.popoverTitle { + font-size: 18px; + font-weight: 700; + line-height: normal; +} + +.metrics { + display: grid; + gap: 40px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.metric { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 3px; +} + +.metricValue { + align-items: center; + display: flex; + gap: 5px; + + img { + flex: 0 0 24px; + height: 24px; + width: 24px; + } + + strong { + font-size: 24px; + font-weight: 600; + line-height: normal; + white-space: nowrap; + } +} + +.breakdown { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 10px; +} + +.bar { + border-radius: 4px; + display: flex; + height: 24px; + overflow: hidden; + width: 100%; +} + +.segment { + align-items: center; + display: flex; + flex: 0 0 auto; + justify-content: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.legend { + align-items: center; + display: flex; + justify-content: space-between; +} + +.legendItem { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; + + > span:last-child { + max-width: 70px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.dot { + border-radius: 50%; + flex: 0 0 9px; + height: 9px; + width: 9px; +} + +.topMember { + display: flex; + flex-direction: column; + gap: 8px; +} + +.topMemberContent { + align-items: center; + display: flex; + gap: 14px; + min-width: 0; +} + +.avatar { + background-color: #d9d9d9; + background-position: center; + background-size: cover; + border-radius: 50%; + display: block; + flex: 0 0 40px; + height: 40px; + overflow: hidden; + position: relative; + width: 40px; +} + +.avatarHead { + background: #aab6c2; + border-radius: 50%; + height: 14px; + left: 13px; + position: absolute; + top: 7px; + width: 14px; +} + +.avatarBody { + background: #aab6c2; + border-radius: 16px 16px 8px 8px; + bottom: -2px; + height: 18px; + left: 7px; + position: absolute; + width: 26px; +} + +.handle { + font-size: 16px; + font-weight: 600; + line-height: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #3877EA; +} + +.memberStats { + align-items: center; + display: flex; + font-size: 12px; + gap: 5px; + line-height: normal; + white-space: nowrap; +} + +.flag { + flex: 0 0 16px; + height: 16px; + width: 16px; +} + +.divider { + margin: 0 5px; +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx new file mode 100644 index 000000000..040f36424 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx @@ -0,0 +1,483 @@ +/* eslint-disable react/jsx-no-bind, no-use-before-define */ +import { + CSSProperties, + FC, + KeyboardEvent, + RefObject, + SVGProps, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import classNames from 'classnames' +import useSWR, { SWRResponse } from 'swr' + +import { getRatingColor } from '~/libs/core' +import { IconOutline } from '~/libs/ui' + +import { + ExpertSkillCategory, + ExpertSkillCategoryMember, + expertSkillCategoryMembersCacheKey, + fetchExpertSkillCategoryMembers, +} from '../../../lib' +import memberGroupIcon from '../../statistics/StatisticsPage/assets/member-group.svg' +import skillCognitionIcon from '../../statistics/StatisticsPage/assets/skill-cognition.svg' + +import { packCircles, PackedCircle } from './packCircles' +import styles from './SkillBubblesChart.module.scss' + +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') +const SKILL_COLORS = ['#c1294f', '#00797a', '#fdc220', '#a6a6a6'] +const POPOVER_GAP = 12 +const POPOVER_ESTIMATED_HEIGHT = 340 +const POPOVER_WIDTH = 320 +const VIEW_PAD = 8 +const MIN_BUBBLE_FONT_SIZE = 10 +const MAX_BUBBLE_FONT_SIZE = 16 + +type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right' + +type PopoverLayout = { + arrowOffset?: number + left: number + placement: PopoverPlacement + top: number +} + +type ChartRect = { + height: number + left: number + top: number + width: number +} + +function getPopoverLayout( + circle: PackedCircle, + chartRect: ChartRect, + popoverWidth: number, + popoverHeight: number, +): PopoverLayout { + const viewWidth = typeof window === 'undefined' ? chartRect.width : window.innerWidth + const viewHeight = typeof window === 'undefined' ? chartRect.height : window.innerHeight + const centerX = chartRect.left + circle.x + const centerY = chartRect.top + circle.y + const bubbleTop = centerY - circle.r + const bubbleBottom = centerY + circle.r + const bubbleLeft = centerX - circle.r + const bubbleRight = centerX + circle.r + const spaceLeft = bubbleLeft - VIEW_PAD + const spaceRight = viewWidth - VIEW_PAD - bubbleRight + const fitsTop = bubbleTop - POPOVER_GAP - popoverHeight >= VIEW_PAD + const fitsBottom = bubbleBottom + POPOVER_GAP + popoverHeight <= viewHeight - VIEW_PAD + const fitsLeft = spaceLeft >= popoverWidth + POPOVER_GAP + const fitsRight = spaceRight >= popoverWidth + POPOVER_GAP + + let placement: PopoverPlacement = 'top' + if (fitsTop) { + placement = 'top' + } else if (fitsLeft && fitsRight) { + placement = spaceRight >= spaceLeft ? 'right' : 'left' + } else if (fitsRight) { + placement = 'right' + } else if (fitsLeft) { + placement = 'left' + } else if (fitsBottom) { + placement = 'bottom' + } else { + placement = spaceRight >= spaceLeft ? 'right' : 'left' + } + + if (placement === 'top') { + return { + left: centerX, + placement, + top: bubbleTop, + } + } + + if (placement === 'bottom') { + return { + left: centerX, + placement, + top: bubbleBottom, + } + } + + const desiredTop = centerY - (popoverHeight / 2) + const clampedTop = Math.min( + Math.max(desiredTop, VIEW_PAD), + viewHeight - VIEW_PAD - popoverHeight, + ) + + return { + arrowOffset: centerY - clampedTop, + left: placement === 'right' ? bubbleRight : bubbleLeft, + placement, + top: clampedTop, + } +} + +type SkillCategoryIcon = FC> + +interface SkillBubblesChartProps { + categories: ExpertSkillCategory[] + onSelect: (categoryId: string) => void + selectedCategoryId?: string +} + +function getCategoryIcon(iconName?: string): SkillCategoryIcon { + const icons = IconOutline as Record + const icon = iconName ? icons[iconName] : undefined + + return icon || IconOutline.CodeIcon +} + +function radiusForSize(size: number): number { + return 28 + (size * 9) +} + +function fontSizeForRadius( + radius: number, + minRadius: number, + maxRadius: number, +): number { + if (maxRadius <= minRadius) { + return (MIN_BUBBLE_FONT_SIZE + MAX_BUBBLE_FONT_SIZE) / 2 + } + + const t = (radius - minRadius) / (maxRadius - minRadius) + + return MIN_BUBBLE_FONT_SIZE + (t * (MAX_BUBBLE_FONT_SIZE - MIN_BUBBLE_FONT_SIZE)) +} + +const SkillBubblesChart: FC = props => { + const chartRef = useRef(null) + const [hoveredCategoryId, setHoveredCategoryId] = useState() + const [viewport, setViewport] = useState({ height: 560, width: 960 }) + + useEffect(() => { + const node = chartRef.current + if (!node) { + return undefined + } + + const measure = (): void => { + setViewport(current => { + const height = Math.max(node.clientHeight, 1) + const width = Math.max(node.clientWidth, 1) + + return current.width === width && current.height === height + ? current + : { height, width } + }) + } + + measure() + window.addEventListener('resize', measure) + + const observer = typeof ResizeObserver === 'undefined' + ? undefined + : new ResizeObserver(measure) + observer?.observe(node) + + return () => { + window.removeEventListener('resize', measure) + observer?.disconnect() + } + }, []) + + const packed = useMemo( + () => packCircles( + props.categories.map(category => ({ + id: category.id, + r: radiusForSize(category.size), + })), + viewport.width, + viewport.height, + ), + [props.categories, viewport.height, viewport.width], + ) + + const packedById = useMemo( + () => new Map(packed.map(circle => [circle.id, circle])), + [packed], + ) + const packedRadii = useMemo( + () => packed.map(circle => circle.r), + [packed], + ) + const minPackedRadius = packedRadii.length ? Math.min(...packedRadii) : 0 + const maxPackedRadius = packedRadii.length ? Math.max(...packedRadii) : 0 + + const hoveredCategory = props.categories.find( + category => category.id === hoveredCategoryId, + ) + const hoveredCircle = hoveredCategory + ? packedById.get(hoveredCategory.id) + : undefined + const { data: hoveredMembers }: SWRResponse = useSWR( + hoveredCategory + ? expertSkillCategoryMembersCacheKey(hoveredCategory.name) + : undefined, + () => fetchExpertSkillCategoryMembers(hoveredCategory?.name || ''), + ) + const topMember = hoveredMembers?.[0] + + const handleKeyDown = useCallback(( + event: KeyboardEvent, + categoryId: string, + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + props.onSelect(categoryId) + } + }, [props]) + + return ( +
+ {props.categories.map(category => { + const circle = packedById.get(category.id) + if (!circle) { + return undefined + } + + const Icon = getCategoryIcon(category.icon) + const isSelected = category.id === props.selectedCategoryId + const fontSize = fontSizeForRadius( + circle.r, + minPackedRadius, + maxPackedRadius, + ) + const innerSize = circle.r * 1.16 + const iconSize = Math.max(12, Math.min(22, innerSize / 5.5)) + + return ( + + ) + })} + {hoveredCategory && hoveredCircle && ( + + )} +
+ ) +} + +interface SkillCategoryPopoverProps { + category: ExpertSkillCategory + chartHeight: number + chartRef: RefObject + chartWidth: number + circle: PackedCircle + topMember?: ExpertSkillCategoryMember +} + +const SkillCategoryPopover = (props: SkillCategoryPopoverProps): JSX.Element => { + const popoverRef = useRef(null) + const [layout, setLayout] = useState({ + left: props.circle.x, + placement: 'top', + top: props.circle.y - props.circle.r, + }) + + useLayoutEffect(() => { + const update = (): void => { + const chartNode = props.chartRef.current + const chartRect = chartNode?.getBoundingClientRect() + const measured: ChartRect = chartRect && chartRect.width > 0 + ? chartRect + : { + height: props.chartHeight, + left: 0, + top: 0, + width: props.chartWidth, + } + const height = popoverRef.current?.offsetHeight || POPOVER_ESTIMATED_HEIGHT + const width = popoverRef.current?.offsetWidth || POPOVER_WIDTH + setLayout(getPopoverLayout(props.circle, measured, width, height)) + } + + update() + window.addEventListener('resize', update) + window.addEventListener('scroll', update, true) + + return () => { + window.removeEventListener('resize', update) + window.removeEventListener('scroll', update, true) + } + }, [props.category.id, props.chartHeight, props.chartRef, props.chartWidth, props.circle]) + + const topSkillsPercentage = props.category.skillsBreakdown.reduce( + (total, skill) => total + skill.percentage, + 0, + ) + const skills = [ + ...props.category.skillsBreakdown, + { + name: 'Others', + percentage: Math.max(100 - topSkillsPercentage, 0), + }, + ].filter(skill => skill.percentage > 0) + const countryCode = /^[A-Z]{2}$/.test(props.topMember?.countryCode || '') + ? props.topMember?.countryCode.toLowerCase() + : '' + const popoverStyle: CSSProperties = { + left: layout.left, + top: layout.top, + } + + if (layout.arrowOffset !== undefined) { + Object.assign(popoverStyle, { '--arrow-offset': `${layout.arrowOffset}px` }) + } + + const popover = ( +
+ {props.category.name} +
+
+ Total Members + + + {NUMBER_FORMATTER.format(props.category.totalMembers)} + +
+
+ Total Skills + + + {NUMBER_FORMATTER.format(props.category.totalSkills)} + +
+
+
+ Sub-Skill Breakdown +
+ {skills.map((skill, index) => ( + + {`${skill.percentage}%`} + + ))} +
+
+ {skills.map((skill, index) => ( + + + {skill.name} + + ))} +
+
+ {props.topMember && ( +
+ Top Member +
+ + + + + + + {props.topMember.handle} + + + {countryCode && ( + + +
+
+ )} +
+ ) + + return typeof document === 'undefined' + ? popover + : createPortal(popover, document.body) +} + +export default SkillBubblesChart diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss new file mode 100644 index 000000000..35b950ec8 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss @@ -0,0 +1,268 @@ +@import '@libs/ui/styles/includes'; + +.section { + margin-top: 40px; +} + +.header { + h2 { + color: #151515; + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + line-height: 30px; + margin: 0; + text-transform: none; + } +} + +.subtitle { + color: #0a0a0a; + font-size: 18px; + line-height: 25px; + margin: 4px 0 0; +} + +.body { + display: grid; + gap: 32px; + grid-template-columns: minmax(220px, 240px) minmax(0, 1fr); + margin-top: 24px; + + @include ltemd { + grid-template-columns: 1fr; + } +} + +.filters { + display: flex; + flex-direction: column; + gap: 16px; +} + +.search { + position: relative; + + input { + background: #fff; + border: 1px solid #a8a8a8; + border-radius: 4px; + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + height: 40px; + line-height: 22px; + padding: 8px 40px 8px 12px; + width: 100%; + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 1px; + } + } + + svg { + color: #767676; + height: 20px; + pointer-events: none; + position: absolute; + right: 12px; + top: 10px; + width: 20px; + } +} + +.filter { + display: flex; + flex-direction: column; + gap: 6px; + + span { + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 16px; + } + + select { + appearance: none; + background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%230a0a0a'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E") no-repeat right 12px center; + background-size: 18px; + border: 1px solid #a8a8a8; + border-radius: 4px; + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + height: 40px; + line-height: 22px; + padding: 8px 36px 8px 12px; + width: 100%; + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 1px; + } + } +} + +$row-height: 64px; +$visible-member-rows: 10; + +.tableWrap { + max-height: $row-height * ($visible-member-rows + 1); + overflow: auto; + + table { + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + width: 100%; + } + + thead { + position: sticky; + top: 0; + z-index: 3; + } + + th, + td { + border-bottom: 1px solid #e2e2e2; + color: #1a1a1a; + font-size: 14px; + height: $row-height; + line-height: 20px; + padding: 12px 16px; + text-align: left; + vertical-align: middle; + } + + th { + background: #fff; + border-bottom-color: #a8a8a8; + font-weight: 700; + position: sticky; + top: 0; + z-index: 3; + } + + tbody td { + background: #fff; + position: relative; + z-index: 0; + } + + .avatar { + z-index: 0; + } + + th:first-child, + td:first-child { + text-align: center; + width: 64px; + } + + th:nth-child(3), + td:nth-child(3) { + width: 88px; + } + + th:nth-child(4), + td:nth-child(4) { + width: 140px; + } + + th:last-child, + td:last-child { + text-align: right; + width: 110px; + } +} + +.memberCell { + align-items: center; + display: flex; + gap: 12px; + min-width: 0; +} + +.avatar { + flex: 0 0 40px; + height: 40px; + width: 40px; + + :global(span) { + font-size: 14px !important; + } +} + +.memberText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.handle { + font-weight: 700; + overflow: hidden; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } +} + +.memberName { + color: #767676; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.countryCell { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; + + span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.flag { + display: inline-flex; + flex: 0 0 auto; + height: 14px; + width: 22px; +} + +.rank1, +.rank2, +.rank3 { + align-items: center; + display: inline-flex; + height: 20px; + justify-content: center; + width: 20px; +} + +.rank4 { + display: inline-block; + font-size: 14px; + line-height: 20px; + min-width: 19px; + text-align: center; +} + +.empty { + color: #545f71; + font-size: 14px; + padding: 24px 16px; + text-align: center; +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx new file mode 100644 index 000000000..8776462d1 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx @@ -0,0 +1,202 @@ +/* eslint-disable react/jsx-no-bind */ +import { ChangeEvent, FC, useMemo } from 'react' +import classNames from 'classnames' + +import { EnvironmentConfig } from '~/config' +import { getRatingColor } from '~/libs/core' +import { ProfilePicture } from '~/libs/shared' +import { IconOutline } from '~/libs/ui' + +import { ExpertSkillCategory, ExpertSkillCategoryMember } from '../../../lib' +import { + IconFirstPlace, + IconSecondPlace, + IconThirdPlace, +} from '../../statistics/StatisticsPage/assets' + +import styles from './SkillMembersPanel.module.scss' + +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') + +interface SkillMembersPanelProps { + category: ExpertSkillCategory + countryFilter: string + members: ExpertSkillCategoryMember[] + onCountryChange: (countryCode: string) => void + onSearchChange: (value: string) => void + search: string +} + +const SkillMembersPanel: FC = props => { + const countryOptions = useMemo(() => { + const unique = new Map() + props.members.forEach(member => { + if (member.countryCode && member.countryName) { + unique.set(member.countryCode, member.countryName) + } + }) + + return Array.from(unique.entries()) + .map(([code, name]) => ({ code, name })) + .sort((left, right) => left.name.localeCompare(right.name)) + }, [props.members]) + + const visibleMembers = useMemo(() => { + const query = props.search.trim() + .toLowerCase() + + return props.members + .filter(member => { + const matchesSearch = !query + || member.handle.toLowerCase() + .includes(query) + || member.name.toLowerCase() + .includes(query) + const matchesCountry = !props.countryFilter + || member.countryCode === props.countryFilter + + return matchesSearch && matchesCountry + }) + .sort((left, right) => right.wins - left.wins) + }, [props.countryFilter, props.members, props.search]) + + return ( +
+
+

{`Members for ${props.category.name}`}

+

+ Browse top 100 talent by skills and numbers of wins. +

+
+
+ +
+
+ + + + + + + + + + + {visibleMembers.length === 0 && ( + + + + )} + {visibleMembers.map((member, index) => { + const rankIcon = index === 0 + ? + + + + + + + ) + })} + +
RankMemberRatingCountry# of Wins
+ No members match the current filters. +
+ + {rankIcon} + + +
+ +
+ + {member.handle} + + {member.name} +
+
+
+ + {member.rating} + + +
+ {countryCode && ( +
+
{NUMBER_FORMATTER.format(member.wins)}
+
+ + + ) +} + +export default SkillMembersPanel diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss new file mode 100644 index 000000000..b7a1e854d --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss @@ -0,0 +1,64 @@ +@import '@libs/ui/styles/includes'; + +.page { + color: #0a0a0a; + display: flex; + flex-direction: column; + font-family: 'Nunito Sans', sans-serif; + overflow-x: hidden; + padding: 8px 0 0; +} + +.header { + h1 { + color: #151515; + font-family: 'Figtree', sans-serif; + font-size: 32px; + font-weight: 700; + line-height: 38px; + margin-top: 20px; + text-transform: none; + } +} + +.subtitle { + color: #0a0a0a; + font-size: 18px; + line-height: 25px; + margin: 4px 0 0; +} + +.chartPanel { + box-sizing: border-box; + display: flex; + flex-direction: column; + left: 50%; + margin-top: 16px; + max-width: 100vw; + padding: 0 32px; + position: relative; + transform: translateX(-50%); + width: 100vw; +} + +.hint { + color: #0a0a0a; + font-size: 16px; + line-height: 22px; + margin: 20px 0 8px; + text-align: center; +} + +.status { + align-items: center; + color: #545f71; + display: flex; + gap: 8px; + justify-content: center; + min-height: 240px; + + button { + color: #0d61bf; + text-decoration: underline; + } +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx new file mode 100644 index 000000000..3636316a1 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx @@ -0,0 +1,282 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { fireEvent, render, screen, within } from '@testing-library/react' +import { SWRConfig } from 'swr' + +import { getTabIdFromPathName, getTabsConfig } from '../../../lib/components/NavTabs/config/tabs-config' +import { + fetchExpertSkillCategories, + fetchExpertSkillCategoryMembers, +} from '../../../lib' +import SkillStatisticsPage from './SkillStatisticsPage' + +jest.mock('~/config', () => ({ + AppSubdomain: { + customer: 'customer', + }, + EnvironmentConfig: { + SUBDOMAIN: 'customer', + USER_PROFILE_URL: 'https://profiles.example.com', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + getRatingColor: (rating?: number) => (rating && rating >= 2200 ? '#EF3A3A' : '#F2C900'), +}), { + virtual: true, +}) + +jest.mock('~/libs/shared', () => ({ + ProfilePicture: () => , +}), { + virtual: true, +}) + +const DummyIcon = (): JSX.Element => + +jest.mock('~/libs/ui', () => ({ + IconOutline: new Proxy({}, { + get: () => DummyIcon, + }), + TabsNavItem: {}, +}), { + virtual: true, +}) + +jest.mock('~/apps/customer-portal/src/config/routes.config', () => ({ + flexiTalentRouteId: 'flexi-talent', + showcaseSearchRouteId: 'showcase', + skillStatisticsRouteId: 'skill-statistics', + statisticsRouteId: 'statistics', + talentSearchRouteId: 'talent-search', +}), { + virtual: true, +}) + +jest.mock('flag-icons/css/flag-icons.min.css', () => ({}), { + virtual: true, +}) + +jest.mock('../../statistics/StatisticsPage/assets', () => ({ + IconFirstPlace: () => 1st, + IconSecondPlace: () => 2nd, + IconThirdPlace: () => 3rd, +}), { + virtual: true, +}) + +jest.mock('../../statistics/StatisticsPage/assets/member-group.svg', () => 'member-group.svg', { + virtual: true, +}) + +jest.mock('../../statistics/StatisticsPage/assets/skill-cognition.svg', () => 'skill-cognition.svg', { + virtual: true, +}) + +jest.mock('../../../lib', () => ({ + EXPERT_SKILL_CATEGORIES_CACHE_KEY: 'customer-portal-expert-skill-categories', + expertSkillCategoryMembersCacheKey: (selectedCategory: string) => ( + `customer-portal-expert-skill-category-members:${selectedCategory}` + ), + fetchExpertSkillCategories: jest.fn(), + fetchExpertSkillCategoryMembers: jest.fn(), +})) + +const mockedFetchCategories = fetchExpertSkillCategories as jest.MockedFunction< + typeof fetchExpertSkillCategories +> +const mockedFetchMembers = fetchExpertSkillCategoryMembers as jest.MockedFunction< + typeof fetchExpertSkillCategoryMembers +> + +const CATEGORIES = [ + { + color: '#1B4F72', + icon: 'TerminalIcon', + id: '481b5ebc-2fe6-45ed-a90c-736936d458d7', + name: 'Programming and Development', + officialName: 'Programming and Development', + size: 10, + skillsBreakdown: [{ name: 'JavaScript', percentage: 40 }], + totalMembers: 101, + totalSkills: 50, + }, + { + color: '#4A6A7A', + icon: 'CodeIcon', + id: '1f5ed3e8-8d22-44ea-b75d-ea85147a04da', + name: 'Scripting and Automation', + officialName: 'Scripting and Automation', + size: 3, + skillsBreakdown: [], + totalMembers: 10, + totalSkills: 4, + }, +] + +const MEMBERS = [ + { + countryCode: 'IN', + countryName: 'India', + handle: 'billzedison', + name: 'Honghan W', + rating: 2000, + wins: 376, + }, + { + countryCode: 'US', + countryName: 'USA', + handle: 'Ghostar', + name: 'Justin G', + rating: 1900, + wins: 322, + }, + { + countryCode: 'GB', + countryName: 'UK', + handle: 'diazx', + name: 'DAT N', + rating: 2300, + wins: 200, + }, +] + +function renderPage(): ReturnType { + return render( + new Map(), + }} + > + + , + ) +} + +describe('Customer Portal Skill Statistics tabs', () => { + it('adds Skill Statistics beside General Statistics', () => { + const tabs = getTabsConfig(['administrator'], false, false) + + expect(tabs.map(tab => tab.title)) + .toEqual([ + 'General Statistics', + 'Skill Statistics', + 'Talent Search', + 'Showcase', + 'Flexi-Talent', + ]) + expect(getTabIdFromPathName('/skill-statistics', ['administrator'], false, false)) + .toBe('skill-statistics') + expect(getTabIdFromPathName('/statistics', ['administrator'], false, false)) + .toBe('statistics') + }) +}) + +describe('SkillStatisticsPage', () => { + beforeEach(() => { + mockedFetchCategories.mockReset() + mockedFetchMembers.mockReset() + mockedFetchCategories.mockResolvedValue(CATEGORIES) + mockedFetchMembers.mockImplementation(async selectedCategory => ( + selectedCategory === 'Programming and Development' ? MEMBERS : [] + )) + }) + + it('renders skill categories from the reports API', async () => { + renderPage() + + expect(await screen.findByRole('button', { name: 'Programming and Development' })) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Scripting and Automation' })) + .toBeInTheDocument() + expect(screen.getByText('Browse and connect with verified experts across 2 skill categories.')) + .toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Bar' })) + .not.toBeInTheDocument() + expect(mockedFetchCategories) + .toHaveBeenCalledTimes(1) + }) + + it('shows the category popover on hover and the members UI on click', async () => { + renderPage() + + const bubble = await screen.findByRole('button', { name: 'Programming and Development' }) + fireEvent.mouseEnter(bubble) + + expect(screen.getByText('Total Members')) + .toBeInTheDocument() + expect(await screen.findByText('billzedison')) + .toBeInTheDocument() + expect(screen.getByText('Total Members') + .closest('[data-placement]')) + .toHaveAttribute('data-placement', expect.stringMatching(/^(top|bottom|left|right)$/)) + expect(screen.queryByRole('heading', { name: 'Members for Programming and Development' })) + .not.toBeInTheDocument() + + fireEvent.click(bubble) + + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + expect(screen.getByText('Ghostar')) + .toBeInTheDocument() + }) + + it('filters members from in-memory state when searching', async () => { + renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(await screen.findByText('billzedison')) + .toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Search members'), { + target: { value: 'Ghostar' }, + }) + + const table = screen.getByRole('table') + expect(within(table) + .getByText('Ghostar')) + .toBeInTheDocument() + expect(within(table) + .queryByText('billzedison')) + .not.toBeInTheDocument() + expect(within(table) + .getByText('1st')) + .toBeInTheDocument() + }) + + it('reranks members when filtering by country', async () => { + renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(await screen.findByText('billzedison')) + .toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Filter By'), { + target: { value: 'GB' }, + }) + + const table = screen.getByRole('table') + expect(within(table) + .getByText('diazx')) + .toBeInTheDocument() + expect(within(table) + .queryByText('billzedison')) + .not.toBeInTheDocument() + expect(within(table) + .getByText('1st')) + .toBeInTheDocument() + }) + + it('shows an error when skill categories fail to load', async () => { + mockedFetchCategories.mockRejectedValueOnce(new Error('failed')) + renderPage() + + expect(await screen.findByRole('alert')) + .toHaveTextContent('Skill categories could not be loaded.') + expect(screen.queryByRole('button', { name: 'Programming and Development' })) + .not.toBeInTheDocument() + }) +}) diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx new file mode 100644 index 000000000..53fb628b2 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx @@ -0,0 +1,127 @@ +import { FC, useCallback, useMemo, useState } from 'react' +import useSWR, { SWRResponse } from 'swr' +import 'flag-icons/css/flag-icons.min.css' + +import { + ExpertSkillCategory, + ExpertSkillCategoryMember, + expertSkillCategoryMembersCacheKey, + EXPERT_SKILL_CATEGORIES_CACHE_KEY, + fetchExpertSkillCategories, + fetchExpertSkillCategoryMembers, +} from '../../../lib' + +import SkillBubblesChart from './SkillBubblesChart' +import SkillMembersPanel from './SkillMembersPanel' +import styles from './SkillStatisticsPage.module.scss' + +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') + +function getPageSubtitle(categoryCount?: number): string { + if (!categoryCount) { + return 'Browse and connect with verified experts.' + } + + return `Browse and connect with verified experts across ${NUMBER_FORMATTER.format(categoryCount)} skill categories.` +} + +const SkillStatisticsPage: FC = () => { + const [selectedCategoryId, setSelectedCategoryId] = useState() + const [search, setSearch] = useState('') + const [countryFilter, setCountryFilter] = useState('') + const { + data: categories, + error: categoriesError, + mutate: reloadCategories, + }: SWRResponse = useSWR( + EXPERT_SKILL_CATEGORIES_CACHE_KEY, + fetchExpertSkillCategories, + ) + + const selectedCategory = useMemo( + () => categories?.find(category => category.id === selectedCategoryId), + [categories, selectedCategoryId], + ) + const { + data: members, + error: membersError, + mutate: reloadMembers, + }: SWRResponse = useSWR( + selectedCategory + ? expertSkillCategoryMembersCacheKey(selectedCategory.name) + : undefined, + () => fetchExpertSkillCategoryMembers(selectedCategory?.name || ''), + ) + + const isLoadingCategories = !categories && !categoriesError + const isLoadingMembers = Boolean(selectedCategory && !members && !membersError) + + const selectCategory = useCallback((categoryId: string) => { + setSelectedCategoryId(categoryId) + setSearch('') + setCountryFilter('') + }, []) + + const retryCategories = useCallback(() => { + reloadCategories() + }, [reloadCategories]) + + const retryMembers = useCallback(() => { + reloadMembers() + }, [reloadMembers]) + + return ( +
+
+

Skill Statistics

+

{getPageSubtitle(categories?.length)}

+
+ +
+

Select a skill category to see additional details

+ {isLoadingCategories && ( +
Loading skill categories…
+ )} + {categoriesError && ( +
+ Skill categories could not be loaded. + +
+ )} + {!isLoadingCategories && !categoriesError && ( + + )} +
+ + {selectedCategory && isLoadingMembers && ( +
Loading members…
+ )} + {selectedCategory && membersError && ( +
+ Members could not be loaded. + +
+ )} + {selectedCategory && !isLoadingMembers && !membersError && ( + + )} +
+ ) +} + +export default SkillStatisticsPage diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts new file mode 100644 index 000000000..4e9fd8917 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts @@ -0,0 +1 @@ +export { default as SkillStatisticsPage } from './SkillStatisticsPage' diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts new file mode 100644 index 000000000..c9cceac3a --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts @@ -0,0 +1,137 @@ +export type PackedCircle = { + id: string + r: number + x: number + y: number +} + +function overlaps( + a: PackedCircle, + b: PackedCircle, + padding: number, +): boolean { + const dx = a.x - b.x + const dy = a.y - b.y + const minDist = a.r + b.r + padding + + return (dx * dx) + (dy * dy) < minDist * minDist +} + +function hashString(value: string): number { + let hash = 0 + + for (let i = 0; i < value.length; i += 1) { + hash = ((hash * 31) + value.charCodeAt(i)) % 2147483647 + } + + return hash + 1 +} + +function createRng(seed: number): () => number { + let state = (seed % 2147483646) + 1 + + return () => { + state = (state * 16807) % 2147483647 + return (state - 1) / 2147483646 + } +} + +function shuffleItems(items: T[], rng: () => number): T[] { + const shuffled = [...items] + + for (let i = shuffled.length - 1; i > 0; i -= 1) { + const j = Math.floor(rng() * (i + 1)) + const current = shuffled[i] + shuffled[i] = shuffled[j] + shuffled[j] = current + } + + return shuffled +} + +/** + * Place circles in a tight non-overlapping cluster, then scale uniformly + * so the pack fits inside the given viewport while staying close together. + * Placement order is shuffled so the largest bubble is not always centered. + */ +export function packCircles( + items: Array<{ id: string; r: number }>, + width: number, + height: number, + padding: number = 6, +): PackedCircle[] { + const rng = createRng(items.reduce((seed, item) => seed + hashString(item.id), 1)) + const ordered = shuffleItems(items, rng) + const placed: PackedCircle[] = [] + const aspect = Math.min(Math.max(width / Math.max(height, 1), 1), 2.15) + const angleOffset = rng() * Math.PI * 2 + + ordered.forEach(item => { + if (placed.length === 0) { + placed.push({ + id: item.id, + r: item.r, + x: 0, + y: 0, + }) + return + } + + let found: PackedCircle | undefined + const maxReach = placed.reduce( + (reach, circle) => Math.max( + reach, + Math.hypot(circle.x / aspect, circle.y) + circle.r, + ), + 0, + ) + item.r + padding + 8 + + for (let dist = item.r; dist <= maxReach && !found; dist += 3) { + const steps = Math.max(16, Math.ceil((2 * Math.PI * dist) / 10)) + for (let step = 0; step < steps && !found; step += 1) { + const angle = ((step / steps) * 2 * Math.PI) + angleOffset + (placed.length * 0.37) + const candidate: PackedCircle = { + id: item.id, + r: item.r, + x: Math.cos(angle) * dist * aspect, + y: Math.sin(angle) * dist, + } + + if (!placed.some(circle => overlaps(candidate, circle, padding))) { + found = candidate + } + } + } + + placed.push(found || { + id: item.id, + r: item.r, + x: (maxReach + item.r) * aspect, + y: 0, + }) + }) + + if (!placed.length || width <= 0 || height <= 0) { + return placed + } + + const minX = Math.min(...placed.map(circle => circle.x - circle.r)) + const maxX = Math.max(...placed.map(circle => circle.x + circle.r)) + const minY = Math.min(...placed.map(circle => circle.y - circle.r)) + const maxY = Math.max(...placed.map(circle => circle.y + circle.r)) + const packWidth = Math.max(maxX - minX, 1) + const packHeight = Math.max(maxY - minY, 1) + const inset = 16 + const availableWidth = Math.max(width - (inset * 2), 1) + const availableHeight = Math.max(height - (inset * 2), 1) + const scale = Math.min(availableWidth / packWidth, availableHeight / packHeight) + const offsetX = inset + ((availableWidth - (packWidth * scale)) / 2) + const offsetY = inset + ((availableHeight - (packHeight * scale)) / 2) + + return placed.map(circle => ({ + id: circle.id, + r: circle.r * scale, + x: ((circle.x - minX) * scale) + offsetX, + y: ((circle.y - minY) * scale) + offsetY, + })) +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx b/src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx new file mode 100644 index 000000000..49948f976 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx @@ -0,0 +1,26 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' + +import { skillStatisticsRouteId } from '../../config/routes.config' + +const SkillStatisticsPage: LazyLoadedComponent = lazyLoad( + () => import('./SkillStatisticsPage'), + 'SkillStatisticsPage', +) + +export const skillStatisticsChildRoutes = [ + { + authRequired: true, + element: , + id: 'skill-statistics-page', + route: '', + }, +] + +export const customerPortalSkillStatisticsRoutes = [ + { + children: [...skillStatisticsChildRoutes], + element: getRoutesContainer(skillStatisticsChildRoutes), + id: skillStatisticsRouteId, + route: skillStatisticsRouteId, + }, +] diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss index 782957589..3ff7d101d 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss @@ -189,6 +189,7 @@ position: sticky; top: 0; z-index: 1; + vertical-align: middle; } th:first-child, @@ -351,6 +352,22 @@ } } +// Shown when the hovered country has no matching shape on the map, so the +// Highcharts tooltip has no point to anchor to. +.mapFallbackTooltip { + left: 50%; + pointer-events: none; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + z-index: 20; + + // no anchor point to point at + > div::after { + display: none; + } +} + .mapTooltip, .mapTooltipCompact, .countryMapTooltip { diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx index 8eae64042..c93033069 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useEffect, useMemo, useRef } from 'react' +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import Highcharts from 'highcharts/highmaps' import HighchartsReact from 'highcharts-react-official' @@ -351,6 +351,35 @@ function createTooltipFormatter( } } +// Shapes dropped from the bundled world map. 'sx' is Somaliland, which the map +// collection tags with the ISO code of Sint Maarten, so it both shows a country +// that we never have data for and can steal SX data from the real Sint Maarten. +const EXCLUDED_MAP_KEYS = ['sx'] + +const worldMapTopology = ((): any => { + const topology = worldMap as any + const geometries = topology.objects?.default?.geometries + + if (!Array.isArray(geometries)) { + return topology + } + + return { + ...topology, + objects: { + ...topology.objects, + default: { + ...topology.objects.default, + geometries: geometries.filter( + (geometry: any) => !EXCLUDED_MAP_KEYS.includes( + String(geometry?.properties?.['hc-key'] ?? ''), + ), + ), + }, + }, + } +})() + interface ChartDataPoint { code: string name: string @@ -365,6 +394,8 @@ const WorldMap: FC = props => { const mapRef = useRef(null) const chartRef = useRef(null) const hoveredPointRef = useRef(undefined) + const [fallbackTooltipPoint, setFallbackTooltipPoint] + = useState(undefined) const chartData: ChartDataPoint[] = useMemo( () => props.countries.map(country => { const code = String(country.code ?? '') @@ -415,14 +446,25 @@ const WorldMap: FC = props => { chart.tooltip?.hide() } + // The map geometry does not cover every country we have data for + // (missing/unmatched ISO codes), so fall back to a tooltip rendered at a + // fixed spot over the map instead of showing nothing at all. + const showFallbackTooltip = (): void => { + clearHover() + setFallbackTooltipPoint( + chartData.find(point => point.code === normalizedHoveredCountry), + ) + } + if (!normalizedHoveredCountry) { clearHover() + setFallbackTooltipPoint(undefined) return } const series = chart.series?.[0] if (!series) { - clearHover() + showFallbackTooltip() return } @@ -433,8 +475,17 @@ const WorldMap: FC = props => { return pointCode && pointCode === normalizedHoveredCountry }) - if (!hoveredPoint) { - clearHover() + // const countryName = getName(normalizedHoveredCountry, 'EN'); + + // A point can exist in the series without being drawn on the map, in + // which case it has no plot coordinates and cannot anchor a tooltip. + const isPlotted + = Number.isFinite(hoveredPoint?.plotX) + && Number.isFinite(hoveredPoint?.plotY) + // && hoveredPoint.name === countryName + + if (!hoveredPoint || !isPlotted) { + showFallbackTooltip() return } @@ -442,16 +493,17 @@ const WorldMap: FC = props => { hoveredPointRef.current.setState('') } + setFallbackTooltipPoint(undefined) hoveredPointRef.current = hoveredPoint hoveredPoint.setState('hover') chart.tooltip.refresh(hoveredPoint) - }, [normalizedHoveredCountry, props.showWinnerDetails]) + }, [chartData, normalizedHoveredCountry, props.showWinnerDetails]) const chartOptions = useMemo( () => ({ chart: { backgroundColor: '#ffffff', - map: worldMap as any, + map: worldMapTopology, margin: [12, 8, 54, 8], plotBackgroundColor: '#f8f8f8', spacing: [0, 0, 0, 0], @@ -572,6 +624,16 @@ const WorldMap: FC = props => { ], ) + const fallbackTooltipHtml = useMemo(() => { + if (!fallbackTooltipPoint) { + return '' + } + + return props.showWinnerDetails + ? renderWinnersTooltip(fallbackTooltipPoint) + : renderCountryTooltip(fallbackTooltipPoint) + }, [fallbackTooltipPoint, props.showWinnerDetails]) + const toggleFullscreen = useCallback(async () => { if (document.fullscreenElement === mapRef.current) { await document.exitFullscreen() @@ -602,6 +664,12 @@ const WorldMap: FC = props => { options={chartOptions} ref={chartRef} /> + {!!fallbackTooltipHtml && ( +
+ )} + {canShowTopgearReprocess && (
{isOpen && portalContainer && createPortal( -
- -
, + <> + +
+ +
+ , portalContainer, )} diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.module.scss b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.module.scss new file mode 100644 index 000000000..faaf51935 --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.module.scss @@ -0,0 +1,93 @@ +@import '@libs/ui/styles/includes'; + +.badge { + display: inline-flex; + align-items: center; + color: $red-100; + cursor: pointer; + background: none; + border: none; + padding: 0; + line-height: 1; + + svg { + width: 16px; + height: 16px; + } +} + +.panel { + display: flex; + flex-direction: column; + gap: $sp-2; + padding: $sp-3 0; +} + +.panelTitle { + display: flex; + align-items: center; + gap: $sp-1; + color: $red-100; + font-weight: 700; + + svg { + width: 16px; + height: 16px; + } +} + +.panelBox { + border: 1px solid $black-20; + border-radius: 4px; + padding: $sp-3; + display: flex; + flex-direction: column; + gap: $sp-2; +} + +.duplicate { + display: flex; + flex-direction: column; + gap: 2px; +} + +.duplicateLine { + display: flex; + align-items: center; + gap: $sp-1; + flex-wrap: wrap; +} + +.bullet { + color: $black-60; +} + +.duplicateId { + color: $black-60; +} + +.crossChallenge { + display: inline-flex; + align-items: center; + gap: $sp-1; + color: $red-100; + padding-left: $sp-4; + + svg { + width: 14px; + height: 14px; + } +} + +.crossChallengeLink { + display: inline-flex; + align-items: center; + gap: 2px; + color: $red-100; + text-decoration: underline; + + svg { + width: 12px; + height: 12px; + } +} diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.spec.tsx b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.spec.tsx new file mode 100644 index 000000000..a82d96e9c --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.spec.tsx @@ -0,0 +1,197 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render, screen } from '@testing-library/react' + +import { ChallengeDetailContext } from '../../contexts/ChallengeDetailContext' +import type { ChallengeDetailContextModel, SubmissionDuplicatesMap } from '../../models' + +import { SubmissionDuplicatesBadge } from './SubmissionDuplicatesBadge' +import { SubmissionDuplicatesPanel } from './SubmissionDuplicatesPanel' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + URLS: { + CHALLENGES_PAGE: 'https://example.com/challenges', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/ui', () => { + const React = jest.requireActual('react') + + return { + IconOutline: { + ExclamationIcon: () => React.createElement('svg'), + ExternalLinkIcon: () => React.createElement('svg'), + LightningBoltIcon: () => React.createElement('svg'), + }, + Tooltip: (props: { children: React.ReactNode }) => ( + React.createElement(React.Fragment, undefined, props.children) + ), + } +}, { virtual: true }) + +const sameChallengeDuplicate = { + challenge: 'challenge-1', + challengeTitle: 'This Challenge', + isCrossChallenge: false, + submissionId: '12I.RbObnTFCVt', + submittedAt: '2026-07-13T09:35:00.000Z', + user: '2001', + userHandle: 'testmfa1', +} + +const crossChallengeDuplicate = { + challenge: 'challenge-2', + challengeTitle: 'Basketball Stats App', + isCrossChallenge: true, + submissionId: 'plkGwR_M_145', + submittedAt: '2026-07-09T11:21:00.000Z', + user: '2002', + userHandle: 'sathya22in', +} + +/** + * Renders a component inside a challenge detail context carrying duplicates. + * + * @param element component under test + * @param duplicatesBySubmissionId duplicate matches exposed through context + * @returns The testing-library render result. + */ +function renderWithDuplicates( + element: JSX.Element, + duplicatesBySubmissionId: SubmissionDuplicatesMap, +): ReturnType { + const contextValue = { + duplicatesBySubmissionId, + } as ChallengeDetailContextModel + + return render( + + {element} + , + ) +} + +describe('SubmissionDuplicatesBadge', () => { + it('renders nothing when the submission has no duplicates', () => { + renderWithDuplicates( + , + { 'submission-1': [] }, + ) + + expect(screen.queryByRole('img')) + .toBeNull() + }) + + it('renders nothing when no submission id is supplied', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate] }, + ) + + expect(screen.queryByRole('img')) + .toBeNull() + }) + + it('summarizes same-challenge duplicates', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate] }, + ) + + expect(screen.getByRole('img', { name: '1 identical submission on this challenge' })) + .toBeTruthy() + }) + + it('calls out cross-challenge duplicates', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate, crossChallengeDuplicate] }, + ) + + expect(screen.getByRole('img', { + name: '2 identical submissions, 1 on other challenges', + })) + .toBeTruthy() + }) +}) + +describe('SubmissionDuplicatesPanel', () => { + it('renders nothing when the submission has no duplicates', () => { + renderWithDuplicates( + , + {}, + ) + + expect(screen.queryByText(/Duplicates/)) + .toBeNull() + }) + + it('lists every duplicate with handle, id and date', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate, crossChallengeDuplicate] }, + ) + + expect(screen.getAllByText( + (_content, element) => element?.textContent === 'Duplicates (2)', + ).length) + .toBeGreaterThan(0) + expect(screen.getByText('testmfa1')) + .toBeTruthy() + expect(screen.getByText('(12I.RbObnTFCVt)')) + .toBeTruthy() + expect(screen.getByText('sathya22in')) + .toBeTruthy() + }) + + it('links only cross-challenge duplicates to their originating challenge', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate, crossChallengeDuplicate] }, + ) + + const links = screen.getAllByRole('link') + + expect(links) + .toHaveLength(1) + expect(links[0].getAttribute('href')) + .toBe('https://example.com/challenges/challenge-2') + expect(links[0].textContent) + .toContain('Basketball Stats App') + }) + + it('falls back to the member id when no handle resolved', () => { + renderWithDuplicates( + , + { + 'submission-1': [ + { + ...sameChallengeDuplicate, + userHandle: undefined, + }, + ], + }, + ) + + expect(screen.getByText('2001')) + .toBeTruthy() + }) + + it('renders a placeholder date when the timestamp is unusable', () => { + renderWithDuplicates( + , + { + 'submission-1': [ + { + ...sameChallengeDuplicate, + submittedAt: undefined, + }, + ], + }, + ) + + expect(screen.getByText('- --')) + .toBeTruthy() + }) +}) diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesBadge.tsx b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesBadge.tsx new file mode 100644 index 000000000..4c94686ba --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesBadge.tsx @@ -0,0 +1,72 @@ +/** + * Warning badge shown next to a submission ID when identical submissions exist. + */ +import { FC, useContext, useMemo } from 'react' + +import { IconOutline, Tooltip } from '~/libs/ui' + +import { ChallengeDetailContext } from '../../contexts/ChallengeDetailContext' +import { ChallengeDetailContextModel, SubmissionDuplicate } from '../../models' + +import styles from './SubmissionDuplicates.module.scss' + +interface SubmissionDuplicatesBadgeProps { + submissionId?: string +} + +/** + * Builds the tooltip summary for a set of duplicate matches. + * @param duplicates Duplicate matches for the submission. + * @returns Count summary, calling out cross-challenge matches when present. + */ +function getTooltipContent(duplicates: SubmissionDuplicate[]): string { + const countLabel = `${duplicates.length} identical submission${duplicates.length === 1 ? '' : 's'}` + const crossChallengeCount = duplicates.filter(duplicate => duplicate.isCrossChallenge).length + + if (!crossChallengeCount) { + return `${countLabel} on this challenge` + } + + if (crossChallengeCount === duplicates.length) { + return `${countLabel} on other challenges` + } + + return `${countLabel}, ${crossChallengeCount} on other challenges` +} + +/** + * Renders the duplicate-submission warning icon, or nothing when the submission + * has no known duplicates. + * + * Duplicate data comes from `ChallengeDetailContext`, so the badge can be + * dropped into any table cell without threading props through the renderer. + */ +export const SubmissionDuplicatesBadge: FC = props => { + const { duplicatesBySubmissionId }: ChallengeDetailContextModel + = useContext(ChallengeDetailContext) + + const duplicates = useMemo( + () => (props.submissionId + ? duplicatesBySubmissionId[props.submissionId] ?? [] + : []), + [duplicatesBySubmissionId, props.submissionId], + ) + + if (!duplicates.length) { + return <> + } + + return ( + + + + + ) +} + +export default SubmissionDuplicatesBadge diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesPanel.tsx b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesPanel.tsx new file mode 100644 index 000000000..12924db2b --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesPanel.tsx @@ -0,0 +1,135 @@ +/** + * Duplicate submission list rendered above the AI reviewers table. + */ +import { FC, useContext, useMemo } from 'react' +import moment from 'moment' + +import { EnvironmentConfig } from '~/config' +import { IconOutline } from '~/libs/ui' + +import { ChallengeDetailContext } from '../../contexts/ChallengeDetailContext' +import { ChallengeDetailContextModel, SubmissionDuplicate } from '../../models' +import { TABLE_DATE_FORMAT } from '../../constants' + +import styles from './SubmissionDuplicates.module.scss' + +interface SubmissionDuplicatesPanelProps { + submissionId?: string +} + +interface DuplicateEntryProps { + duplicate: SubmissionDuplicate +} + +/** + * Formats a duplicate's submission timestamp for display. + * @param submittedAt ISO timestamp reported by the duplicates endpoint. + * @returns Formatted date, or an em dash when the timestamp is missing or invalid. + */ +function formatSubmittedAt(submittedAt?: string): string { + if (!submittedAt) { + return '--' + } + + const parsed = moment(submittedAt) + + return parsed.isValid() + ? parsed.format(TABLE_DATE_FORMAT) + : '--' +} + +/** + * Renders a single duplicate entry, adding the originating challenge link when + * the match comes from a different challenge. + * @param duplicate Duplicate match to render. + * @returns The duplicate list item. + */ +const DuplicateEntry: FC = (props: DuplicateEntryProps) => { + const duplicate: SubmissionDuplicate = props.duplicate + const challengeUrl = duplicate.challenge + ? `${EnvironmentConfig.URLS.CHALLENGES_PAGE}/${duplicate.challenge}` + : undefined + + return ( +
+
+ + {duplicate.userHandle || duplicate.user || 'Unknown member'} + + ( + {duplicate.submissionId} + ) + + + - + {' '} + {formatSubmittedAt(duplicate.submittedAt)} + +
+ + {duplicate.isCrossChallenge && ( +
+
+ )} +
+ ) +} + +/** + * Renders the duplicates block for a submission, or nothing when the submission + * has no known duplicates. + * + * Duplicate data comes from `ChallengeDetailContext` so the panel can be dropped + * into any expandable submission row. + */ +export const SubmissionDuplicatesPanel: FC = props => { + const { duplicatesBySubmissionId }: ChallengeDetailContextModel + = useContext(ChallengeDetailContext) + + const duplicates = useMemo( + () => (props.submissionId + ? duplicatesBySubmissionId[props.submissionId] ?? [] + : []), + [duplicatesBySubmissionId, props.submissionId], + ) + + if (!duplicates.length) { + return <> + } + + return ( +
+
+
+ +
+ {duplicates.map(duplicate => ( + + ))} +
+
+ ) +} + +export default SubmissionDuplicatesPanel diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/index.ts b/src/apps/review/src/lib/components/SubmissionDuplicates/index.ts new file mode 100644 index 000000000..27efbfa34 --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/index.ts @@ -0,0 +1,2 @@ +export { default as SubmissionDuplicatesBadge } from './SubmissionDuplicatesBadge' +export { default as SubmissionDuplicatesPanel } from './SubmissionDuplicatesPanel' diff --git a/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx b/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx index db8de2d8d..830f7bf46 100644 --- a/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx +++ b/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx @@ -46,6 +46,7 @@ import { ConfirmModal } from '../ConfirmModal' import { useRolePermissions, UseRolePermissionsResult, useSubmissionDownloadAccess } from '../../hooks' import type { UseSubmissionDownloadAccessResult } from '../../hooks/useSubmissionDownloadAccess' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { SubmissionDuplicatesBadge } from '../SubmissionDuplicates/SubmissionDuplicatesBadge' import { SUBMISSION_DOWNLOAD_RESTRICTION_MESSAGE } from '../../constants' import styles from './TableCheckpointSubmissions.module.scss' @@ -329,6 +330,7 @@ export const TableCheckpointSubmissions: FC = (props: Props) => { > +
) }, diff --git a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx index e0cadd634..24f31d5b7 100644 --- a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx +++ b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx @@ -54,6 +54,7 @@ import { resolveSubmissionReviewResult } from '../common/reviewResult' import { ProgressBar } from '../ProgressBar' import { TableWrapper } from '../TableWrapper' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { SubmissionDuplicatesBadge } from '../SubmissionDuplicates/SubmissionDuplicatesBadge' import { EscalationModals } from '../TableReview/EscalationModals' import { SUBMISSION_DOWNLOAD_RESTRICTION_MESSAGE } from '../../constants' @@ -895,6 +896,7 @@ export const TableIterativeReview: FC = (props: Props) => { > +
) }, diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx index a06faab00..40af73fe0 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx +++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx @@ -83,7 +83,10 @@ import { isSubmissionReviewerActionRow, resolveSubmissionReviewResult, } from '../common/reviewResult' -import { shouldIncludeInReviewPhase } from '../../utils/reviewPhaseGuards' +import { + isAiFailedReviewSubmission, + shouldIncludeInReviewPhase, +} from '../../utils/reviewPhaseGuards' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' import { EscalationModals } from './EscalationModals' @@ -149,9 +152,11 @@ export const TableReview: FC = (props: TableReviewProps) => { const isTablet = useMemo(() => screenWidth <= 744, [screenWidth]) const reviewPhaseDatas = useMemo( - () => datas.filter(submission => shouldIncludeInReviewPhase( - submission, - challengeInfo?.phases, + () => datas.filter(submission => ( + // AI-locked submissions may carry no Review-phase review yet, but reviewers and + // copilots still need the row to escalate, verify, or unlock them. + isAiFailedReviewSubmission(submission) + || shouldIncludeInReviewPhase(submission, challengeInfo?.phases) )), [challengeInfo?.phases, datas], ) @@ -278,7 +283,7 @@ export const TableReview: FC = (props: TableReviewProps) => { return true } - return (submission.status ?? '').toUpperCase() === 'AI_FAILED_REVIEW' + return isAiFailedReviewSubmission(submission) }, ), [props.screeningOutcome.failingSubmissionIds], @@ -370,7 +375,7 @@ export const TableReview: FC = (props: TableReviewProps) => { submission: SubmissionReviewerRow, decision?: AiReviewEscalationDecision, ): boolean => { - if (submission.status !== 'AI_FAILED_REVIEW') { + if (!isAiFailedReviewSubmission(submission)) { return false } @@ -839,16 +844,15 @@ export const TableReview: FC = (props: TableReviewProps) => { ) } - appendAction(buildPrimaryAction(), 'primary') if (submission.isFirstReviewerRow) { + appendAction(buildPrimaryAction(), 'primary') appendAction(buildEscalateAction(), 'escalate') appendAction(buildVerifyAction(), 'verify') appendAction(buildUnlockAction(), 'unlock') appendAction(buildHistoryAction(), 'history') + appendAction(buildReopenAction(), 'reopen') } - appendAction(buildReopenAction(), 'reopen') - if (!actionEntries.length) { return ( diff --git a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx index 512ce4dee..5cacef591 100644 --- a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx +++ b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx @@ -57,6 +57,7 @@ import { useRole, useRolePermissions, UseRolePermissionsResult, useSubmissionDow import type { UseSubmissionDownloadAccessResult } from '../../hooks/useSubmissionDownloadAccess' import type { useRoleProps } from '../../hooks/useRole' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { SubmissionDuplicatesBadge } from '../SubmissionDuplicates/SubmissionDuplicatesBadge' import styles from './TableSubmissionScreening.module.scss' @@ -208,6 +209,7 @@ const createSubmissionColumn = (config: SubmissionColumnConfig): TableColumn + ) }, diff --git a/src/apps/review/src/lib/components/TableWinners/TableWinners.tsx b/src/apps/review/src/lib/components/TableWinners/TableWinners.tsx index 445c75005..5b34e7491 100644 --- a/src/apps/review/src/lib/components/TableWinners/TableWinners.tsx +++ b/src/apps/review/src/lib/components/TableWinners/TableWinners.tsx @@ -24,6 +24,7 @@ import type { PhaseOrderingOptions } from '../../utils' import { useSubmissionDownloadAccess } from '../../hooks' import type { UseSubmissionDownloadAccessResult } from '../../hooks/useSubmissionDownloadAccess' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { SubmissionDuplicatesBadge } from '../SubmissionDuplicates/SubmissionDuplicatesBadge' import styles from './TableWinners.module.scss' @@ -170,6 +171,7 @@ export const TableWinners: FC = (props: Props) => { ) : undefined} {renderedDownloadButton} + - + ) } diff --git a/src/apps/review/src/lib/components/index.ts b/src/apps/review/src/lib/components/index.ts index 8d4cfa287..d13c04229 100644 --- a/src/apps/review/src/lib/components/index.ts +++ b/src/apps/review/src/lib/components/index.ts @@ -22,4 +22,5 @@ export * from './ChallengeTimeline' export * from './ConfirmModal' export * from './ScorecardsFilter' export * from './TableScorecards' +export * from './SubmissionDuplicates' export * from './SubmissionHistoryModal' diff --git a/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts b/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts index 7560bb931..f58c2d410 100644 --- a/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts +++ b/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts @@ -15,12 +15,14 @@ export const ChallengeDetailContext: Context challengeScopedFetchError: undefined, challengeSubmissions: [], challengeSubmissionsError: undefined, + duplicatesBySubmissionId: {}, hasChallengeScopedFetchError: false, isLoadingAiReviewConfig: false, isLoadingAiReviewDecisions: false, isLoadingChallengeInfo: false, isLoadingChallengeResources: false, isLoadingChallengeSubmissions: false, + isLoadingSubmissionDuplicates: false, myResources: [], myRoles: [], registrants: [], diff --git a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx index cf662918f..42b553c73 100644 --- a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx +++ b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx @@ -21,8 +21,11 @@ import { useFetchChallengeResourcesProps, useFetchChallengeSubmissions, useFetchChallengeSubmissionsProps, + useFetchSubmissionDuplicates, + UseFetchSubmissionDuplicatesResult, } from '../hooks' import type { ChallengeVisibilityFlags } from '../hooks/useFetchChallengeSubmissions' +import { canViewSubmissionDuplicates } from '../utils' import { ChallengeDetailContext } from './ChallengeDetailContext' import { ReviewAppContext } from './ReviewAppContext' @@ -123,6 +126,27 @@ export const ChallengeDetailContextProvider: FC = props => { [aiReviewDecisions], ) + // Duplicate detection is queried for every visible submission at once so any + // tab can decorate its rows straight from context. + const duplicateCheckSubmissionIds = useMemo( + () => challengeSubmissions + .map(submission => `${submission.id ?? ''}`.trim()) + .filter(Boolean), + [challengeSubmissions], + ) + const canQueryDuplicates = useMemo( + () => canViewSubmissionDuplicates(myRoles, loginUserInfo?.roles), + [loginUserInfo?.roles, myRoles], + ) + const { + duplicatesBySubmissionId, + isLoading: isLoadingSubmissionDuplicates, + }: UseFetchSubmissionDuplicatesResult = useFetchSubmissionDuplicates( + challengeId, + duplicateCheckSubmissionIds, + canQueryDuplicates, + ) + const enrichedChallengeInfo = useMemo( () => (challengeInfo ? { @@ -165,12 +189,14 @@ export const ChallengeDetailContextProvider: FC = props => { challengeScopedFetchError, challengeSubmissions, challengeSubmissionsError, + duplicatesBySubmissionId, hasChallengeScopedFetchError: !!challengeScopedFetchError, isLoadingAiReviewConfig, isLoadingAiReviewDecisions, isLoadingChallengeInfo: isLoadingChallengeInfoCombined, isLoadingChallengeResources, isLoadingChallengeSubmissions, + isLoadingSubmissionDuplicates, myResources, myRoles, registrants, @@ -187,9 +213,11 @@ export const ChallengeDetailContextProvider: FC = props => { challengeScopedFetchError, challengeSubmissions, challengeSubmissionsError, + duplicatesBySubmissionId, isLoadingChallengeInfoCombined, isLoadingChallengeResources, isLoadingChallengeSubmissions, + isLoadingSubmissionDuplicates, aiReviewConfig, aiReviewDecisionsBySubmissionId, isLoadingAiReviewConfig, diff --git a/src/apps/review/src/lib/hooks/index.ts b/src/apps/review/src/lib/hooks/index.ts index 89595ac76..ba08224ed 100644 --- a/src/apps/review/src/lib/hooks/index.ts +++ b/src/apps/review/src/lib/hooks/index.ts @@ -23,3 +23,4 @@ export * from './useFetchAiReviewData' export * from './useFetchSubmissionInfo' export * from './useReviewEditAccess' export * from './useFetchAiReviewEscalations' +export * from './useFetchSubmissionDuplicates' diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx b/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx index 1c1ed4e31..ff9f8e2d5 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx @@ -118,12 +118,14 @@ const buildContextValue = ( challengeScopedFetchError: undefined, challengeSubmissions: [], challengeSubmissionsError: undefined, + duplicatesBySubmissionId: {}, hasChallengeScopedFetchError: false, isLoadingAiReviewConfig: false, isLoadingAiReviewDecisions: false, isLoadingChallengeInfo: false, isLoadingChallengeResources: false, isLoadingChallengeSubmissions: false, + isLoadingSubmissionDuplicates: false, myResources: [], myRoles: [], registrants: [], diff --git a/src/apps/review/src/lib/hooks/useFetchSubmissionDuplicates.ts b/src/apps/review/src/lib/hooks/useFetchSubmissionDuplicates.ts new file mode 100644 index 000000000..7dc7682cc --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchSubmissionDuplicates.ts @@ -0,0 +1,129 @@ +import { useMemo } from 'react' +import useSWR, { SWRResponse } from 'swr' + +import { SubmissionDuplicate, SubmissionDuplicatesMap } from '../models' +import { + fetchMemberHandles, + fetchSubmissionDuplicates, + getSubmissionDuplicatesCacheKey, +} from '../services' + +export interface UseFetchSubmissionDuplicatesResult { + duplicatesBySubmissionId: SubmissionDuplicatesMap + isLoading: boolean +} + +const EMPTY_DUPLICATES: SubmissionDuplicatesMap = {} + +/** + * Resolves member handles for the members behind the duplicate submissions. + * @param duplicatesBySubmissionId Duplicate matches keyed by checked submission id. + * @returns The same map with `userHandle` filled in wherever a handle resolved. + */ +async function withMemberHandles( + duplicatesBySubmissionId: SubmissionDuplicatesMap, +): Promise { + const memberIds = Array.from(new Set( + Object.values(duplicatesBySubmissionId) + .flat() + .map(duplicate => duplicate.user) + .filter((memberId): memberId is string => !!memberId && /^\d+$/.test(memberId)), + )) + + if (!memberIds.length) { + return duplicatesBySubmissionId + } + + let handlesByMemberId: Map + try { + handlesByMemberId = await fetchMemberHandles(memberIds) + } catch { + return duplicatesBySubmissionId + } + + return Object.entries(duplicatesBySubmissionId) + .reduce((result, [submissionId, duplicates]) => { + result[submissionId] = duplicates.map((duplicate: SubmissionDuplicate) => { + const handle = duplicate.user + ? handlesByMemberId.get(Number(duplicate.user)) + : undefined + + return handle + ? { + ...duplicate, + userHandle: handle, + } + : duplicate + }) + + return result + }, {}) +} + +/** + * Fetches SHA-256 duplicate matches for a challenge's submissions. + * + * Duplicate detection is an operator-only endpoint, so the caller must gate the + * request with `enabled`. Failures resolve to an empty map instead of surfacing + * a toast, because duplicate badges are supplementary to every table they + * decorate. + * + * @param challengeId Challenge that owns the submissions being checked. + * @param submissionIds Submission ids to check for duplicates. + * @param enabled Whether the caller is allowed to query duplicates. + * @returns Duplicate matches keyed by submission id plus the loading flag. + */ +export function useFetchSubmissionDuplicates( + challengeId?: string, + submissionIds: string[] = [], + enabled: boolean = true, +): UseFetchSubmissionDuplicatesResult { + const normalizedSubmissionIds = useMemo( + () => Array.from(new Set( + submissionIds + .map(submissionId => `${submissionId ?? ''}`.trim()) + .filter(Boolean), + )), + [submissionIds], + ) + + const cacheKey = enabled + ? getSubmissionDuplicatesCacheKey(challengeId, normalizedSubmissionIds, true) + : undefined + + const { + data: duplicatesBySubmissionId = EMPTY_DUPLICATES, + isValidating: isLoading, + }: SWRResponse = useSWR( + cacheKey, + { + fetcher: async (): Promise => { + if (!challengeId || !normalizedSubmissionIds.length) { + return EMPTY_DUPLICATES + } + + try { + const duplicates = await fetchSubmissionDuplicates( + challengeId, + normalizedSubmissionIds, + true, + ) + + return withMemberHandles(duplicates) + } catch { + // Duplicate detection is optional context; a denied or failed + // lookup must not break the tables it decorates. + return EMPTY_DUPLICATES + } + }, + isPaused: () => !cacheKey, + revalidateOnFocus: false, + shouldRetryOnError: false, + }, + ) + + return { + duplicatesBySubmissionId, + isLoading, + } +} diff --git a/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts index 0208d4549..973f0b1eb 100644 --- a/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts +++ b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts @@ -2,6 +2,7 @@ import { BackendResource } from './BackendResource.model' import { BackendSubmission } from './BackendSubmission.model' import { ChallengeInfo } from './ChallengeInfo.model' import { AiReviewConfig, AiReviewDecision } from './AiReview.model' +import { SubmissionDuplicatesMap } from './SubmissionDuplicate.model' /** * Model for challenge detail context @@ -28,6 +29,9 @@ export interface ChallengeDetailContextModel { aiReviewDecisionsBySubmissionId: Record isLoadingAiReviewConfig: boolean isLoadingAiReviewDecisions: boolean + /** SHA-256 duplicate matches keyed by submission id; empty when not permitted. */ + duplicatesBySubmissionId: SubmissionDuplicatesMap + isLoadingSubmissionDuplicates: boolean resourceMemberIdMapping: { [memberId: string]: BackendResource } diff --git a/src/apps/review/src/lib/models/SubmissionDuplicate.model.ts b/src/apps/review/src/lib/models/SubmissionDuplicate.model.ts new file mode 100644 index 000000000..ed7dd05a1 --- /dev/null +++ b/src/apps/review/src/lib/models/SubmissionDuplicate.model.ts @@ -0,0 +1,26 @@ +/** + * Models for the SHA-256 duplicate submission detection endpoint. + */ + +/** + * A submission sharing the exact SHA-256 digest of the checked submission. + */ +export interface SubmissionDuplicate { + /** Challenge that owns the duplicate submission. */ + challenge?: string + /** Challenge name, when the API could resolve it. */ + challengeTitle?: string + /** True when the duplicate lives on a different challenge. */ + isCrossChallenge: boolean + /** ID of the duplicate submission. */ + submissionId: string + /** ISO timestamp the duplicate was submitted. */ + submittedAt?: string + /** Member ID that created the duplicate submission. */ + user?: string + /** Member handle resolved from `user`, when available. */ + userHandle?: string +} + +/** Duplicate matches keyed by the checked submission ID. */ +export type SubmissionDuplicatesMap = Record diff --git a/src/apps/review/src/lib/models/index.ts b/src/apps/review/src/lib/models/index.ts index ea54c5535..827f7edcf 100644 --- a/src/apps/review/src/lib/models/index.ts +++ b/src/apps/review/src/lib/models/index.ts @@ -44,6 +44,7 @@ export * from './ChallengeDetailContextModel.model' export * from './FormContactManager.model' export * from './BackendContactRequest.model' export * from './BackendSubmission.model' +export * from './SubmissionDuplicate.model' export * from './BackendReview.model' export * from './BackendMeta.model' export * from './BackendResponseWithMeta.model' diff --git a/src/apps/review/src/lib/services/index.ts b/src/apps/review/src/lib/services/index.ts index 2c3fba054..5c85478bd 100644 --- a/src/apps/review/src/lib/services/index.ts +++ b/src/apps/review/src/lib/services/index.ts @@ -8,3 +8,4 @@ export * from './challenge-phases.service' export * from './aiReviewEscalation.service' export * from './aiReview.service' export * from './submission-reprocess.service' +export * from './submission-duplicates.service' diff --git a/src/apps/review/src/lib/services/submission-duplicates.service.spec.ts b/src/apps/review/src/lib/services/submission-duplicates.service.spec.ts new file mode 100644 index 000000000..657767704 --- /dev/null +++ b/src/apps/review/src/lib/services/submission-duplicates.service.spec.ts @@ -0,0 +1,181 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { xhrGetAsync } from '~/libs/core' + +import { + chunkSubmissionIds, + fetchSubmissionDuplicates, + getSubmissionDuplicatesCacheKey, +} from './submission-duplicates.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { + V6: 'https://api.test/v6', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + xhrGetAsync: jest.fn(), +}), { virtual: true }) + +const xhrGetAsyncMock = xhrGetAsync as jest.MockedFunction + +describe('submission-duplicates.service', () => { + beforeEach(() => { + xhrGetAsyncMock.mockReset() + }) + + describe('chunkSubmissionIds', () => { + it('splits ids into chunks of at most 100', () => { + const ids = Array.from({ length: 205 }, (_, index) => `submission-${index}`) + + expect(chunkSubmissionIds(ids) + .map(chunk => chunk.length)) + .toEqual([100, 100, 5]) + }) + }) + + describe('getSubmissionDuplicatesCacheKey', () => { + it('is stable regardless of submission id order', () => { + expect(getSubmissionDuplicatesCacheKey('challenge-1', ['b', 'a'], true)) + .toBe(getSubmissionDuplicatesCacheKey('challenge-1', ['a', 'b'], true)) + }) + + it('varies with the cross-challenge flag', () => { + expect(getSubmissionDuplicatesCacheKey('challenge-1', ['a'], true)) + .not + .toBe(getSubmissionDuplicatesCacheKey('challenge-1', ['a'], false)) + }) + + it('is undefined without a challenge or submission ids', () => { + expect(getSubmissionDuplicatesCacheKey(undefined, ['a'])) + .toBeUndefined() + expect(getSubmissionDuplicatesCacheKey('challenge-1', [])) + .toBeUndefined() + }) + }) + + describe('fetchSubmissionDuplicates', () => { + it('requests every submission id and flags cross-challenge matches', async () => { + xhrGetAsyncMock.mockResolvedValue({ + 'submission-1': { + duplicates: [ + { + challenge: 'challenge-1', + challengeTitle: 'This Challenge', + submissionId: 'submission-2', + submittedAt: '2026-07-13T09:35:00.000Z', + user: 2001, + }, + { + challenge: 'challenge-9', + challengeTitle: 'Other Challenge', + submissionId: 'submission-9', + submittedAt: '2026-07-09T11:21:00.000Z', + user: '2002', + }, + ], + }, + } as never) + + const result = await fetchSubmissionDuplicates( + 'challenge-1', + ['submission-1'], + true, + ) + + expect(xhrGetAsyncMock) + .toHaveBeenCalledWith( + 'https://api.test/v6/submissions/challenge-1/duplicates' + + '?submissionId=submission-1&crossChallenge=true', + ) + expect(result['submission-1']) + .toEqual([ + { + challenge: 'challenge-1', + challengeTitle: 'This Challenge', + isCrossChallenge: false, + submissionId: 'submission-2', + submittedAt: '2026-07-13T09:35:00.000Z', + user: '2001', + }, + { + challenge: 'challenge-9', + challengeTitle: 'Other Challenge', + isCrossChallenge: true, + submissionId: 'submission-9', + submittedAt: '2026-07-09T11:21:00.000Z', + user: '2002', + }, + ]) + }) + + it('omits the cross-challenge flag for same-challenge lookups', async () => { + xhrGetAsyncMock.mockResolvedValue({} as never) + + await fetchSubmissionDuplicates('challenge-1', ['submission-1']) + + expect(xhrGetAsyncMock) + .toHaveBeenCalledWith( + 'https://api.test/v6/submissions/challenge-1/duplicates?submissionId=submission-1', + ) + }) + + it('chunks large id lists into separate requests and merges the results', async () => { + const ids = Array.from({ length: 101 }, (_, index) => `submission-${index}`) + xhrGetAsyncMock.mockImplementation(async url => ( + `${url}`.includes('submission-100') + ? { 'submission-100': { duplicates: [{ submissionId: 'dup-b' }] } } as never + : { 'submission-0': { duplicates: [{ submissionId: 'dup-a' }] } } as never + )) + + const result = await fetchSubmissionDuplicates('challenge-1', ids, true) + + expect(xhrGetAsyncMock) + .toHaveBeenCalledTimes(2) + expect(result['submission-0']?.[0].submissionId) + .toBe('dup-a') + expect(result['submission-100']?.[0].submissionId) + .toBe('dup-b') + }) + + it('deduplicates and trims requested ids', async () => { + xhrGetAsyncMock.mockResolvedValue({} as never) + + await fetchSubmissionDuplicates( + 'challenge-1', + [' submission-1 ', 'submission-1', ''], + ) + + expect(xhrGetAsyncMock) + .toHaveBeenCalledWith( + 'https://api.test/v6/submissions/challenge-1/duplicates?submissionId=submission-1', + ) + }) + + it('skips the request when there is nothing to check', async () => { + expect(await fetchSubmissionDuplicates('challenge-1', [])) + .toEqual({}) + expect(await fetchSubmissionDuplicates('', ['submission-1'])) + .toEqual({}) + expect(xhrGetAsyncMock) + .not + .toHaveBeenCalled() + }) + + it('tolerates malformed duplicate payloads', async () => { + xhrGetAsyncMock.mockResolvedValue({ + 'submission-1': { duplicates: 'nope' }, + 'submission-2': { duplicates: [undefined, {}, { submissionId: 'dup-a' }] }, + } as never) + + const result = await fetchSubmissionDuplicates('challenge-1', ['submission-1']) + + expect(result['submission-1']) + .toEqual([]) + expect(result['submission-2']) + .toHaveLength(1) + }) + }) +}) diff --git a/src/apps/review/src/lib/services/submission-duplicates.service.ts b/src/apps/review/src/lib/services/submission-duplicates.service.ts new file mode 100644 index 000000000..95f1248e3 --- /dev/null +++ b/src/apps/review/src/lib/services/submission-duplicates.service.ts @@ -0,0 +1,159 @@ +/** + * Service for the SHA-256 duplicate submission detection endpoint. + */ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import { SubmissionDuplicate, SubmissionDuplicatesMap } from '../models' + +const v6BaseUrl = `${EnvironmentConfig.API.V6}` + +/** The API rejects requests carrying more submission ids than this. */ +export const DUPLICATES_REQUEST_CHUNK_SIZE = 100 + +interface DuplicateGroupResponse { + duplicates?: unknown +} + +type DuplicatesResponse = Record + +function toOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalizedValue = String(value) + .trim() + + return normalizedValue || undefined +} + +/** + * Splits submission ids into request-sized chunks. + * @param submissionIds Unique submission ids to check. + * @returns Chunks no larger than the API's per-request id limit. + */ +export function chunkSubmissionIds(submissionIds: string[]): string[][] { + const chunks: string[][] = [] + + for (let index = 0; index < submissionIds.length; index += DUPLICATES_REQUEST_CHUNK_SIZE) { + chunks.push(submissionIds.slice(index, index + DUPLICATES_REQUEST_CHUNK_SIZE)) + } + + return chunks +} + +/** + * Normalizes one duplicate entry returned by the API. + * @param value Raw duplicate entry. + * @param challengeId Challenge the checked submission belongs to. + * @returns Normalized duplicate, or `undefined` when the entry has no submission id. + */ +function toSubmissionDuplicate( + value: unknown, + challengeId: string, +): SubmissionDuplicate | undefined { + if (typeof value !== 'object' || !value) { + return undefined + } + + const entry = value as Record + const submissionId = toOptionalString(entry.submissionId) + + if (!submissionId) { + return undefined + } + + const challenge = toOptionalString(entry.challenge) + + return { + challenge, + challengeTitle: toOptionalString(entry.challengeTitle), + isCrossChallenge: !!challenge && challenge !== challengeId, + submissionId, + submittedAt: toOptionalString(entry.submittedAt), + user: toOptionalString(entry.user), + } +} + +/** + * Builds the cache key for a duplicate-detection request. + * @param challengeId Challenge that owns the checked submissions. + * @param submissionIds Submission ids being checked. + * @param crossChallenge Whether other challenges are searched too. + * @returns Stable SWR cache key, or `undefined` when there is nothing to fetch. + */ +export function getSubmissionDuplicatesCacheKey( + challengeId?: string, + submissionIds: string[] = [], + crossChallenge: boolean = false, +): string | undefined { + if (!challengeId || !submissionIds.length) { + return undefined + } + + return [ + `${v6BaseUrl}/submissions/${challengeId}/duplicates`, + `crossChallenge=${crossChallenge}`, + [...submissionIds].sort() + .join(','), + ].join('|') +} + +/** + * Fetches submissions sharing a SHA-256 digest with the supplied submissions. + * @param challengeId Challenge that owns every checked submission. + * @param submissionIds Submission ids to check; chunked to respect the API limit. + * @param crossChallenge When true, duplicates from other challenges are included. + * @returns Duplicate matches keyed by checked submission id. + */ +export async function fetchSubmissionDuplicates( + challengeId: string, + submissionIds: string[], + crossChallenge: boolean = false, +): Promise { + const normalizedChallengeId = challengeId.trim() + const uniqueSubmissionIds = Array.from(new Set( + submissionIds + .map(submissionId => toOptionalString(submissionId)) + .filter((submissionId): submissionId is string => !!submissionId), + )) + + if (!normalizedChallengeId || !uniqueSubmissionIds.length) { + return {} + } + + const responses = await Promise.all( + chunkSubmissionIds(uniqueSubmissionIds) + .map(async chunk => { + const query = new URLSearchParams() + chunk.forEach(submissionId => { + query.append('submissionId', submissionId) + }) + + if (crossChallenge) { + query.set('crossChallenge', 'true') + } + + return xhrGetAsync( + `${v6BaseUrl}/submissions/${normalizedChallengeId}/duplicates?${query.toString()}`, + ) + }), + ) + + return responses.reduce((result, response) => { + Object.entries(response ?? {}) + .forEach(([submissionId, group]) => { + const rawDuplicates: unknown = group?.duplicates + const duplicates: unknown[] = Array.isArray(rawDuplicates) + ? rawDuplicates + : [] + + result[submissionId] = duplicates + .map(duplicate => toSubmissionDuplicate(duplicate, normalizedChallengeId)) + .filter((duplicate): duplicate is SubmissionDuplicate => !!duplicate) + }) + + return result + }, {}) +} diff --git a/src/apps/review/src/lib/utils/index.ts b/src/apps/review/src/lib/utils/index.ts index 2f86d2098..37256688b 100644 --- a/src/apps/review/src/lib/utils/index.ts +++ b/src/apps/review/src/lib/utils/index.ts @@ -23,3 +23,4 @@ export * from './metadataMatching' export * from './reviewMatching' export * from './reviewBuilding' export * from './submissionOwnership' +export * from './submissionDuplicates' diff --git a/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts b/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts index 6071363ec..fcb0983bf 100644 --- a/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts +++ b/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts @@ -1,6 +1,10 @@ import type { BackendPhase, SubmissionInfo } from '../models' -import { isContestReviewPhaseSubmission } from './reviewPhaseGuards' +import { + isAiFailedReviewSubmission, + isContestReviewPhaseSubmission, + shouldIncludeInReviewPhase, +} from './reviewPhaseGuards' const reviewPhase: BackendPhase = { constraints: [], @@ -93,3 +97,34 @@ describe('isContestReviewPhaseSubmission', () => { .toBe(false) }) }) + +describe('isAiFailedReviewSubmission', () => { + it('detects AI-locked submissions regardless of status casing', () => { + expect(isAiFailedReviewSubmission({ status: 'AI_FAILED_REVIEW' } as SubmissionInfo)) + .toBe(true) + expect(isAiFailedReviewSubmission({ status: 'ai_failed_review' } as SubmissionInfo)) + .toBe(true) + }) + + it('ignores other submission statuses', () => { + expect(isAiFailedReviewSubmission({ status: 'ACTIVE' } as SubmissionInfo)) + .toBe(false) + expect(isAiFailedReviewSubmission(undefined)) + .toBe(false) + }) + + it('keeps AI-failed submissions visible even when the phase guard excludes them', () => { + const aiFailedSubmission = { + id: 'submission-ai-failed', + memberId: '1001', + status: 'AI_FAILED_REVIEW', + type: 'Contest Submission', + } as SubmissionInfo + + // No review-phase hints, so the phase guard alone would drop the row. + expect(shouldIncludeInReviewPhase(aiFailedSubmission, [reviewPhase])) + .toBe(false) + expect(isAiFailedReviewSubmission(aiFailedSubmission)) + .toBe(true) + }) +}) diff --git a/src/apps/review/src/lib/utils/reviewPhaseGuards.ts b/src/apps/review/src/lib/utils/reviewPhaseGuards.ts index 0e93dcf14..e027dd3fc 100644 --- a/src/apps/review/src/lib/utils/reviewPhaseGuards.ts +++ b/src/apps/review/src/lib/utils/reviewPhaseGuards.ts @@ -159,6 +159,19 @@ export const isContestReviewPhaseSubmission = ( return normalizedCandidates.has(normalizeReviewPhaseKey(targetPhaseName)) } +/** + * Detects submissions the AI reviewer failed and locked. + * + * @param submission - Submission candidate. + * @returns True when the submission status marks an AI review failure. + * @throws This helper does not throw. + * Such submissions must stay visible on the Review tab so reviewers and copilots + * can escalate, verify, or unlock them even without a Review-phase review record. + */ +export const isAiFailedReviewSubmission = (submission?: SubmissionInfo): boolean => ( + (submission?.status ?? '').toUpperCase() === 'AI_FAILED_REVIEW' +) + export const shouldIncludeInReviewPhase = ( submission?: SubmissionInfo, phases?: BackendPhase[], diff --git a/src/apps/review/src/lib/utils/submissionDuplicates.spec.ts b/src/apps/review/src/lib/utils/submissionDuplicates.spec.ts new file mode 100644 index 000000000..33b3d156e --- /dev/null +++ b/src/apps/review/src/lib/utils/submissionDuplicates.spec.ts @@ -0,0 +1,39 @@ +import { canViewSubmissionDuplicates } from './submissionDuplicates' + +describe('canViewSubmissionDuplicates', () => { + it('allows administrators from the token roles', () => { + expect(canViewSubmissionDuplicates([], ['Topcoder User', 'administrator'])) + .toBe(true) + }) + + it('allows project managers from the token roles', () => { + expect(canViewSubmissionDuplicates([], ['Project Manager'])) + .toBe(true) + }) + + it.each([ + 'Copilot', + 'Manager', + 'Reviewer', + 'Iterative Reviewer', + 'Checkpoint Screener', + ])('allows the %s challenge resource role', challengeRole => { + expect(canViewSubmissionDuplicates([challengeRole], ['Topcoder User'])) + .toBe(true) + }) + + it('denies submitters', () => { + expect(canViewSubmissionDuplicates(['Submitter'], ['Topcoder User'])) + .toBe(false) + }) + + it('denies observers and approvers, which the API does not accept', () => { + expect(canViewSubmissionDuplicates(['Observer', 'Approver'], ['Topcoder User'])) + .toBe(false) + }) + + it('denies anonymous callers', () => { + expect(canViewSubmissionDuplicates(undefined, undefined)) + .toBe(false) + }) +}) diff --git a/src/apps/review/src/lib/utils/submissionDuplicates.ts b/src/apps/review/src/lib/utils/submissionDuplicates.ts new file mode 100644 index 000000000..54ded4d81 --- /dev/null +++ b/src/apps/review/src/lib/utils/submissionDuplicates.ts @@ -0,0 +1,54 @@ +/** + * Access rules for the operator-only submission duplicate detection endpoint. + */ + +/** + * Challenge resource role fragments the duplicates endpoint accepts. + * Mirrors the review API's `DUPLICATE_DETECTION_RESOURCE_ROLE_FRAGMENTS`. + */ +const DUPLICATE_CHALLENGE_ROLE_FRAGMENTS = [ + 'copilot', + 'manager', + 'reviewer', + 'screener', +] + +/** Token roles the duplicates endpoint accepts without a challenge resource. */ +const DUPLICATE_TOKEN_ROLES = [ + 'administrator', + 'project manager', +] + +function normalizeRoles(roles: Array | undefined): string[] { + return (roles ?? []) + .map(role => `${role ?? ''}`.trim() + .toLowerCase()) + .filter(Boolean) +} + +/** + * Determines whether the current user may query submission duplicates. + * + * The endpoint answers only for admins, PMs, and challenge + * Reviewer/Screener/Copilot/Manager resources, so the UI must not call it for + * anyone else — a submitter would only collect a 403. + * + * @param challengeRoles Resource role names the user holds on the challenge. + * @param tokenRoles Roles carried by the auth token. + * @returns True when the duplicates endpoint will answer for this user. + */ +export function canViewSubmissionDuplicates( + challengeRoles: string[] | undefined, + tokenRoles: Array | undefined, +): boolean { + const normalizedTokenRoles = normalizeRoles(tokenRoles) + if (normalizedTokenRoles.some(role => DUPLICATE_TOKEN_ROLES.includes(role))) { + return true + } + + const normalizedChallengeRoles = normalizeRoles(challengeRoles) + + return normalizedChallengeRoles.some( + role => DUPLICATE_CHALLENGE_ROLE_FRAGMENTS.some(fragment => role.includes(fragment)), + ) +} diff --git a/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.module.scss b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.module.scss new file mode 100644 index 000000000..345409045 --- /dev/null +++ b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.module.scss @@ -0,0 +1,97 @@ +@import '@libs/ui/styles/includes'; + +.cell { + padding-top: 0; +} + +.toggle { + align-items: center; + background: transparent; + border: 0; + color: $red-100; + cursor: pointer; + display: flex; + font-size: 13px; + font-weight: 500; + gap: $sp-1; + padding: 0; + width: 100%; + + svg { + height: 16px; + width: 16px; + } +} + +.toggleLabel { + flex: 1; + text-align: left; +} + +.chevron { + transition: transform 0.15s ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.panel { + border: 1px solid $black-20; + border-radius: 4px; + display: flex; + flex-direction: column; + gap: $sp-2; + margin-top: $sp-2; + padding: $sp-3; +} + +.duplicate { + display: flex; + flex-direction: column; + gap: 2px; +} + +.duplicateLine { + align-items: center; + color: $black-80; + display: flex; + flex-wrap: wrap; + font-size: 13px; + gap: $sp-1; +} + +.bullet { + color: $black-60; +} + +.duplicateMeta { + color: $black-60; +} + +.crossChallenge { + align-items: center; + color: $red-100; + display: inline-flex; + font-size: 13px; + gap: $sp-1; + padding-left: $sp-4; + + svg { + height: 14px; + width: 14px; + } +} + +.crossChallengeLink { + align-items: center; + color: $red-100; + display: inline-flex; + gap: 2px; + text-decoration: underline; + + svg { + height: 12px; + width: 12px; + } +} diff --git a/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.tsx b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.tsx new file mode 100644 index 000000000..8238aa269 --- /dev/null +++ b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.tsx @@ -0,0 +1,140 @@ +/** + * Expandable duplicates row rendered under a submissions table row. + */ +import { FC, useCallback, useState } from 'react' +import classNames from 'classnames' + +import { EnvironmentConfig } from '~/config' +import { IconOutline } from '~/libs/ui' + +import { SubmissionDuplicate } from '../../models' + +import styles from './SubmissionDuplicatesRow.module.scss' + +interface SubmissionDuplicatesRowProps { + colSpan: number + duplicates: SubmissionDuplicate[] +} + +interface DuplicateEntryProps { + duplicate: SubmissionDuplicate +} + +/** + * Formats a duplicate's submission timestamp as `Jul 13, 7:39 AM`. + * @param submittedAt ISO timestamp reported by the duplicates endpoint. + * @returns Formatted timestamp, or a dash when it is missing or unparseable. + */ +function formatDuplicateDate(submittedAt?: string): string { + if (!submittedAt) { + return '-' + } + + const parsed = new Date(submittedAt) + if (Number.isNaN(parsed.getTime())) { + return '-' + } + + return parsed.toLocaleString('en-US', { + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + month: 'short', + }) +} + +/** + * Renders a single duplicate entry, adding the originating challenge link when + * the match comes from a different challenge. + */ +const DuplicateEntry: FC = (props: DuplicateEntryProps) => { + const duplicate: SubmissionDuplicate = props.duplicate + const challengeUrl = duplicate.challenge + ? `${EnvironmentConfig.URLS.CHALLENGES_PAGE}/${duplicate.challenge}` + : undefined + + return ( + + ) +} + +/** + * Renders the collapsed-by-default duplicates row for one submission. + * + * The caller must render this only when duplicates exist; the wireframe hides + * the row entirely for submissions with no identical siblings. + */ +export const SubmissionDuplicatesRow: FC = props => { + const [isOpen, setIsOpen] = useState(false) + + const toggleOpen = useCallback((): void => { + setIsOpen(wasOpen => !wasOpen) + }, []) + + const countLabel = `${props.duplicates.length} duplicate${props.duplicates.length === 1 ? '' : 's'}` + + return ( + + + + + {isOpen && ( +
+ {props.duplicates.map(duplicate => ( + + ))} +
+ )} + + + ) +} + +export default SubmissionDuplicatesRow diff --git a/src/apps/work/src/lib/components/SubmissionDuplicatesRow/index.ts b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/index.ts new file mode 100644 index 000000000..01f6e01d7 --- /dev/null +++ b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/index.ts @@ -0,0 +1 @@ +export * from './SubmissionDuplicatesRow' diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx index ce0827d80..855d5af35 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx @@ -1,11 +1,24 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { SubmissionsTable } from './SubmissionsTable' +jest.mock('~/config', () => ({ + EnvironmentConfig: { + URLS: { + CHALLENGES_PAGE: 'https://example.com/challenges', + }, + }, +}), { + virtual: true, +}) jest.mock('~/libs/ui', () => ({ IconOutline: { + ChevronDownIcon: (): JSX.Element => , ClockIcon: (): JSX.Element => , + ExclamationIcon: (): JSX.Element => , + ExternalLinkIcon: (): JSX.Element => , + LightningBoltIcon: (): JSX.Element => , XCircleIcon: (): JSX.Element => , }, IconSolid: { @@ -565,4 +578,131 @@ describe('SubmissionsTable', () => { expect(screen.getByRole('img', { name: 'Test status: FAILED' })) .toBeTruthy() }) + describe('duplicate submissions', () => { + const submissions = [ + { + challengeId: 'challenge-123', + createdBy: 'member-1', + id: 'submission-1', + review: [ + { + finalScore: 95, + initialScore: 90, + }, + ], + type: 'SUBMISSION', + }, + ] + + function renderWithDuplicates( + duplicatesBySubmissionId?: Record>>, + ): void { + render( + , + ) + } + + it('hides the duplicates row when the submission has no duplicates', () => { + renderWithDuplicates({ 'submission-1': [] }) + + expect(screen.queryByRole('button', { name: /duplicate/ })) + .toBeNull() + }) + + it('hides the duplicates row when duplicates were never fetched', () => { + renderWithDuplicates(undefined) + + expect(screen.queryByRole('button', { name: /duplicate/ })) + .toBeNull() + }) + + it('renders a collapsed duplicates row and expands it on click', () => { + renderWithDuplicates({ + 'submission-1': [ + { + challenge: 'challenge-123', + isCrossChallenge: false, + submissionId: 'PNM4cbZgII428Iv', + submittedAt: '2026-07-13T07:39:00.000Z', + user: '2001', + userHandle: 'taasintake500', + }, + { + challenge: 'challenge-999', + challengeTitle: 'Basketball Stats App', + isCrossChallenge: true, + submissionId: '12I.RbObnTFCVt', + submittedAt: '2026-07-10T14:15:00.000Z', + user: '2002', + userHandle: 'testmfa1', + }, + ], + }) + + const toggle = screen.getByRole('button', { name: /2 duplicates/ }) + expect(toggle.getAttribute('aria-expanded')) + .toBe('false') + expect(screen.queryByText('taasintake500')) + .toBeNull() + + fireEvent.click(toggle) + + expect(toggle.getAttribute('aria-expanded')) + .toBe('true') + expect(screen.getByText('taasintake500')) + .toBeTruthy() + expect(screen.getByText('(PNM4cbZgII428Iv)')) + .toBeTruthy() + expect( + screen.getByRole('link', { name: 'Basketball Stats App' }) + .getAttribute('href'), + ) + .toBe('https://example.com/challenges/challenge-999') + }) + + it('singularizes the duplicate count label', () => { + renderWithDuplicates({ + 'submission-1': [ + { + challenge: 'challenge-123', + isCrossChallenge: false, + submissionId: 'other-submission', + }, + ], + }) + + expect(screen.getByRole('button', { name: /1 duplicate$/ })) + .toBeTruthy() + }) + + it('falls back to the member id and a dash when handle or date are missing', () => { + renderWithDuplicates({ + 'submission-1': [ + { + challenge: 'challenge-123', + isCrossChallenge: false, + submissionId: 'other-submission', + user: '2003', + }, + ], + }) + + fireEvent.click(screen.getByRole('button', { name: /1 duplicate/ })) + + expect(screen.getByText('2003')) + .toBeTruthy() + expect(screen.getByText('- -')) + .toBeTruthy() + }) + }) }) diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx index 0f47b1bb2..ab2a7b588 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx @@ -1,5 +1,6 @@ import { FC, + Fragment, MouseEvent, ReactElement, } from 'react' @@ -15,7 +16,8 @@ import { COMMUNITY_APP_URL, REVIEW_APP_URL } from '../../constants' import { ReactComponent as IconDownloadArtifacts } from '../../assets/icons/IconDownloadArtifacts.svg' import { ReactComponent as IconRunnerLogs } from '../../assets/icons/IconRunnerLogs.svg' import { ReactComponent as IconSquareDownload } from '../../assets/icons/IconSquareDownload.svg' -import { Submission } from '../../models' +import { Submission, SubmissionDuplicatesMap } from '../../models' +import { SubmissionDuplicatesRow } from '../SubmissionDuplicatesRow' import { formatDateTime, getRatingLevel, @@ -49,6 +51,8 @@ interface SubmissionsTableProps { canDownloadSubmissions: boolean canViewRunnerLogs?: boolean challengeId: string + /** SHA-256 duplicate matches keyed by submission id; empty when not permitted. */ + duplicatesBySubmissionId?: SubmissionDuplicatesMap isLoading?: boolean isLoadingMembers?: boolean onDownloadSubmission: (submissionId: string) => void @@ -405,116 +409,128 @@ export const SubmissionsTable: FC = ( : '' const reviewLink = `${REVIEW_APP_URL}/active-challenges/${props.challengeId}` + `/challenge-details?tab=${reviewTab}` + const duplicates = props.duplicatesBySubmissionId?.[submission.id] ?? [] return ( - - - {submission.memberHandle + + + + {submission.memberHandle + ? ( + + {handleDisplay} + + ) + : ( + + {handleDisplay} + + )} + + + + {emailDisplay} + + + + {submissionDate} + + + + + {initialScore} + {' / '} + {finalScore} + + + + {props.showMarathonMatchTestProgress ? ( - - {handleDisplay} - + <> + + {formatTestProcess(testProgress?.process)} + + + + {renderTestStatusIcon(testProgress?.status)} + + + + {testProgress?.progressPercent || ''} + + ) - : ( - - {handleDisplay} - - )} - - - - {emailDisplay} - - - - {submissionDate} - - - - - {initialScore} - {' / '} - {finalScore} - - - - {props.showMarathonMatchTestProgress + : undefined} + + + {submission.id} + + + +
+ + + + + {props.canViewRunnerLogs + ? ( + + ) + : undefined} + +
+ + + + {duplicates.length > 0 ? ( - <> - - {formatTestProcess(testProgress?.process)} - - - - {renderTestStatusIcon(testProgress?.status)} - - - - {testProgress?.progressPercent || ''} - - + ) : undefined} - - - {submission.id} - - - -
- - - - - {props.canViewRunnerLogs - ? ( - - ) - : undefined} - -
- - +
) })} diff --git a/src/apps/work/src/lib/components/form/FormRadioGroup/FormRadioGroup.tsx b/src/apps/work/src/lib/components/form/FormRadioGroup/FormRadioGroup.tsx index 16a103d38..288ab42d3 100644 --- a/src/apps/work/src/lib/components/form/FormRadioGroup/FormRadioGroup.tsx +++ b/src/apps/work/src/lib/components/form/FormRadioGroup/FormRadioGroup.tsx @@ -21,6 +21,7 @@ export interface FormRadioOption { interface FormRadioGroupProps { disabled?: boolean + hint?: string label: string name: string onChange?: (value: boolean | string) => void @@ -66,6 +67,7 @@ export const FormRadioGroup: FC = (props: FormRadioGroupPro return ( { + const memberIds = Array.from(new Set( + Object.values(duplicatesBySubmissionId) + .flat() + .map(duplicate => duplicate.user) + .filter((memberId): memberId is string => !!memberId && /^\d+$/.test(memberId)), + )) + + if (!memberIds.length) { + return duplicatesBySubmissionId + } + + const members = await fetchMembersByUserIds(memberIds, 'userId,handle') + const handlesByMemberId = new Map( + members + .filter(member => !!member.handle) + .map(member => [member.userId, member.handle as string]), + ) + + if (!handlesByMemberId.size) { + return duplicatesBySubmissionId + } + + return Object.entries(duplicatesBySubmissionId) + .reduce((result, [submissionId, duplicates]) => { + result[submissionId] = duplicates.map((duplicate: SubmissionDuplicate) => { + const handle = duplicate.user + ? handlesByMemberId.get(duplicate.user) + : undefined + + return handle + ? { + ...duplicate, + userHandle: handle, + } + : duplicate + }) + + return result + }, {}) +} + +/** + * Fetches SHA-256 duplicate matches for a challenge's submissions. + * + * Duplicate detection is an operator-only endpoint, so the caller must gate the + * request with `enabled`. Failures resolve to an empty map rather than surfacing + * an error, because the duplicates row only supplements the submissions table. + * + * @param challengeId Challenge that owns the submissions being checked. + * @param submissionIds Submission ids to check for duplicates. + * @param enabled Whether the caller is allowed to query duplicates. + * @returns Duplicate matches keyed by submission id plus the loading flag. + */ +export function useFetchSubmissionDuplicates( + challengeId?: string, + submissionIds: string[] = [], + enabled: boolean = true, +): UseFetchSubmissionDuplicatesResult { + const normalizedSubmissionIds = useMemo( + () => Array.from(new Set( + submissionIds + .map(submissionId => `${submissionId ?? ''}`.trim()) + .filter(Boolean), + )) + .sort(), + [submissionIds], + ) + + const swrKey = enabled && challengeId && normalizedSubmissionIds.length + ? [ + 'submission-duplicates', + challengeId, + normalizedSubmissionIds.join(','), + ] + : undefined + + const { + data: duplicatesBySubmissionId = EMPTY_DUPLICATES, + isValidating: isLoading, + }: SWRResponse = useSWR( + swrKey, + async () => { + try { + const duplicates = await fetchSubmissionDuplicates( + challengeId as string, + normalizedSubmissionIds, + true, + ) + + return withMemberHandles(duplicates) + } catch { + // Duplicate detection is optional context; a denied or failed + // lookup must not break the submissions table. + return EMPTY_DUPLICATES + } + }, + { + revalidateOnFocus: false, + shouldRetryOnError: false, + }, + ) + + return { + duplicatesBySubmissionId, + isLoading, + } +} diff --git a/src/apps/work/src/lib/models/SubmissionDuplicate.model.ts b/src/apps/work/src/lib/models/SubmissionDuplicate.model.ts new file mode 100644 index 000000000..ed7dd05a1 --- /dev/null +++ b/src/apps/work/src/lib/models/SubmissionDuplicate.model.ts @@ -0,0 +1,26 @@ +/** + * Models for the SHA-256 duplicate submission detection endpoint. + */ + +/** + * A submission sharing the exact SHA-256 digest of the checked submission. + */ +export interface SubmissionDuplicate { + /** Challenge that owns the duplicate submission. */ + challenge?: string + /** Challenge name, when the API could resolve it. */ + challengeTitle?: string + /** True when the duplicate lives on a different challenge. */ + isCrossChallenge: boolean + /** ID of the duplicate submission. */ + submissionId: string + /** ISO timestamp the duplicate was submitted. */ + submittedAt?: string + /** Member ID that created the duplicate submission. */ + user?: string + /** Member handle resolved from `user`, when available. */ + userHandle?: string +} + +/** Duplicate matches keyed by the checked submission ID. */ +export type SubmissionDuplicatesMap = Record diff --git a/src/apps/work/src/lib/models/index.ts b/src/apps/work/src/lib/models/index.ts index 69213f847..bbd562a43 100644 --- a/src/apps/work/src/lib/models/index.ts +++ b/src/apps/work/src/lib/models/index.ts @@ -31,6 +31,7 @@ export * from './Reviewer.model' export * from './ReviewType.model' export * from './Skill.model' export * from './Submission.model' +export * from './SubmissionDuplicate.model' export * from './TaasJob.model' export * from './Term.model' export * from './Timeline.model' diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts index 5918ca0d8..6b51cb33b 100644 --- a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts +++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts @@ -5,6 +5,9 @@ import { REVIEW_TYPES, ROUND_TYPES, } from '../constants/challenge-editor.constants' +import { + SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, +} from '../utils/submission-limit.utils' import { challengeAdvancedOptionsSchema, @@ -356,6 +359,81 @@ describe('challenge-editor schema reviewer slot assignment validation', () => { .toBeTruthy() }) + it('accepts unassigned Design copilot review phases', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + phases: [ + { + name: 'Review', + phaseId: 'review-phase-id', + }, + { + name: 'Approval', + phaseId: 'approval-phase-id', + }, + ], + reviewers: [ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'approval-phase-id', + scorecardId: 'approval-scorecard-id', + shouldOpenOpportunity: false, + }, + ], + }, + { + context: { + isDesignChallenge: true, + }, + }, + ), + ) + .resolves + .toBeTruthy() + }) + + it('still requires review assignments outside Design challenges', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + phases: [{ + name: 'Review', + phaseId: 'review-phase-id', + }], + reviewers: [ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + shouldOpenOpportunity: false, + }, + ], + }, + { + context: { + isDesignChallenge: false, + }, + }, + ), + ) + .rejects + .toMatchObject({ + path: 'reviewers[0].memberId', + }) + }) + it('accepts required reviewer slot assignments when opportunity is closed', async () => { await expect( challengeAdvancedOptionsSchema.validate({ @@ -413,3 +491,113 @@ describe('challenge-editor schema reviewer slot assignment validation', () => { .toThrow(`Number of reviewers cannot exceed ${MAX_MANUAL_REVIEWER_COUNT}`) }) }) + +describe('challenge-editor schema submission limit validation', () => { + const baseFormData = { + roundType: ROUND_TYPES.SINGLE_ROUND, + } + const configurableContext = { + context: { + isSubmissionLimitConfigurable: true, + }, + } + + function buildSubmissionLimitMetadata(count: string, limit: string): Array<{ + name: string + value: string + }> { + return [{ + name: 'submissionLimit', + value: JSON.stringify({ + count, + limit, + unlimited: limit === 'true' + ? 'false' + : 'true', + }), + }] + } + + it('rejects a limited submission setting without a count', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('', 'true'), + }, + configurableContext, + ), + ) + .rejects + .toThrow(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE) + }) + + it('reports the missing count on the visible limit field', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('', 'true'), + }, + configurableContext, + ), + ) + .rejects + .toMatchObject({ + path: 'submissionLimitCount', + }) + }) + + it('rejects a limited submission setting with a zero count', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('0', 'true'), + }, + configurableContext, + ), + ) + .rejects + .toThrow(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE) + }) + + it('accepts a limited submission setting with a count', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('2', 'true'), + }, + configurableContext, + ), + ) + .resolves + .toBeTruthy() + }) + + it('accepts an unlimited submission setting', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('', 'false'), + }, + configurableContext, + ), + ) + .resolves + .toBeTruthy() + }) + + it('skips the count rule when the submission limit is not configurable', async () => { + await expect( + challengeAdvancedOptionsSchema.validate({ + ...baseFormData, + metadata: buildSubmissionLimitMetadata('', 'true'), + }), + ) + .resolves + .toBeTruthy() + }) +}) diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts index e90090c07..4141fe648 100644 --- a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts +++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts @@ -13,12 +13,33 @@ import { } from '../constants/challenge-editor.constants' import { ChallengeEditorFormData, + ChallengeMetadata, ChallengeReviewer, } from '../models' import { isSkillsRequired, } from '../utils/challenge-editor.utils' -import { isScreenerAssignmentOptional } from '../utils/reviewer.utils' +import { isReviewerAssignmentOptional } from '../utils/reviewer.utils' +import { + isSubmissionLimitCountMissing, + SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, +} from '../utils/submission-limit.utils' + +/** + * Validation context supplied to the challenge editor schema by the challenge editor form. + * + * @remarks The schema only receives form values, so track and type driven rules such as the + * Design `Challenge` reviewer assignment exception are provided through the resolver context. + */ +export interface ChallengeEditorValidationContext { + /** Whether the edited challenge is a Design `Challenge`, whose private reviewers are + * automatically assigned to the selected copilot during save. */ + isDesignChallenge?: boolean + /** Whether the submission-limit control is currently editable. The limit is only rendered for + * Design submission settings and is locked once members have uploaded submissions, so the + * required-count rule is skipped when the copilot cannot correct the value. */ + isSubmissionLimitConfigurable?: boolean +} function isSchedulingApiEnabled(value: unknown): boolean { return value !== false @@ -409,7 +430,32 @@ export const challengeAdvancedOptionsSchema = yup.object({ .optional(), metadata: yup.array() .of(metadataSchema) - .optional(), + .optional() + .test( + 'submission-limit-count-required', + SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, + function validateSubmissionLimitCount(value: unknown): boolean | yup.ValidationError { + const isSubmissionLimitConfigurable = ( + this.options.context as ChallengeEditorValidationContext | undefined + )?.isSubmissionLimitConfigurable === true + + if ( + !isSubmissionLimitConfigurable + || !isSubmissionLimitCountMissing(value as ChallengeMetadata[] | undefined) + ) { + return true + } + + /* + * The limit is edited through display-only form fields, so the error is reported on + * the visible count input instead of the metadata array that stores the value. + */ + return this.createError({ + message: SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, + path: 'submissionLimitCount', + }) + }, + ), reviewer: yup.string() .transform(emptyStringToUndefined) .optional(), @@ -425,6 +471,9 @@ export const challengeAdvancedOptionsSchema = yup.object({ } const phases = (this.parent as Partial)?.phases + const isDesignChallenge = ( + this.options.context as ChallengeEditorValidationContext | undefined + )?.isDesignChallenge === true for (let reviewerIndex = 0; reviewerIndex < value.length; reviewerIndex += 1) { const reviewer = value[reviewerIndex] as ChallengeReviewer | undefined @@ -433,7 +482,7 @@ export const challengeAdvancedOptionsSchema = yup.object({ const requiresMemberAssignments = !!reviewer && isMemberReview && !shouldOpenOpportunity - && !isScreenerAssignmentOptional(reviewer, phases) + && !isReviewerAssignmentOptional(reviewer, phases, isDesignChallenge) if (requiresMemberAssignments) { const reviewerSlots = getRequiredReviewerSlots(reviewer.memberReviewerCount) diff --git a/src/apps/work/src/lib/services/index.ts b/src/apps/work/src/lib/services/index.ts index f58b5bd44..71257d3aa 100644 --- a/src/apps/work/src/lib/services/index.ts +++ b/src/apps/work/src/lib/services/index.ts @@ -37,6 +37,7 @@ export * from './resources.service' export * from './reviews.service' export * from './skills.service' export * from './submissions.service' +export * from './submission-duplicates.service' export * from './taas-projects.service' export * from './terms.service' export * from './timeline-templates.service' diff --git a/src/apps/work/src/lib/services/submission-duplicates.service.ts b/src/apps/work/src/lib/services/submission-duplicates.service.ts new file mode 100644 index 000000000..f586dc491 --- /dev/null +++ b/src/apps/work/src/lib/services/submission-duplicates.service.ts @@ -0,0 +1,123 @@ +/** + * Service for the SHA-256 duplicate submission detection endpoint. + */ +import { xhrGetAsync } from '~/libs/core' + +import { SUBMISSIONS_API_URL } from '../constants' +import { SubmissionDuplicate, SubmissionDuplicatesMap } from '../models' + +/** The API rejects requests carrying more submission ids than this. */ +const DUPLICATES_REQUEST_CHUNK_SIZE = 100 + +interface DuplicateGroupResponse { + duplicates?: unknown +} + +type DuplicatesResponse = Record + +function toOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalizedValue = String(value) + .trim() + + return normalizedValue || undefined +} + +/** + * Normalizes one duplicate entry returned by the API. + * @param value Raw duplicate entry. + * @param challengeId Challenge the checked submission belongs to. + * @returns Normalized duplicate, or `undefined` when the entry has no submission id. + */ +function toSubmissionDuplicate( + value: unknown, + challengeId: string, +): SubmissionDuplicate | undefined { + if (typeof value !== 'object' || !value) { + return undefined + } + + const entry = value as Record + const submissionId = toOptionalString(entry.submissionId) + + if (!submissionId) { + return undefined + } + + const challenge = toOptionalString(entry.challenge) + + return { + challenge, + challengeTitle: toOptionalString(entry.challengeTitle), + isCrossChallenge: !!challenge && challenge !== challengeId, + submissionId, + submittedAt: toOptionalString(entry.submittedAt), + user: toOptionalString(entry.user), + } +} + +/** + * Fetches submissions sharing a SHA-256 digest with the supplied submissions. + * + * Requests are chunked to respect the API's per-request submission id limit. + * + * @param challengeId Challenge that owns every checked submission. + * @param submissionIds Submission ids to check for duplicates. + * @param crossChallenge When true, duplicates from other challenges are included. + * @returns Duplicate matches keyed by checked submission id. + */ +export async function fetchSubmissionDuplicates( + challengeId: string, + submissionIds: string[], + crossChallenge: boolean = false, +): Promise { + const normalizedChallengeId = challengeId.trim() + const uniqueSubmissionIds = Array.from(new Set( + submissionIds + .map(submissionId => toOptionalString(submissionId)) + .filter((submissionId): submissionId is string => !!submissionId), + )) + + if (!normalizedChallengeId || !uniqueSubmissionIds.length) { + return {} + } + + const chunks: string[][] = [] + for (let index = 0; index < uniqueSubmissionIds.length; index += DUPLICATES_REQUEST_CHUNK_SIZE) { + chunks.push(uniqueSubmissionIds.slice(index, index + DUPLICATES_REQUEST_CHUNK_SIZE)) + } + + const responses = await Promise.all(chunks.map(async chunk => { + const query = new URLSearchParams() + chunk.forEach(submissionId => { + query.append('submissionId', submissionId) + }) + + if (crossChallenge) { + query.set('crossChallenge', 'true') + } + + return xhrGetAsync( + `${SUBMISSIONS_API_URL}/${normalizedChallengeId}/duplicates?${query.toString()}`, + ) + })) + + return responses.reduce((result, response) => { + Object.entries(response ?? {}) + .forEach(([submissionId, group]) => { + const rawDuplicates: unknown = group?.duplicates + const duplicates: unknown[] = Array.isArray(rawDuplicates) + ? rawDuplicates + : [] + + result[submissionId] = duplicates + .map(duplicate => toSubmissionDuplicate(duplicate, normalizedChallengeId)) + .filter((duplicate): duplicate is SubmissionDuplicate => !!duplicate) + }) + + return result + }, {}) +} diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts index 79e4e63d6..0ec480451 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts @@ -293,6 +293,22 @@ describe('challenge-editor utils submission count mapping', () => { .toBe(1) }) + it('keeps numOfCheckpointSubmissions in form data so submission-limit locking can use it', () => { + const result = transformChallengeToFormData({ + description: 'Public specification', + name: 'Checkpoint submission challenge', + numOfCheckpointSubmissions: 2, + numOfSubmissions: 0, + trackId: 'track-id', + typeId: 'type-id', + }) + + expect(result.numOfCheckpointSubmissions) + .toBe(2) + expect(result.numOfSubmissions) + .toBe(0) + }) + it('keeps phase completion dates in form data so completed schedule rows stay locked', () => { const result = transformChallengeToFormData({ description: 'Public specification', @@ -633,6 +649,22 @@ describe('challenge-editor utils design work type mapping', () => { }) describe('challenge-editor utils terms mapping', () => { + it('keeps an empty tags array in API payloads to clear tags on update', () => { + const formData: Record = { + description: 'Public specification', + name: 'Design challenge', + skills: [], + tags: [], + trackId: 'track-id', + typeId: 'type-id', + } + + const result = transformFormDataToChallenge(formData as any) + + expect(result.tags) + .toEqual([]) + }) + it('keeps an empty groups array in API payloads to clear groups on update', () => { const formData: Record = { description: 'Public specification', diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.ts index 496c2f607..aa4b211fb 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.ts @@ -55,6 +55,7 @@ const MILESTONE_METADATA_NAMES = { const MILESTONE_METADATA_KEYS: readonly string[] = Object.values(MILESTONE_METADATA_NAMES) const ALLOW_EMPTY_ARRAY_PAYLOAD_KEYS = new Set([ 'groups', + 'tags', 'terms', ]) @@ -1046,6 +1047,7 @@ export function transformChallengeToFormData( milestoneDurationDays: normalizeOptionalNumber(milestoneConfiguration.milestoneDurationDays), }, name, + numOfCheckpointSubmissions: normalizeOptionalNumber(challenge?.numOfCheckpointSubmissions), numOfSubmissions: normalizeOptionalNumber(challenge?.numOfSubmissions), phases, privateDescription, diff --git a/src/apps/work/src/lib/utils/index.ts b/src/apps/work/src/lib/utils/index.ts index dae957d45..435888bb1 100644 --- a/src/apps/work/src/lib/utils/index.ts +++ b/src/apps/work/src/lib/utils/index.ts @@ -34,6 +34,7 @@ export * from './rating.utils' export * from './resource-deletion.utils' export * from './sorting.utils' export * from './storage.utils' +export * from './submission-limit.utils' export * from './timezone.utils' export * from './toast.utils' export * from './user.utils' diff --git a/src/apps/work/src/lib/utils/permissions.utils.ts b/src/apps/work/src/lib/utils/permissions.utils.ts index 8a3789923..827b9e64c 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.ts @@ -221,6 +221,19 @@ export function canViewMarathonMatchRunnerLogs(userRoles: string[]): boolean { || hasCopilotRole(userRoles) } +/** + * Returns whether the supplied roles can query submission duplicate detection. + * @param userRoles caller roles from the decoded auth token or app context. + * @returns `true` for admins, project managers, and copilots; otherwise `false`. + * Used by `SubmissionsSection` so only callers the review API answers for issue + * the duplicates request; everyone else would collect a 403. + */ +export function canViewSubmissionDuplicates(userRoles: string[]): boolean { + return hasAdminRole(userRoles) + || hasManagerRole(userRoles) + || hasCopilotRole(userRoles) +} + export function canCreateTaasProject(userRoles: string[]): boolean { return hasAdminRole(userRoles) || hasCopilotRole(userRoles) } diff --git a/src/apps/work/src/lib/utils/reviewer.utils.spec.ts b/src/apps/work/src/lib/utils/reviewer.utils.spec.ts new file mode 100644 index 000000000..494088a2b --- /dev/null +++ b/src/apps/work/src/lib/utils/reviewer.utils.spec.ts @@ -0,0 +1,59 @@ +import { + isReviewerAssignmentOptional, +} from './reviewer.utils' + +describe('isReviewerAssignmentOptional', () => { + const phases = [ + { + id: 'screening-instance-id', + name: 'Screening', + phaseId: 'screening-phase-id', + }, + { + id: 'review-instance-id', + name: 'Review', + phaseId: 'review-phase-id', + }, + { + id: 'approval-instance-id', + name: 'Approval', + phaseId: 'approval-phase-id', + }, + { + id: 'checkpoint-review-instance-id', + name: 'Checkpoint Review', + phaseId: 'checkpoint-review-phase-id', + }, + ] + + it('defers screening assignments for every track', () => { + expect(isReviewerAssignmentOptional({ phaseId: 'screening-phase-id' }, phases)) + .toBe(true) + }) + + it('requires review assignments outside Design challenges', () => { + expect(isReviewerAssignmentOptional({ phaseId: 'review-phase-id' }, phases)) + .toBe(false) + expect(isReviewerAssignmentOptional({ phaseId: 'approval-instance-id' }, phases)) + .toBe(false) + }) + + it('defers copilot assigned review phases for Design challenges', () => { + expect(isReviewerAssignmentOptional({ phaseId: 'review-phase-id' }, phases, true)) + .toBe(true) + expect(isReviewerAssignmentOptional({ phaseId: 'approval-instance-id' }, phases, true)) + .toBe(true) + expect(isReviewerAssignmentOptional({ phaseId: 'checkpoint-review-phase-id' }, phases, true)) + .toBe(true) + }) + + it('keeps AI reviewer rows and unknown phases required', () => { + expect(isReviewerAssignmentOptional({ + isMemberReview: false, + phaseId: 'review-phase-id', + }, phases, true)) + .toBe(false) + expect(isReviewerAssignmentOptional({ phaseId: 'unknown-phase-id' }, phases, true)) + .toBe(false) + }) +}) diff --git a/src/apps/work/src/lib/utils/reviewer.utils.ts b/src/apps/work/src/lib/utils/reviewer.utils.ts index fc1ed2cb2..707dd8710 100644 --- a/src/apps/work/src/lib/utils/reviewer.utils.ts +++ b/src/apps/work/src/lib/utils/reviewer.utils.ts @@ -3,6 +3,16 @@ import type { ChallengeReviewer, } from '../models' +const SCREENER_PHASE_NAMES = new Set([ + 'checkpoint screening', + 'screening', +]) +const DESIGN_COPILOT_ASSIGNED_PHASE_NAMES = new Set([ + 'approval', + 'checkpoint review', + 'review', +]) + /** * Normalizes a reviewer or phase value for exact identifier and name comparisons. * @@ -22,14 +32,18 @@ function normalizeReviewerValue(value: unknown): string { * * @param reviewer reviewer configuration whose phase should be inspected. * @param phases challenge phases used to resolve the reviewer's phase name. - * @returns `true` for a human reviewer configured on Screening or Checkpoint Screening. + * @param isDesignChallenge whether the editor is configuring a Design `Challenge`, where the + * selected copilot is assigned to the private review phases during save. + * @returns `true` for a human reviewer configured on Screening or Checkpoint Screening, and for a + * human reviewer configured on Checkpoint Review, Review, or Approval of a Design `Challenge`. * @remarks Form validation and reviewer fields use this exception; every other reviewer phase * still requires assignments up front. * @throws Does not throw. */ -export function isScreenerAssignmentOptional( +export function isReviewerAssignmentOptional( reviewer: ChallengeReviewer | undefined, phases: ChallengePhase[] | undefined, + isDesignChallenge: boolean = false, ): boolean { if (reviewer?.isMemberReview === false || !Array.isArray(phases)) { return false @@ -51,8 +65,11 @@ export function isScreenerAssignmentOptional( return matchesPhase && ( - normalizedPhaseName === 'screening' - || normalizedPhaseName === 'checkpoint screening' + SCREENER_PHASE_NAMES.has(normalizedPhaseName) + || ( + isDesignChallenge + && DESIGN_COPILOT_ASSIGNED_PHASE_NAMES.has(normalizedPhaseName) + ) ) }) } diff --git a/src/apps/work/src/lib/utils/submission-limit.utils.spec.ts b/src/apps/work/src/lib/utils/submission-limit.utils.spec.ts new file mode 100644 index 000000000..64594a9bb --- /dev/null +++ b/src/apps/work/src/lib/utils/submission-limit.utils.spec.ts @@ -0,0 +1,74 @@ +import { + hasChallengeSubmissions, + isSubmissionLimitCountMissing, +} from './submission-limit.utils' + +function buildSubmissionLimitMetadata(count: string, limit: string): Array<{ + name: string + value: string +}> { + return [{ + name: 'submissionLimit', + value: JSON.stringify({ + count, + limit, + unlimited: limit === 'true' + ? 'false' + : 'true', + }), + }] +} + +describe('isSubmissionLimitCountMissing', () => { + it('detects a limited setting without a count', () => { + expect(isSubmissionLimitCountMissing(buildSubmissionLimitMetadata('', 'true'))) + .toBe(true) + }) + + it('detects a limited setting with a zero count', () => { + expect(isSubmissionLimitCountMissing(buildSubmissionLimitMetadata('0', 'true'))) + .toBe(true) + }) + + it('accepts a limited setting with a positive count', () => { + expect(isSubmissionLimitCountMissing(buildSubmissionLimitMetadata('3', 'true'))) + .toBe(false) + }) + + it('accepts an unlimited setting', () => { + expect(isSubmissionLimitCountMissing(buildSubmissionLimitMetadata('', 'false'))) + .toBe(false) + }) + + it('accepts missing and malformed metadata', () => { + expect(isSubmissionLimitCountMissing(undefined)) + .toBe(false) + expect(isSubmissionLimitCountMissing([{ + name: 'submissionLimit', + value: '{invalid', + }])) + .toBe(false) + }) +}) + +describe('hasChallengeSubmissions', () => { + it('reports contest submissions', () => { + expect(hasChallengeSubmissions({ numOfSubmissions: 1 })) + .toBe(true) + }) + + it('reports checkpoint submissions', () => { + expect(hasChallengeSubmissions({ numOfCheckpointSubmissions: '2' })) + .toBe(true) + }) + + it('reports no submissions', () => { + expect(hasChallengeSubmissions({ + numOfCheckpointSubmissions: 0, + numOfSubmissions: 0, + })) + .toBe(false) + expect(hasChallengeSubmissions(undefined)) + .toBe(false) + }) +}) diff --git a/src/apps/work/src/lib/utils/submission-limit.utils.ts b/src/apps/work/src/lib/utils/submission-limit.utils.ts new file mode 100644 index 000000000..ec76e1789 --- /dev/null +++ b/src/apps/work/src/lib/utils/submission-limit.utils.ts @@ -0,0 +1,166 @@ +import { ChallengeMetadata } from '../models' + +import { getMetadataValue } from './metadata.utils' + +export const SUBMISSION_LIMIT_METADATA_NAME = 'submissionLimit' +export const SUBMISSION_LIMIT_LIMITED_MODE = 'limited' +export const SUBMISSION_LIMIT_UNLIMITED_MODE = 'unlimited' +export const SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE + = 'Enter a submission limit of at least 1 when submissions are limited' + +export type SubmissionLimitMode = + typeof SUBMISSION_LIMIT_LIMITED_MODE + | typeof SUBMISSION_LIMIT_UNLIMITED_MODE + +export interface SubmissionLimitMetadata { + count: string + mode: SubmissionLimitMode +} + +const defaultSubmissionLimitMetadata: SubmissionLimitMetadata = { + count: '', + mode: SUBMISSION_LIMIT_UNLIMITED_MODE, +} + +/** + * Converts legacy string and boolean flags to a strict boolean. + * + * @param value legacy metadata flag. + * @returns Whether the flag is enabled. + * @throws Does not throw. + */ +function toBoolean(value: unknown): boolean { + return value === true || value === 'true' +} + +/** + * Removes non-numeric characters from a submission-limit count. + * + * @param value raw form or metadata value. + * @returns The digits-only submission count. + * @throws Does not throw. + */ +export function sanitizeSubmissionLimitCount(value: string): string { + return value.replace(/[^\d]/g, '') +} + +/** + * Parses the legacy JSON string stored in `submissionLimit` challenge metadata. + * + * Missing, malformed, and explicitly non-limited values use the product default of unlimited. + * A positive count without either flag is retained for compatibility with older payloads. + * + * @param value serialized challenge metadata value. + * @returns The submission-limit mode and sanitized count used by the form. + * @throws Does not throw; malformed metadata falls back to unlimited. + */ +export function parseSubmissionLimitMetadata(value: string | undefined): SubmissionLimitMetadata { + if (!value) { + return defaultSubmissionLimitMetadata + } + + try { + const parsedValue = JSON.parse(value) as unknown + + if (!parsedValue || typeof parsedValue !== 'object' || Array.isArray(parsedValue)) { + return defaultSubmissionLimitMetadata + } + + const parsedMetadata = parsedValue as Record + const rawCount = typeof parsedMetadata.count === 'string' + || typeof parsedMetadata.count === 'number' + ? String(parsedMetadata.count) + : '' + const count = sanitizeSubmissionLimitCount(rawCount) + const isUnlimited = toBoolean(parsedMetadata.unlimited) + const isLimited = toBoolean(parsedMetadata.limit) + || (!isUnlimited && Number(count) > 0) + + return { + count: isLimited + ? count + : '', + mode: isLimited + ? SUBMISSION_LIMIT_LIMITED_MODE + : SUBMISSION_LIMIT_UNLIMITED_MODE, + } + } catch { + return defaultSubmissionLimitMetadata + } +} + +/** + * Serializes the editor state to the legacy submission-limit metadata contract. + * + * @param mode selected unlimited or limited mode. + * @param count digits-only maximum submission count. + * @returns The JSON string persisted in challenge metadata. + * @throws Does not throw. + */ +export function serializeSubmissionLimitMetadata( + mode: SubmissionLimitMode, + count: string | undefined, +): string { + const isLimited = mode === SUBMISSION_LIMIT_LIMITED_MODE + + return JSON.stringify({ + count: isLimited + ? (count || '') + : '', + limit: isLimited + ? 'true' + : 'false', + unlimited: isLimited + ? 'false' + : 'true', + }) +} + +/** + * Detects a limited submission setting that is missing a usable count. + * + * @param metadata current challenge metadata entries. + * @returns `true` when submissions are limited but no positive count is configured. + * @throws Does not throw. + */ +export function isSubmissionLimitCountMissing(metadata: ChallengeMetadata[] | undefined): boolean { + const submissionLimit = parseSubmissionLimitMetadata( + getMetadataValue(metadata, SUBMISSION_LIMIT_METADATA_NAME), + ) + + return submissionLimit.mode === SUBMISSION_LIMIT_LIMITED_MODE + && Number(submissionLimit.count || 0) < 1 +} + +/** + * Normalizes a challenge submission counter that form values expose as an unknown value. + * + * @param value raw counter from a challenge payload or watched form value. + * @returns The counter as a finite number, or `0` when it is missing or not numeric. + * @throws Does not throw. + */ +function toSubmissionCount(value: unknown): number { + const count = Number(value ?? 0) + + return Number.isFinite(count) + ? count + : 0 +} + +/** + * Reports whether members have already uploaded contest or checkpoint submissions. + * + * @param counts challenge or form submission counters. + * @returns `true` when at least one submission of either type exists. + * @throws Does not throw. + */ +export function hasChallengeSubmissions( + counts: { + numOfCheckpointSubmissions?: unknown + numOfSubmissions?: unknown + } | undefined, +): boolean { + return toSubmissionCount(counts?.numOfSubmissions) + + toSubmissionCount(counts?.numOfCheckpointSubmissions) + > 0 +} diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index c1dce85ea..d61c395e1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -45,7 +45,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `tags`: optional string array. - `skills`: required unless billing account is listed in `SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS`. - `reviewer`: optional for task challenges. -- `reviewers`: when using `Save as Draft` from `NEW` status, non-task/non-marathon challenges must include reviewer coverage for configured review phases. If required phases are configured, each phase must have at least one member reviewer with a scorecard. The Screening and Checkpoint Screening configurations and scorecards remain required, but their Screener member assignments may be left empty until after launch; other closed manual reviewer assignments remain required. +- `reviewers`: when using `Save as Draft` from `NEW` status, non-task/non-marathon challenges must include reviewer coverage for configured review phases. If required phases are configured, each phase must have at least one member reviewer with a scorecard. The Screening and Checkpoint Screening configurations and scorecards remain required, but their Screener member assignments may be left empty until after launch. Design `Challenge` reviewers additionally leave the Checkpoint Review, Review, and Approval member assignments optional because the selected copilot is assigned to those private phases during save; other closed manual reviewer assignments remain required. - `AI review configuration`: templates and manual configs autosave separately once valid, switching a template-backed config to manual mode keeps its copied settings but clears the template link on save, and the AI tab becomes read-only after the challenge has submissions. ## Autosave Behavior @@ -85,11 +85,11 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha from `is_test_challenge`, and explicitly persists metadata value `true` or `false`. Test challenges do not generate payments, and authorized modifiers can delete them after they reach a completed or cancelled status. -- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section fetches defaults for the selected timeline template, resolves the API's phase-name-only defaults against the challenge phases, then repairs missing, duplicate, and stale hidden reviewer rows while exposing the Screening and Checkpoint Screening member selectors. Checkpoint Review, Review, and Approval are private and automatically assigned to the selected copilot during save; Design Challenge creation and saving highlight the Copilot field when no copilot is selected. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. +- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section fetches defaults for the selected timeline template, resolves the API's phase-name-only defaults against the challenge phases, then repairs missing, duplicate, and stale hidden reviewer rows while exposing the Screening and Checkpoint Screening member selectors. Checkpoint Review, Review, and Approval are private and automatically assigned to the selected copilot during save; Design Challenge creation and saving highlight the Copilot field when no copilot is selected. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. For Design `Challenge` challenges the advanced view also drops the required marker from the Checkpoint Review, Review, and Approval member selectors, because those private phases are assigned to the selected copilot during save. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-limit compatibility fields. - `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The radio group persists `allowAllRegistrantsToDownloadWinningSubmissions` as the exact string `true` for all challenge registrants or `false` for passing submitters only. New Development challenges default to passing submitters; other new challenges, including Design, default to all registrants. Existing challenges without the metadata retain passing-submitter-only access. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. -- `MaximumSubmissionsField`: submission-limit editor with `Unlimited` (the default) and `Limited` modes. Limited mode reveals a numeric count field, and both modes persist the legacy `submissionLimit` JSON metadata contract consumed by challenge and review applications. Existing limited values are restored without being overwritten, including when a draft-save response omits submission-limit metadata, while missing or malformed metadata is normalized to unlimited after initial resource hydration so copilot restoration completes before autosave/manual-save treats the default as a user change. +- `MaximumSubmissionsField`: submission-limit editor with `Unlimited` (the default) and `Limited` modes. Limited mode reveals a numeric count field, and both modes persist the legacy `submissionLimit` JSON metadata contract consumed by challenge and review applications. The selection and count are display-only fields, so they are re-seeded from the persisted metadata on every render; that keeps the saved limit visible after the challenge loads and after a draft save resets the form. Existing limited values are restored without being overwritten, including when a draft-save response omits submission-limit metadata, while missing or malformed metadata is normalized to unlimited after initial resource hydration so copilot restoration completes before autosave/manual-save treats the default as a user change. Once the challenge has at least one contest or checkpoint submission the mode and count become read-only, because review scorecards are created from the limit that applied when members submitted. Limited mode requires a count of at least 1: the challenge editor schema validates the persisted `submissionLimit` metadata and reports a missing count on the visible `Limit count` field, so saving, autosaving, and launching are blocked until the count is entered. The rule is skipped when the limit is not configurable, which keeps non-Design challenges and challenges that already have submissions saveable. - `ChallengeDescriptionField`: public markdown spec editor with a `Copy spec` action that copies the current Markdown in both edit and read-only view modes. - `ChallengePrivateDescriptionField`: optional private markdown spec editor. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx index 154691d68..d1fc9eb4a 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx @@ -512,9 +512,36 @@ jest.mock('./ChallengePrivateDescriptionField', () => ({ /> ), })) -jest.mock('./ChallengePrizesField', () => ({ - ChallengePrizesField: () => <>, -})) +jest.mock('./ChallengePrizesField', () => { + const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + + return { + ChallengePrizesField: function ChallengePrizesField() { + const formContext = reactHookForm.useFormContext() + const handleSetPlacementPrize = (): void => { + formContext.setValue('prizeSets', [{ + prizes: [{ + type: 'USD', + value: 500, + }], + type: 'PLACEMENT', + }], { + shouldDirty: true, + shouldValidate: true, + }) + } + + return ( + + ) + }, + } +}) jest.mock('./ChallengeSkillsField', () => ({ ChallengeSkillsField: () => <>, })) @@ -948,6 +975,13 @@ describe('ChallengeEditorForm', () => { }, typeId: 'design-challenge-type-id', } as Challenge + const designChallengeWithDeferredReviewers = { + ...designChallengeWithDeferredScreeners, + reviewers: designChallengeWithDeferredScreeners.reviewers?.map(reviewer => ({ + ...reviewer, + memberId: undefined, + })), + } as Challenge const twoRoundDesignChallengeWithCopilotReviewers = { ...validDraftChallenge, copilot: 'TCConnCopilot', @@ -1430,6 +1464,59 @@ describe('ChallengeEditorForm', () => { .toBeNull() }) + it('shows budget approval actions after saving a new challenge without persisted prizes', async () => { + const user = userEvent.setup() + const managerContextValue: WorkAppContextModel = { + ...copilotContextValue, + isManager: true, + userRoles: ['manager'], + } + // Challenges in 'New' status are created before the prizes section is available, so the + // fetched challenge snapshot still has no persisted prize sets while the form is edited. + const newChallengeWithoutPrizes = { + ...validNewChallenge, + approvalStatus: 'PENDING_APPROVAL', + prizeSets: undefined, + } as Challenge + const renderForm = (isReadOnly: boolean): React.ReactElement => ( + + + + + + ) + + mockedPatchChallenge.mockResolvedValue({ + ...validNewChallenge, + approvalStatus: 'PENDING_APPROVAL', + status: 'DRAFT', + }) + + const renderResult = render(renderForm(false)) + + await user.click(screen.getByRole('button', { name: 'Mock Set Placement Prize' })) + await user.click(screen.getByRole('button', { name: 'Save as Draft' })) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledWith('12345', expect.objectContaining({ + status: 'DRAFT', + })) + }) + + // The saved form stays mounted while the successful save redirects to the read-only view. + renderResult.rerender(renderForm(true)) + + expect(screen.getByRole('button', { name: 'Approve Budget' })) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Reject Budget' })) + .toBeInTheDocument() + }) + it('hides the editable timeline section for task challenges in edit mode', () => { mockedUseFetchChallengeTypes.mockReturnValue({ challengeTypes: [{ @@ -2133,6 +2220,75 @@ describe('ChallengeEditorForm', () => { .not.toHaveBeenCalledWith('Please fix validation errors before launching') }) + it('launches a design draft before copilot assigned review members exist', async () => { + let launchAction: (() => Promise) | undefined + + mockedUseFetchChallengeTracks.mockReturnValue({ + isLoading: false, + tracks: [{ + id: 'design-track-id', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [{ + abbreviation: 'CH', + id: 'design-challenge-type-id', + name: 'Challenge', + }], + isLoading: false, + }) + mockedUseFetchProjectBillingAccount.mockReturnValue({ + billingAccount: { + active: true, + id: '80001063', + totalBudgetRemaining: 500, + }, + isLoading: false, + }) + mockedPatchChallenge.mockResolvedValue({ + ...designChallengeWithDeferredReviewers, + status: 'ACTIVE', + }) + + render( + + { + launchAction = action + }} + /> + , + ) + + await waitFor(() => { + expect(launchAction) + .toEqual(expect.any(Function)) + }) + + await act(async () => { + await launchAction?.() + }) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledWith('12345', expect.objectContaining({ + reviewers: expect.arrayContaining([ + expect.objectContaining({ + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + }), + ]), + status: 'ACTIVE', + })) + }) + expect(mockedShowErrorToast) + .not.toHaveBeenCalledWith('Please fix validation errors before launching') + }) + it('launches a read-only draft when manual reviewer assignments exist only in resources', async () => { let launchAction: (() => Promise) | undefined diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index b71d0acf1..7616fe245 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -54,6 +54,7 @@ import { ChallengeEditorFormData, ChallengePhase, ChallengeType, + PrizeSet, Resource, ResourceRole, Reviewer, @@ -61,6 +62,7 @@ import { } from '../../../../lib/models' import { challengeEditorSchema, + ChallengeEditorValidationContext, } from '../../../../lib/schemas/challenge-editor.schema' import { createChallenge, @@ -90,11 +92,14 @@ import { getMetadataValue, setMetadataValue, } from '../../../../lib/utils/metadata.utils' -import { isScreenerAssignmentOptional } from '../../../../lib/utils/reviewer.utils' +import { isReviewerAssignmentOptional } from '../../../../lib/utils/reviewer.utils' import { getProjectBillingAccountChallengeErrorMessage, getProjectBillingAccountChallengeIssue, } from '../../../../lib/utils/project-billing-account.utils' +import { + hasChallengeSubmissions, +} from '../../../../lib/utils/submission-limit.utils' import { resolveMatchingChallengeViewPath, } from '../ChallengeEditorPage.utils' @@ -1401,6 +1406,7 @@ async function hydratePersistedManualReviewerAssignments( function getReviewerEntryValidationError( reviewer: Reviewer | undefined, phases: ChallengeEditorFormData['phases'], + isDesignChallenge: boolean, ): string | undefined { if (!reviewer) { return undefined @@ -1423,7 +1429,7 @@ function getReviewerEntryValidationError( if ( reviewer.shouldOpenOpportunity !== true - && !isScreenerAssignmentOptional(reviewer, phases) + && !isReviewerAssignmentOptional(reviewer, phases, isDesignChallenge) ) { const requiredAssignedMembers = getAssignedMemberReviewerValidationSlots(reviewer) .slice(0, reviewerCount) @@ -1465,6 +1471,7 @@ interface ReviewerValidationOptions { challengeTypeAbbreviation?: string challengeTypeName?: string requiredReviewersErrorMessage: string + isDesignChallenge: boolean isTaskChallenge: boolean } @@ -1493,7 +1500,11 @@ function getReviewerValidationError( } const invalidReviewer = reviewers - .map(reviewer => getReviewerEntryValidationError(reviewer, formData.phases)) + .map(reviewer => getReviewerEntryValidationError( + reviewer, + formData.phases, + options.isDesignChallenge, + )) .find(Boolean) if (invalidReviewer) { return invalidReviewer @@ -1887,6 +1898,19 @@ function getApprovalStatusText(approvalStatus: string | undefined): string { return 'Pending Approval' } +/** + * Detects whether a persisted challenge snapshot already stores at least one prize. + * + * @param prizeSets prize sets returned by challenge-api for the challenge. + * @returns `true` when any prize set contains at least one prize. + * @remarks Used by the budget approval actions so approvers only act on prizes + * that challenge-api already persisted. + */ +function hasPrizeSetWithPrizes(prizeSets: PrizeSet[] | undefined): boolean { + return Array.isArray(prizeSets) + && prizeSets.some(prizeSet => Array.isArray(prizeSet?.prizes) && prizeSet.prizes.length > 0) +} + interface TaskLaunchValidationParams { assignedMemberId?: unknown currentStatus?: unknown @@ -2056,11 +2080,21 @@ export const ChallengeEditorForm: FC = ( const [scorerHasError, setScorerHasError] = useState(false) const [isUpdatingApproval, setIsUpdatingApproval] = useState(false) const [rejectionReasonInput, setRejectionReasonInput] = useState('') + // Tracks the prize sets challenge-api has stored so budget approval actions can appear + // right after a save instead of waiting for the challenge prop to be refetched. + const [persistedPrizeSets, setPersistedPrizeSets] = useState( + props.challenge?.prizeSets, + ) const [showApproveBudgetModal, setShowApproveBudgetModal] = useState(false) const [showRejectBudgetModal, setShowRejectBudgetModal] = useState(false) const [resolvedPaymentCreator, setResolvedPaymentCreator] = useState() + const validationContextRef = useRef({ + isDesignChallenge: false, + isSubmissionLimitConfigurable: false, + }) const formMethods = useForm({ + context: validationContextRef.current, defaultValues: applyProjectBillingToChallengeFormData( transformChallengeToFormData(props.challenge), projectBillingAccount, @@ -2316,10 +2350,8 @@ export const ChallengeEditorForm: FC = ( projectResult.project, ) const hasPersistedPrizeSets = useMemo( - () => Array.isArray(props.challenge?.prizeSets) - && props.challenge?.prizeSets - .some(prizeSet => Array.isArray(prizeSet?.prizes) && prizeSet.prizes.length > 0), - [props.challenge?.prizeSets], + () => hasPrizeSetWithPrizes(persistedPrizeSets), + [persistedPrizeSets], ) const hasUnsavedPrizeSetChanges = useMemo( () => { @@ -2386,6 +2418,35 @@ export const ChallengeEditorForm: FC = ( && !workAppContext.isManager const shouldUseSimplifiedDesignReview = isDesignTrackSelected && isChallengeTypeSelected + + useEffect(() => { + if (validationContextRef.current.isDesignChallenge === shouldUseSimplifiedDesignReview) { + return + } + + validationContextRef.current.isDesignChallenge = shouldUseSimplifiedDesignReview + trigger('reviewers') + .catch(() => undefined) + }, [ + shouldUseSimplifiedDesignReview, + trigger, + ]) + + /* + * The submission limit is only editable inside Design submission settings, and it is locked + * once members have uploaded submissions. Publishing that state to the validation context keeps + * the required-count rule from blocking saves on challenges where the copilot cannot change it. + */ + const isSubmissionLimitConfigurable = showSubmissionSettingsSection + && !hasChallengeSubmissions({ + numOfCheckpointSubmissions: values.numOfCheckpointSubmissions, + numOfSubmissions: values.numOfSubmissions, + }) + + useEffect(() => { + validationContextRef.current.isSubmissionLimitConfigurable = isSubmissionLimitConfigurable + }, [isSubmissionLimitConfigurable]) + /** * Validates the copilot required for hidden private Design reviewer assignments. * @@ -2909,6 +2970,10 @@ export const ChallengeEditorForm: FC = ( challengeRef.current = props.challenge }, [props.challenge]) + useEffect(() => { + setPersistedPrizeSets(props.challenge?.prizeSets) + }, [props.challenge?.prizeSets]) + useEffect(() => { currentChallengeIdRef.current = currentChallengeId }, [currentChallengeId]) @@ -3612,6 +3677,8 @@ export const ChallengeEditorForm: FC = ( } } + setPersistedPrizeSets(savedChallengeSnapshot.prizeSets) + const persistedFormData = applyProjectBillingToChallengeFormData( transformChallengeToFormData(savedChallengeSnapshot), resolvedProjectBillingAccount, @@ -3967,6 +4034,7 @@ export const ChallengeEditorForm: FC = ( const reviewerValidationError = getReviewerValidationError(formData, { challengeTypeAbbreviation: resolvedChallengeTypeAbbreviation, challengeTypeName: resolvedChallengeTypeName, + isDesignChallenge: shouldUseSimplifiedDesignReview, isTaskChallenge, requiredReviewersErrorMessage: 'Reviewers are required for configured review phases before saving as draft.', diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx index 29219fa53..635facada 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx @@ -2,6 +2,7 @@ import { FC, useCallback, + useState, } from 'react' import { render, @@ -13,14 +14,25 @@ import { FormProvider, useForm, } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' import { ChallengeEditorFormData, ChallengeMetadata, } from '../../../../../lib/models' +import { challengeAdvancedOptionsSchema } from '../../../../../lib/schemas/challenge-editor.schema' +import { + SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, +} from '../../../../../lib/utils/submission-limit.utils' import { MaximumSubmissionsField } from './MaximumSubmissionsField' +jest.mock('~/config', () => ({ + EnvironmentConfig: new Proxy({}, { + get: (): unknown => 'https://www.topcoder-dev.com', + }), +}), { virtual: true }) + let mockStaleMetadata: ChallengeMetadata[] | undefined jest.mock('react-hook-form', () => { @@ -46,21 +58,49 @@ interface TestHarnessProps { value: string }> deferDirty?: boolean + numOfCheckpointSubmissions?: number + numOfSubmissions?: number onMetadataWrite?: () => void + staleSubmissionLimitMode?: string + validateSubmissionLimit?: boolean } const TestHarness: FC = (props: TestHarnessProps) => { + const [savedCount, setSavedCount] = useState(0) const formMethods = useForm({ + context: { isSubmissionLimitConfigurable: true }, defaultValues: { description: 'Public challenge specification', metadata: props.defaultMetadata, name: 'Design challenge', + numOfCheckpointSubmissions: props.numOfCheckpointSubmissions, + numOfSubmissions: props.numOfSubmissions, skills: [], tags: [], trackId: 'design-track', typeId: 'design-type', - }, + ...(props.staleSubmissionLimitMode + ? { submissionLimitCount: '', submissionLimitMode: props.staleSubmissionLimitMode } + : {}), + } as ChallengeEditorFormData, + mode: 'onChange', + resolver: props.validateSubmissionLimit + ? (yupResolver(challengeAdvancedOptionsSchema) as never) + : undefined, }) + const resetToPersistedValues = useCallback(() => { + // Mirrors the editor resetting the form from saved challenge data, which drops the + // display-only submission-limit fields. + formMethods.reset({ + description: 'Public challenge specification', + metadata: props.defaultMetadata, + name: 'Design challenge', + skills: [], + tags: [], + trackId: 'design-track', + typeId: 'design-type', + } as ChallengeEditorFormData) + }, [formMethods, props.defaultMetadata]) const setValue = useCallback(( name, value, @@ -76,6 +116,12 @@ const TestHarness: FC = (props: TestHarnessProps) => { props.onMetadataWrite, ]) const values = formMethods.watch() + const saveChallenge = useCallback(() => { + formMethods.handleSubmit(() => { + setSavedCount(currentSavedCount => currentSavedCount + 1) + })() + .catch(() => undefined) + }, [formMethods]) return ( = (props: TestHarnessProps) => { setValue={setValue} > + + + {String(savedCount)} {String(formMethods.formState.isDirty)} {JSON.stringify(values.metadata || [])} @@ -361,4 +410,229 @@ describe('MaximumSubmissionsField', () => { expect(onMetadataWrite) .toHaveBeenCalledTimes(1) }) + it('restores the persisted limit when the editor resets the form', async () => { + const user = userEvent.setup() + const limitedMetadata = [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '2', + limit: 'true', + unlimited: 'false', + }), + }] + + render() + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + }) + await user.click(screen.getByRole('button', { name: 'Reset form' })) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + expect((screen.getByRole('spinbutton', { name: 'Limit count' }) as HTMLInputElement).value) + .toBe('2') + }) + }) + + it('replaces a stale selection with the persisted submission limit', async () => { + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + expect((screen.getByRole('spinbutton', { name: 'Limit count' }) as HTMLInputElement).value) + .toBe('3') + }) + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).checked) + .toBe(false) + }) + + it('locks the submission limit once a submission has been uploaded', async () => { + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + }) + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).disabled) + .toBe(true) + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).disabled) + .toBe(true) + expect((screen.getByRole('spinbutton', { name: 'Limit count' }) as HTMLInputElement).disabled) + .toBe(true) + expect(screen.getByText( + 'The submission limit cannot be changed after the first submission is uploaded.', + )) + .toBeTruthy() + }) + + it('locks the submission limit once a checkpoint submission has been uploaded', async () => { + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).checked) + .toBe(true) + }) + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).disabled) + .toBe(true) + expect(screen.getByText( + 'The submission limit cannot be changed after the first submission is uploaded.', + )) + .toBeTruthy() + }) + + it('keeps the submission limit editable while no submission exists', async () => { + const user = userEvent.setup() + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).checked) + .toBe(true) + }) + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).disabled) + .toBe(false) + expect(screen.queryByText( + 'The submission limit cannot be changed after the first submission is uploaded.', + )) + .toBeNull() + + await user.click(screen.getByRole('radio', { name: 'Limited' })) + + expect(await screen.findByRole('spinbutton', { name: 'Limit count' })) + .toBeTruthy() + }) + it('blocks saving a limited submission setting without a count', async () => { + const user = userEvent.setup() + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).checked) + .toBe(true) + }) + await user.click(screen.getByRole('radio', { name: 'Limited' })) + + expect(await screen.findByText(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE)) + .toBeTruthy() + + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByText(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE)) + .toBeTruthy() + }) + expect(screen.getByTestId('saved-count').textContent) + .toBe('0') + }) + + it('saves once a limited submission count is entered', async () => { + const user = userEvent.setup() + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + }) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByText(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE)) + .toBeTruthy() + }) + expect(screen.getByTestId('saved-count').textContent) + .toBe('0') + + await user.type(screen.getByRole('spinbutton', { name: 'Limit count' }), '2') + + await waitFor(() => { + expect(screen.queryByText(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE)) + .toBeNull() + }) + + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByTestId('saved-count').textContent) + .toBe('1') + }) + }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx index 0f95c69c2..ab74380ec 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx @@ -22,21 +22,26 @@ import { getMetadataValue, setMetadataValue, } from '../../../../../lib/utils/metadata.utils' +import { + hasChallengeSubmissions, + parseSubmissionLimitMetadata, + sanitizeSubmissionLimitCount, + serializeSubmissionLimitMetadata, + SubmissionLimitMode, + SUBMISSION_LIMIT_LIMITED_MODE, + SUBMISSION_LIMIT_METADATA_NAME, + SUBMISSION_LIMIT_UNLIMITED_MODE, +} from '../../../../../lib/utils/submission-limit.utils' import styles from './MaximumSubmissionsField.module.scss' -const SUBMISSION_LIMIT_FIELD = 'submissionLimit' +const SUBMISSION_LIMIT_FIELD = SUBMISSION_LIMIT_METADATA_NAME const SUBMISSION_LIMIT_COUNT_FIELD = 'submissionLimitCount' const SUBMISSION_LIMIT_MODE_FIELD = 'submissionLimitMode' -const LIMITED_MODE = 'limited' -const UNLIMITED_MODE = 'unlimited' - -type SubmissionLimitMode = typeof LIMITED_MODE | typeof UNLIMITED_MODE - -interface SubmissionLimitMetadata { - count: string - mode: SubmissionLimitMode -} +const LIMITED_MODE = SUBMISSION_LIMIT_LIMITED_MODE +const UNLIMITED_MODE = SUBMISSION_LIMIT_UNLIMITED_MODE +const SUBMITTED_LIMIT_LOCK_HINT + = 'The submission limit cannot be changed after the first submission is uploaded.' interface SubmissionLimitFormData extends ChallengeEditorFormData { submissionLimitCount?: string @@ -54,11 +59,6 @@ const submissionLimitOptions: FormRadioOption[] = [ }, ] -const defaultSubmissionLimitMetadata: SubmissionLimitMetadata = { - count: '', - mode: UNLIMITED_MODE, -} - interface MaximumSubmissionsFieldProps { /** * Defers automatic metadata normalization while the editor restores persisted assignments. @@ -68,100 +68,6 @@ interface MaximumSubmissionsFieldProps { deferDirty?: boolean } -/** - * Converts legacy string and boolean flags to a strict boolean. - * - * @param value legacy metadata flag. - * @returns Whether the flag is enabled. - * @throws Does not throw. - */ -function toBoolean(value: unknown): boolean { - return value === true || value === 'true' -} - -/** - * Removes non-numeric characters from a submission-limit count. - * - * @param value raw form or metadata value. - * @returns The digits-only submission count. - * @throws Does not throw. - */ -function sanitizeSubmissionLimitCount(value: string): string { - return value.replace(/[^\d]/g, '') -} - -/** - * Parses the legacy JSON string stored in `submissionLimit` challenge metadata. - * - * Missing, malformed, and explicitly non-limited values use the product default of unlimited. - * A positive count without either flag is retained for compatibility with older payloads. - * - * @param value serialized challenge metadata value. - * @returns The submission-limit mode and sanitized count used by the form. - * @throws Does not throw; malformed metadata falls back to unlimited. - */ -function parseSubmissionLimitMetadata(value: string | undefined): SubmissionLimitMetadata { - if (!value) { - return defaultSubmissionLimitMetadata - } - - try { - const parsedValue = JSON.parse(value) as unknown - - if (!parsedValue || typeof parsedValue !== 'object' || Array.isArray(parsedValue)) { - return defaultSubmissionLimitMetadata - } - - const parsedMetadata = parsedValue as Record - const rawCount = typeof parsedMetadata.count === 'string' - || typeof parsedMetadata.count === 'number' - ? String(parsedMetadata.count) - : '' - const count = sanitizeSubmissionLimitCount(rawCount) - const isUnlimited = toBoolean(parsedMetadata.unlimited) - const isLimited = toBoolean(parsedMetadata.limit) - || (!isUnlimited && Number(count) > 0) - - return { - count: isLimited - ? count - : '', - mode: isLimited - ? LIMITED_MODE - : UNLIMITED_MODE, - } - } catch { - return defaultSubmissionLimitMetadata - } -} - -/** - * Serializes the editor state to the legacy submission-limit metadata contract. - * - * @param mode selected unlimited or limited mode. - * @param count digits-only maximum submission count. - * @returns The JSON string persisted in challenge metadata. - * @throws Does not throw. - */ -function serializeSubmissionLimitMetadata( - mode: SubmissionLimitMode, - count: string | undefined, -): string { - const isLimited = mode === LIMITED_MODE - - return JSON.stringify({ - count: isLimited - ? (count || '') - : '', - limit: isLimited - ? 'true' - : 'false', - unlimited: isLimited - ? 'false' - : 'true', - }) -} - /** * Renders and persists the design-challenge submission-limit setting. * @@ -180,9 +86,10 @@ export const MaximumSubmissionsField: FC = ( control, getValues, setValue, + trigger, }: Pick< UseFormReturn, - 'control' | 'getValues' | 'setValue' + 'control' | 'getValues' | 'setValue' | 'trigger' > = useFormContext() const metadata = useWatch({ control, @@ -196,7 +103,19 @@ export const MaximumSubmissionsField: FC = ( control, name: SUBMISSION_LIMIT_COUNT_FIELD, }) as string | undefined + const numOfSubmissions = useWatch({ + control, + name: 'numOfSubmissions', + }) as number | string | undefined + const numOfCheckpointSubmissions = useWatch({ + control, + name: 'numOfCheckpointSubmissions', + }) as number | string | undefined const submissionLimitValue = getMetadataValue(metadata, SUBMISSION_LIMIT_FIELD) + const isLocked = hasChallengeSubmissions({ + numOfCheckpointSubmissions, + numOfSubmissions, + }) const persistSubmissionLimitMetadata = useCallback(( mode: SubmissionLimitMode, @@ -226,43 +145,41 @@ export const MaximumSubmissionsField: FC = ( setValue, ]) + /* + * The selection and count are display-only fields, so every editor form reset drops them + * without changing any value this component watches. Running on each render re-seeds them + * from the current challenge metadata, which keeps the saved limit visible after the + * challenge loads and after a draft save resets the form. + */ useEffect(() => { - if (submissionLimitMode !== undefined && submissionLimitCount !== undefined) { - return - } - - const currentSubmissionLimitMetadata = parseSubmissionLimitMetadata( + const currentSubmissionLimit = parseSubmissionLimitMetadata( getMetadataValue(getValues('metadata'), SUBMISSION_LIMIT_FIELD), ) - if (submissionLimitMode === undefined) { - setValue( - SUBMISSION_LIMIT_MODE_FIELD, - currentSubmissionLimitMetadata.mode, - { - shouldDirty: false, - shouldValidate: false, - }, - ) + if ( + submissionLimitMode === currentSubmissionLimit.mode + && (submissionLimitCount || '') === currentSubmissionLimit.count + ) { + return } - if (submissionLimitCount === undefined) { - setValue( - SUBMISSION_LIMIT_COUNT_FIELD, - currentSubmissionLimitMetadata.count, - { - shouldDirty: false, - shouldValidate: false, - }, - ) - } - }, [ - getValues, - setValue, - submissionLimitCount, - submissionLimitMode, - submissionLimitValue, - ]) + setValue( + SUBMISSION_LIMIT_MODE_FIELD, + currentSubmissionLimit.mode, + { + shouldDirty: false, + shouldValidate: false, + }, + ) + setValue( + SUBMISSION_LIMIT_COUNT_FIELD, + currentSubmissionLimit.count, + { + shouldDirty: false, + shouldValidate: false, + }, + ) + }) useEffect(() => { if (props.deferDirty) { @@ -295,6 +212,16 @@ export const MaximumSubmissionsField: FC = ( submissionLimitValue, ]) + /* + * The required-count rule is validated from the persisted metadata, which this component + * writes after React Hook Form has already scheduled its own change validation. Revalidating + * the count field here keeps the error in step with the value that would be saved. + */ + const revalidateSubmissionLimitCount = useCallback((): void => { + trigger(SUBMISSION_LIMIT_COUNT_FIELD) + .catch(() => undefined) + }, [trigger]) + const handleModeChange = useCallback((value: boolean | string): void => { if (value !== LIMITED_MODE && value !== UNLIMITED_MODE) { return @@ -319,19 +246,29 @@ export const MaximumSubmissionsField: FC = ( ? count : '', ) + revalidateSubmissionLimitCount() }, [ getValues, persistSubmissionLimitMetadata, + revalidateSubmissionLimitCount, setValue, ]) const handleCountChange = useCallback((count: string): void => { persistSubmissionLimitMetadata(LIMITED_MODE, count) - }, [persistSubmissionLimitMetadata]) + revalidateSubmissionLimitCount() + }, [ + persistSubmissionLimitMetadata, + revalidateSubmissionLimitCount, + ]) return (
= ( ? ( { .toHaveProperty('dataset.required', 'true') }) + it('marks copilot assigned Design Challenge review assignments optional', () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [ + { + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }, + ], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [ + { + id: 'type-1', + name: 'Challenge', + }, + ], + }) + + render( + , + ) + + expect(screen.getByTestId('reviewers.0.memberId')) + .toHaveProperty('dataset.required', 'false') + expect(screen.getByTestId('reviewers.1.memberId')) + .toHaveProperty('dataset.required', 'false') + expect(screen.getByTestId('reviewers.2.memberId')) + .toHaveProperty('dataset.required', 'false') + }) + it('assigns one simplified screener selection to checkpoint and final screening roles', async () => { const mutateResources = jest.fn() .mockResolvedValue(undefined) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index a34561357..e0e1e1659 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -53,7 +53,7 @@ import { calculateEstimatedReviewerCost, getFirstPlacePrizeValue, } from '../../../../../lib/utils' -import { isScreenerAssignmentOptional } from '../../../../../lib/utils/reviewer.utils' +import { isReviewerAssignmentOptional } from '../../../../../lib/utils/reviewer.utils' import { isAiReviewer } from './reviewers-field.utils' import { @@ -94,6 +94,7 @@ const SCREENER_ROLE_NAME_BY_PHASE_KEY: Record = { checkpointscreening: 'Checkpoint Screener', screening: 'Screener', } +const CHALLENGE_TYPE_CHALLENGE_KEY = 'challenge' const DESIGN_COPILOT_REVIEW_PHASE_KEYS = new Set([ 'approval', 'checkpointreview', @@ -1493,6 +1494,8 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro }, [challengeTypes, normalizedTypeId], ) + const isDesignChallengeSelected = isDesignTrackSelected + && normalizeKey(selectedScorecardType) === CHALLENGE_TYPE_CHALLENGE_KEY const isLoading = isScorecardsLoading const reviewersValidationError = typeof reviewersFieldState.error?.message === 'string' ? reviewersFieldState.error.message @@ -2591,7 +2594,7 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro if (props.screenerOnly) { const isScreenerAssignmentRequired = screenerReviewerEntries.some(entry => ( - !isScreenerAssignmentOptional(entry.reviewer, phases) + !isReviewerAssignmentOptional(entry.reviewer, phases, isDesignChallengeSelected) )) const isScreenerFieldLoading = resourceRolesResult.isLoading || challengeResourcesResult.isLoading @@ -2682,7 +2685,11 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro || index const reviewerKey = `${reviewerPrefix}-${reviewerIdentity}` const shouldDisablePublicOpportunity = isDesignTrackSelected - const isMemberAssignmentOptional = isScreenerAssignmentOptional(reviewer, phases) + const isMemberAssignmentOptional = isReviewerAssignmentOptional( + reviewer, + phases, + isDesignChallengeSelected, + ) return (
({ Button: (props: { @@ -82,6 +84,9 @@ jest.mock('../../../../../lib/contexts', () => { jest.mock('../../../../../lib/hooks', () => ({ useDownloadAllSubmissions: (): unknown => mockUseDownloadAllSubmissions(), useDownloadSubmission: (): unknown => mockUseDownloadSubmission(), + useFetchSubmissionDuplicates: (...args: unknown[]): unknown => ( + mockUseFetchSubmissionDuplicates(...args) + ), useFetchSubmissions: (...args: unknown[]): unknown => mockUseFetchSubmissions(...args), })) @@ -92,6 +97,7 @@ jest.mock('../../../../../lib/services', () => ({ jest.mock('../../../../../lib/utils', () => ({ canDownloadSubmissions: (): boolean => false, canViewMarathonMatchRunnerLogs: (): boolean => false, + canViewSubmissionDuplicates: (...args: unknown[]): unknown => mockCanViewSubmissionDuplicates(...args), getSubmissionFinalScore: (): number => 0, getSubmissionInitialScore: (): number => 0, getSubmissionProvisionalScore: (): number => 0, @@ -180,6 +186,11 @@ describe('SubmissionsSection', () => { jest.clearAllMocks() mockIsMarathonMatchChallenge.mockReturnValue(true) + mockCanViewSubmissionDuplicates.mockReturnValue(true) + mockUseFetchSubmissionDuplicates.mockReturnValue({ + duplicatesBySubmissionId: {}, + isLoading: false, + }) mockUseDownloadAllSubmissions.mockReturnValue({ downloadAll: jest.fn(), isDownloading: false, @@ -273,4 +284,39 @@ describe('SubmissionsSection', () => { expect(screen.queryByText('bravo-provisional-submission')) .toBeNull() }) + describe('duplicate detection', () => { + it('requests duplicates for the visible submissions when the role allows it', () => { + render( + , + ) + + expect(mockUseFetchSubmissionDuplicates) + .toHaveBeenCalledWith( + 'challenge-1', + expect.arrayContaining([ + 'alpha-system-submission', + 'bravo-provisional-submission', + 'charlie-example-submission', + ]), + true, + ) + }) + + it('disables the duplicates request for roles the API rejects', () => { + mockCanViewSubmissionDuplicates.mockReturnValue(false) + + render( + , + ) + + expect(mockUseFetchSubmissionDuplicates) + .toHaveBeenCalledWith('challenge-1', expect.any(Array), false) + }) + }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx index ace5be142..16b2b274b 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx @@ -24,6 +24,8 @@ import { WorkAppContext } from '../../../../../lib/contexts' import { useDownloadAllSubmissions, useDownloadSubmission, + useFetchSubmissionDuplicates, + type UseFetchSubmissionDuplicatesResult, useFetchSubmissions, } from '../../../../../lib/hooks' import { Challenge, Submission } from '../../../../../lib/models' @@ -32,6 +34,7 @@ import type { MemberProfile } from '../../../../../lib/services' import { canDownloadSubmissions, canViewMarathonMatchRunnerLogs, + canViewSubmissionDuplicates, getSubmissionFinalScore, getSubmissionInitialScore, getSubmissionProvisionalScore, @@ -601,6 +604,26 @@ export const SubmissionsSection: FC = ( const isMembersLoading = memberIdsToLoad.length > 0 const paginationTotal = sortedSubmissions.length + // Duplicates are only requested for the rows currently on screen, and only + // for roles the review API answers for. + const canCheckDuplicates = canViewSubmissionDuplicates(workAppContext.userRoles) + const duplicateCheckSubmissionIds = useMemo( + () => [ + ...paginatedSubmissions, + ...sortedCheckpointSubmissions, + ] + .map(submission => normalizeValue(submission.id)) + .filter(Boolean), + [paginatedSubmissions, sortedCheckpointSubmissions], + ) + const { + duplicatesBySubmissionId, + }: UseFetchSubmissionDuplicatesResult = useFetchSubmissionDuplicates( + props.challengeId, + duplicateCheckSubmissionIds, + canCheckDuplicates, + ) + const handleDownloadAll = useCallback(async (): Promise => { try { await downloadAllResult.downloadAll(toDownloadAllItems(submissionsResult.submissions)) @@ -793,6 +816,7 @@ export const SubmissionsSection: FC = ( canDownloadSubmissions={canDownload} canViewRunnerLogs={canViewRunnerLogs} challengeId={props.challengeId} + duplicatesBySubmissionId={duplicatesBySubmissionId} isLoading={submissionsResult.isLoading} isLoadingMembers={isMembersLoading} onDownloadSubmission={handleDownloadSubmission} @@ -815,6 +839,7 @@ export const SubmissionsSection: FC = ( canDownloadSubmissions={canDownload} canViewRunnerLogs={canViewRunnerLogs} challengeId={props.challengeId} + duplicatesBySubmissionId={duplicatesBySubmissionId} isLoading={false} isLoadingMembers={isMembersLoading} onDownloadSubmission={handleDownloadSubmission} diff --git a/src/config/constants.ts b/src/config/constants.ts index 62a460111..2c4f94c4e 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -9,6 +9,7 @@ export enum AppSubdomain { wallet = 'wallet', walletAdmin = 'wallet-admin', copilots = 'copilots', + campus = 'campus', admin = 'system-admin', review = 'review', calendar = 'calendar', @@ -32,6 +33,7 @@ export enum ToolTitle { wallet = 'Wallet', walletAdmin = 'Wallet Admin', copilots = 'Copilots', + campus = 'Campus', admin = 'Admin', review = 'Review', calendar = 'Calendar', diff --git a/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss b/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss index 3e2b3d54e..8e67b7d7d 100644 --- a/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss +++ b/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss @@ -63,7 +63,6 @@ max-width: 100%; max-height: 100%; object-fit: cover; - min-width: 150px; aspect-ratio: 1 / 1; border-radius: 50%; diff --git a/tsconfig.paths.json b/tsconfig.paths.json index 54e6a260e..809bc6b7c 100644 --- a/tsconfig.paths.json +++ b/tsconfig.paths.json @@ -27,6 +27,9 @@ "@wallet/*": [ "./src/apps/wallet/src/*" ], + "@campus/*": [ + "./src/apps/campus/src/*" + ], "@walletAdmin/*": [ "./src/apps/wallet-admin/src/*" ],